API docs

Guides

Jobs & webhooks

Big jobs run in the background. Two ways to get the result:

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

HTTP
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…
JSON
{
  "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>:

  1. Read the raw body as bytes. Do not parse and re-encode it first.
  2. Split X-Moleman-Signature into t and v1.
  3. Refuse it if t is more than 5 minutes away from now (this blocks replays).
  4. Compute HMAC-SHA256(secret, t + "." + raw body) as hex and compare it with v1 in constant time.

Node.js

verify-webhook.mjs
// 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

verify_webhook.py
# 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.