Guides
Jobs & webhooks
Big jobs run in the background. Two ways to get the result:
- Polling: call
GET /api/v1/jobs/{id}every 5–10 seconds untilstatusisdone,cancelledorfailed. - Webhook: add
"webhookUrl": "https://…"to the POST. When the job is done, we POST the result to that URL.
For small jobs, "wait": 30 on the POST is easiest: the answer comes back finished.
Job status
queued → running → done. A job can also be paused (our checks wait for a mail server; it goes on by itself), cancelled (you or the app stopped it) or failed.
What we send
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-Moleman-Event: job.completed
X-Moleman-Delivery: dlv_8f2c…
X-Moleman-Timestamp: 1762344014
X-Moleman-Signature: t=1762344014,v1=5f2c9b…{
"id": "dlv_8f2c…",
"event": "job.completed",
"livemode": true,
"jobId": 981,
"listId": 377,
"action": "findEmail",
"status": "done",
"creditsCharged": 1,
"total": 1,
"results": [{ "email": "sarah.miller@brightsmiledental.com", "result": "Works", "…": "…" }],
"hasMore": false,
"resultsUrl": "/api/v1/jobs/981"
}Events: job.completed (find-contacts, verify, deep-search), search.completed (business search, with runId and the search's run), ping (the test endpoint). A body carries up to 1,000 results; with more, hasMore is true and you page through resultsUrl.
Check the signature
Every webhook is signed with your account's signing secret (whsec_…, on the API page), or with the webhookSecret you sent with the request. The signature is an HMAC-SHA256 of <timestamp>.<raw body>:
- Read the raw body as bytes. Do not parse and re-encode it first.
- Split
X-Moleman-Signatureintotandv1. - Refuse it if
tis more than 5 minutes away from now (this blocks replays). - Compute HMAC-SHA256(secret,
t + "." + raw body) as hex and compare it withv1in constant time.
Node.js
// Check that a webhook really comes from Moleman, then use it. Node 18 or newer, no packages.
// Run: MOLEMAN_WEBHOOK_SECRET=whsec_... node verify-webhook.mjs (listens on port 8787)
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
const TOLERANCE_SECONDS = 300; // refuse anything older than 5 minutes (replays)
/** rawBody: the exact bytes we sent (do not parse and re-stringify). header: X-Moleman-Signature. */
export function verifyMolemanSignature(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(String(header ?? '').split(',').map((part) => part.trim().split('=', 2)));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || !parts.v1) return false;
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1, 'utf8');
return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected, 'utf8'));
}
const seen = new Set(); // X-Moleman-Delivery ids you already handled (a retry has the same id)
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const secret = process.env.MOLEMAN_WEBHOOK_SECRET;
if (!secret) throw new Error('Set MOLEMAN_WEBHOOK_SECRET (API page in the app).');
createServer((request, response) => {
let raw = '';
request.on('data', (chunk) => (raw += chunk));
request.on('end', () => {
if (!verifyMolemanSignature(raw, request.headers['x-moleman-signature'], secret)) {
response.writeHead(401).end('bad signature');
return;
}
const delivery = request.headers['x-moleman-delivery'];
if (!seen.has(delivery)) {
seen.add(delivery);
const event = JSON.parse(raw);
console.log(event.event, event.jobId ?? '', `${event.results?.length ?? 0} results`);
}
response.writeHead(200).end('ok'); // answer 2xx fast; do slow work after
});
}).listen(8787, () => console.log('Waiting for webhooks on http://localhost:8787'));
}Python
# Check that a webhook really comes from Moleman, then use it. Python 3.8 or newer, no packages.
# Run: MOLEMAN_WEBHOOK_SECRET=whsec_... python verify_webhook.py (listens on port 8787)
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
TOLERANCE_SECONDS = 300 # refuse anything older than 5 minutes (replays)
def verify_moleman_signature(raw_body: bytes, header: str, secret: str, now=None) -> bool:
"""raw_body: the exact bytes we sent (do not parse and re-dump). header: X-Moleman-Signature."""
parts = dict(part.strip().split("=", 1) for part in (header or "").split(",") if "=" in part)
try:
timestamp = int(parts["t"])
given = parts["v1"]
except (KeyError, ValueError):
return False
if abs((now or time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
seen = set() # X-Moleman-Delivery ids you already handled (a retry has the same id)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify_moleman_signature(raw, self.headers.get("X-Moleman-Signature"), os.environ["MOLEMAN_WEBHOOK_SECRET"]):
self.send_response(401)
self.end_headers()
return
delivery = self.headers.get("X-Moleman-Delivery")
if delivery not in seen:
seen.add(delivery)
event = json.loads(raw)
print(event["event"], event.get("jobId", ""), len(event.get("results", [])), "results")
self.send_response(200) # answer 2xx fast; do slow work after
self.end_headers()
if __name__ == "__main__":
print("Waiting for webhooks on http://localhost:8787")
HTTPServer(("", 8787), Handler).serve_forever()Retries
Answer with any 2xx status within 10 seconds. Anything else (or no answer) is retried: after 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 14 h (8 tries over about 18 hours). After the last try we give up; the results stay available with GET /api/v1/jobs/{id}.
A retry has the same X-Moleman-Delivery id and a new timestamp and signature. Remember the ids you handled and skip repeats. Do slow work after you answered.
We only send to public https:// addresses and do not follow redirects.