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:
| Header | Purpose |
|---|---|
X-WellMarked-Delivery-Id | UUID, stable across retries. Use it as your idempotency key. |
X-WellMarked-Timestamp | Unix seconds when this attempt was signed. Reject if drift exceeds 5 minutes. |
X-WellMarked-Signature | v1,<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 objectAll 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-Idis 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.
Idempotency
Retry a bulk or crawl submission safely — the same key returns the original job instead of enqueuing a second one or charging twice.
Compliance and policy
Every key carries an enforced policy — allowed domains, denied patterns, robots.txt handling — that a request can tighten but never loosen.