# Webhooks

> How do I receive, verify and replay webhook deliveries?

Source: https://docs.betterjobs.cc/platform/webhooks/

BetterJobs POSTs events to a URL you own. Two things send them: [watches](https://docs.betterjobs.cc/data/events.md) (a company domain or a saved search) and [async searches](https://docs.betterjobs.cc/platform/async-searches.md) created with `webhook_url`. Your endpoint verifies the signature, stores the event, and returns `2xx` fast.

## The event envelope

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

```json
{
  "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](https://docs.betterjobs.cc/data/events.md).                                 |
| `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](https://docs.betterjobs.cc/data/events.md) lists each type, what it means, what your code should do and what it costs.

> Act on job.opened, not job.reposted
>
> `job.opened` is the only event that should start an outbound sequence. `job.reposted` means the same job was re-listed. Update your copy and do nothing else, or you will contact the same account twice for one opening.

## Verify the signature

Every delivery carries a `BetterJobs-Signature` header:

```text
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.

**Python**

```python
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)
```

**TypeScript**

```ts
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";


const SECRET = process.env.BETTERJOBS_WEBHOOK_SECRET;
if (!SECRET) throw new Error("BETTERJOBS_WEBHOOK_SECRET is not set");
const TOLERANCE_S = 300;


export function verifySignature(rawBody: Buffer, header: string, secret: string): boolean {
  const pairs = header.split(",").map((part) => {
    const i = part.indexOf("=");
    return [part.slice(0, i), part.slice(i + 1)] as const;
  });
  const t = pairs.find(([k]) => k === "t")?.[1];
  const signatures = pairs.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false;
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return signatures.some((sig) => {
    const given = Buffer.from(sig, "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}


const app = express();


// express.raw keeps the body as a Buffer, exactly as signed.
app.post("/betterjobs/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verifySignature(req.body, req.get("BetterJobs-Signature") ?? "", SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  if (!(await alreadyProcessed(event.id))) await enqueue(event); // your database and queue
  res.sendStatus(200);
});
```

> Raw body or nothing
>
> Most frameworks parse JSON before your handler runs. Verify against the bytes that arrived, not against a re-encoded object. In FastAPI use `await request.body()`. In Express mount `express.raw()` on the webhook route, before any global `express.json()`.

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

### Test your verifier

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

```python
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}")
```

## Respond fast

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.

## 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](#recover-missed-events), and you can [replay](#replay-an-event) it.

## Make handlers idempotent

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.

## Replay an event

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.

**curl**

```bash
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" }'
```

**Python**

```python
import os
import requests


resp = requests.post(
    "https://api.betterjobs.cc/v1/webhooks/replay",
    headers={
        "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
        "BetterJobs-Version": "2026-10-01",
    },
    json={"event_id": "evt_7Wq1ZxC4vB"},
    timeout=30,
)
resp.raise_for_status()
print(resp.json())  # {"event_id": "evt_7Wq1ZxC4vB", "status": "queued"}
```

**TypeScript**

```ts
const res = await fetch("https://api.betterjobs.cc/v1/webhooks/replay", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
    "BetterJobs-Version": "2026-10-01",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ event_id: "evt_7Wq1ZxC4vB" }),
});
if (res.status !== 202) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`);
console.log(await res.json()); // { event_id: "evt_7Wq1ZxC4vB", status: "queued" }
```

## Recover missed events

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](https://docs.betterjobs.cc/platform/pagination.md#the-event-feed-uses-since) and [Sync patterns](https://docs.betterjobs.cc/guides/sync-patterns.md).

## What it costs

- `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.

## Related

- [Event catalog](https://docs.betterjobs.cc/data/events.md)
- [Detect hiring changes](https://docs.betterjobs.cc/guides/detect-hiring-changes.md)
- [Event delivery reference](/api/webhooks/webhookevent/) and [Replay a webhook delivery](/api/operations/replaywebhook/)
