WellMarked Docs
Guides

Webhooks

Get a signed job.completed POST the moment a bulk or crawl job finishes, instead of polling — with signature verification and the retry schedule.

Pass webhook_url to POST /bulk or POST /crawl and we POST a signed job.completed notification the moment the job finishes. No polling.

Available on every plan.

1. Submit a job with a webhook

curl -X POST https://api.wellmarked.io/bulk \
  -H "Authorization: Bearer wm_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
        "urls": ["https://a.example", "https://b.example"],
        "webhook_url": "https://yourapp.example/hooks/wellmarked"
      }'

The signing secret is shown once

Your first submission with a webhook_url mints the account's HMAC signing secret and returns it as webhook_signing_secret on the response. Save it before discarding the response — every later submission returns null for that field. Lost it? POST /webhook/rotate mints a new one and invalidates the old immediately.

2. What we POST

The default "thin" payload — metadata plus a results_url you fetch with your normal API key:

{
  "event": "job.completed",
  "job_id": "1c4f9a02-b8c9-4d0e-a1f2-3b4c5d6e7f8a",
  "kind": "bulk",
  "status": "done",
  "total": 50,
  "completed": 48,
  "finished_at": "2026-05-27T14:02:14.812Z",
  "results_url": "https://api.wellmarked.io/bulk/1c4f9a02-b8c9-4d0e-a1f2-3b4c5d6e7f8a"
}

Crawl jobs ("kind": "crawl") also carry truncated and truncated_reason.

Pass webhook_include_results: true on submission to inline the full results array. It is capped at ~5 MB — over the cap the payload silently falls back to the thin shape with results_truncated_for_size: true.

3. Verify the signature

Every delivery carries three headers:

HeaderPurpose
X-WellMarked-Delivery-IdUUID, stable across retries. Use it as your idempotency key.
X-WellMarked-TimestampUnix seconds when this attempt was signed. Reject if drift exceeds 5 minutes.
X-WellMarked-Signaturev1,<base64 HMAC-SHA256> over `${delivery_id}.${timestamp}.${raw_body_bytes}`.

The HMAC key is the secret's hex body: bytes.fromhex(secret.removeprefix("whsec_")).

Both SDKs ship a verifier. Use it rather than reimplementing HMAC — it does the constant-time compare and the timestamp tolerance check for you, and it runs on Node 18.17+, Cloudflare Workers, Deno, Bun, and modern browsers.

from wellmarked import verify_webhook, WebhookVerificationError

@app.post("/hooks/wellmarked")
async def hook(request):
    try:
        payload = verify_webhook(
            secret=os.environ["WELLMARKED_WEBHOOK_SECRET"],
            headers=request.headers,   # case-insensitive lookup; dict works too
            body=await request.body(), # raw bytes, NOT a parsed object
        )
    except WebhookVerificationError:
        return Response(status_code=401)
    # payload is the validated job.completed object

All three arguments are keyword-only. max_age_sec defaults to 300; raise it only if your endpoint's clock skews more than five minutes from UTC.

Verify the raw bytes

Sign-and-compare against the body exactly as received. Parsing the JSON and re-serializing it changes the bytes and the signature will not match.

4. Delivery semantics

  • Your endpoint must respond 2xx within 10 seconds. Anything else — timeout, 4xx, 5xx, DNS failure — triggers a retry.
  • Retry schedule: 30s → 5m → 30m → 2h → 12h → 24h. Seven attempts over roughly 38 hours, after which the delivery is dead-lettered.
  • X-WellMarked-Delivery-Id is stable across retries — deduplicate on it. The timestamp and signature are recomputed each attempt, so the tolerance check stays valid.
  • Treat deliveries as at-least-once.
  • We do not follow redirects. A 3xx from your endpoint counts as a failure.