Skip to content
API v1 preview — endpoints and fields may change before general availability.

Webhooks

How do I receive, verify and replay webhook deliveries?

View .md

BetterJobs POSTs events to a URL you own. Two things send them: watches (a company domain or a saved search) and async searches created with webhook_url. Your endpoint verifies the signature, stores the event, and returns 2xx fast.

Every delivery is one JSON Event per request. The same envelope is used for every type.

{
"id": "evt_7Wq1ZxC4vB",
"type": "job.opened",
"created_at": "2026-10-11T06:15:00Z",
"watch_id": "wat_6Np3QyR8tU",
"data": {
"job": {
"id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB",
"title": "Head of Revenue Operations",
"company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" },
"status": "open"
}
}
}

Illustrative and shortened. A real job.opened carries the full Job.

Field What it is
id Event id (evt_...). The same on every retry and replay. Use it to dedupe.
type One of the event types.
created_at When the event happened.
watch_id The watch that produced it. null for search.completed.
data job for job events, company and is_hiring for company events, search for search.completed.

The event catalog lists each type, what it means, what your code should do and what it costs.

Every delivery carries a BetterJobs-Signature header:

BetterJobs-Signature: t=1760173200,v1=5f2b7c1e9a4d3f6b8c0e2a4d6f8b0c2e4a6d8f0b2c4e6a8d0f2b4c6e8a0d2f4b
  • t is a Unix timestamp in seconds.
  • v1 is the hex HMAC-SHA256 of the string <t>.<raw body>, keyed with your endpoint secret.

To verify:

  1. Read the raw request body as bytes. Do not parse and re-serialize the JSON first. Any change in whitespace or key order breaks the signature.
  2. Compute HMAC-SHA256 over t, a literal ., and the raw body.
  3. Compare your result to v1 with a constant-time compare. A plain == leaks timing information.
  4. Reject the delivery if t is too far from your clock. We suggest 5 minutes. This stops an attacker from replaying a captured request later.
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Request, Response
SECRET = os.environ["BETTERJOBS_WEBHOOK_SECRET"]
TOLERANCE_S = 300
app = FastAPI()
def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
pairs = [part.split("=", 1) for part in header.split(",") if "=" in part]
t = next((v for k, v in pairs if k == "t"), None)
signatures = [v for k, v in pairs if k == "v1"]
if t is None or not t.isdigit() or not signatures:
return False
if abs(time.time() - int(t)) > TOLERANCE_S:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
@app.post("/betterjobs/webhook")
async def betterjobs_webhook(request: Request) -> Response:
raw = await request.body()
if not verify_signature(raw, request.headers.get("BetterJobs-Signature", ""), SECRET):
return Response(status_code=400)
event = json.loads(raw)
if not already_processed(event["id"]): # your database
enqueue(event) # your queue; do the work later
return Response(status_code=200)

Keep the secret in an environment variable or secret store. Never commit it, and never log the header.

Sign a body yourself and send it to your local endpoint. This checks your parsing and compare logic without waiting for a real event.

import hashlib
import hmac
import os
import time
body = b'{"id":"evt_test_0001","type":"job.opened","created_at":"2026-10-11T06:15:00Z","watch_id":null,"data":{}}'
t = str(int(time.time()))
v1 = hmac.new(os.environ["BETTERJOBS_WEBHOOK_SECRET"].encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
print(f"BetterJobs-Signature: t={t},v1={v1}")

Return any 2xx within 10 seconds. Anything else counts as a failed delivery: a non-2xx status, a timeout, or a connection error.

Do the real work after you respond. Write the event to a queue or table, return 200, and process it in a worker. A slow CRM call inside the handler turns into timeouts and retries.

A failed delivery is retried with backoff for 24 hours. The retries happen this long after the first failed attempt:

  1. 1m
  2. 5m
  3. 30m
  4. 2h
  5. 6h
  6. 12h
  7. 24h

Retries are free, including retries of job.opened. You are charged once per event, not once per attempt. After the last retry, BetterJobs stops sending. The event is still in the event feed, and you can replay it.

You will see the same event more than once: after a retry, after a replay, or when your endpoint timed out after it had already done the work. Design for it.

  • Dedupe on id. Store each processed event id. If it is already stored, return 200 and stop.
  • Upsert jobs by data.job.id. The canonical job id is stable across sources and events. Write with upsert, never blind insert.
  • Key side effects on the job, not the event. Before you start a sequence for job.opened, check that you have not already started one for that data.job.id.
  • Handle any order. Do not assume job.opened arrives before job.updated or job.closed for the same job. Compare created_at and keep the newest state.

Fixed a broken endpoint? Re-send any event with POST /v1/webhooks/replay. It goes to the event’s original webhook endpoint and returns 202 with status: queued. Replays are free and never charge again.

Terminal window
curl https://api.betterjobs.cc/v1/webhooks/replay \
-H "Authorization: Bearer $BETTERJOBS_API_KEY" \
-H "BetterJobs-Version: 2026-10-01" \
-H "Content-Type: application/json" \
-d '{ "event_id": "evt_7Wq1ZxC4vB" }'

Webhooks are the fast path. GET /v1/events is the record. Every event from your watches and async searches appears there, oldest first, whether or not it was delivered.

To recover after an outage, page through the feed from your last saved position. Store the next_cursor of the last page you processed and pass it as since next time. Your dedupe on id makes overlap harmless. Reading the feed is free. See Pagination and Sync patterns.

  • job.opened: 1 credit per event delivered, free if you already paid for that job.
  • Every other event type: free.
  • Retries and replays: free.
  • Creating a watch and reading GET /v1/events: free.