<!-- title: Webhooks | order: 5 | summary: Signed, at-least-once event delivery — and exactly how to verify the HMAC signature. -->

# Webhooks

Procurement Sandbox emits **signed, at-least-once** event notifications so your integration can react to state changes — a job inserted, a procurement moving to `order_confirmed` — without polling. This page is the byte-for-byte contract: the envelope, the signature scheme, the replay window, and complete copy-paste verification functions in TypeScript and Python that match the signer exactly.

> Procurement Sandbox is a Procurement-compatible API — a faithful mock of the public Integrations API. The webhook signing, envelope, and delivery semantics described here mirror the contract shape. Where this build simplifies, it is labelled.

This is the verifying-consumer side of the loop. For the request/response API you call, see the [API Reference](./api-reference.md); for how delivery errors surface, see [Errors](./errors.md).

---

## 1. Subscription model

You register a consumer URL that Procurement Sandbox will `POST` each signed notification to. Registration is a single call:

```
POST https://partifact-mock.thanhvuttv.workers.dev/__webhooks/subscribe
Content-Type: application/json
x-partifact-tenant: <your-sandbox-slot>

{ "integration_id": "<your integration_id>", "url": "<your consumer URL>" }
```

- **`integration_id`** — the integration whose events you want delivered (the seeded repairer `0c000000-0000-4000-8000-000000000001`, the seeded supplier `0c000000-0000-4000-8000-000000000002`, or the `integration_id` you minted via `integrations.insert`).
- **`url`** — where each delivery is `POST`ed. What's accepted depends on where the sandbox runs (a webhook is an *inbound* call, so your url must be reachable from the sandbox — see [Receiving deliveries](#receiving-deliveries--local-server-vs-deployed-sandbox) below):
  - **Local dev server** (`pnpm --filter @partifact/mock dev`): a **loopback** url (`http(s)://localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`). Your consumer is on the same machine, so delivery works with no extra setup.
  - **Deployed sandbox** (the Workers url above): a **public `https`** url — typically a tunnel that forwards to your local consumer.
  - Rejected `400 { "ok": false, "reason": … }`: a non-loopback url on the local server (`url_not_loopback`); a non-`https` public url on the deployed sandbox (`https_required`); a private/reserved host such as `10.x`, `192.168.x`, or `169.254.169.254` (`private_or_reserved_host` — an SSRF guard); a missing field (`missing_field`); an unparseable body (`bad_body`). Success is `200 { "ok": true }`.
- **`x-partifact-tenant`** — send the **same** sandbox slot you use for your lifecycle calls (see the [Quickstart](./quickstart.md)) so the subscription and the events it should receive live in one isolated world.

Once registered, every signed webhook for that `integration_id` is `POST`ed to your url **verbatim** — the exact raw body bytes plus the `partly-hmac-sha256` header — right after the API call that produced it returns `200` (best-effort; a slow or failing consumer never fails the originating call). A single confirm fires **two** events (buyer + supplier); each is delivered to whichever integration registered for it. The `__` prefix marks this route — and the reference sink `/__webhooks/sink` — as **out-of-contract dev surfaces**: sandbox plumbing for running a consumer next to the mock, not part of the `2026-01` API. Registrations **persist for the life of your sandbox slot** — they ride the same per-slot store as your job and procurement data and self-heal with it after ~1h idle.

### Receiving deliveries — local server vs deployed sandbox

A webhook is an **inbound** call: the platform dials *your* consumer, so the url you register must be reachable from wherever the sandbox runs.

- **Against the local dev server** — the sandbox and your consumer share a machine, so a `localhost` url receives deliveries with **zero setup**. Simplest path for building and verifying your signature handling.
- **Against the deployed sandbox** — the Workers edge cannot reach a laptop's `localhost`. Expose your local consumer with a tunnel and register the public `https` url it hands you — exactly how Stripe, GitHub, and other webhook platforms have you develop locally:

  ```bash
  # one command, no signup — forwards a public https url to your local consumer
  cloudflared tunnel --url http://localhost:3000
  #  → https://<random>.trycloudflare.com   (register THIS as your webhook url)
  ```

  (`ngrok http 3000` is equivalent.) The deployed sandbox then pushes **real signed deliveries** to your tunnel, and your consumer verifies the HMAC exactly as in §6–§7 — no app deployment required. (Prefer not to run a tunnel? Run the local dev server instead, or poll `repairer.procurements.get` for status against the deployed sandbox.)

### The reference consumer

The sink at `POST /__webhooks/sink` is a working verifying consumer you can read as a template: it verifies the signature, dedups on `message_id`, and acks `200` immediately. The verification functions in §6–§7 are exactly what it runs — use them as the basis for your own endpoint.

---

## 2. Delivery semantics

- **At-least-once.** A given event may arrive more than once (retries, edge replays). You must make processing **idempotent**.
- **Dedup on `message_id`.** Each notification carries a unique `message_id` (a UUID). Keep a seen-set; if you have already processed a `message_id`, ack `200` and do nothing else. The reference sink does exactly this and reports `{ ok: true, deduped: <bool> }`.

  > **Simplification (dev sink):** the reference sink's dedup set is in-memory and ephemeral — it is bounded to a single eval run, not a durable store. Your production consumer should persist the seen-set.

- **Ack fast.** Return `200` as soon as you have verified and enqueued the event. Do the real work asynchronously.
- **5-minute replay window.** Reject any notification whose `webhook_timestamp` is more than **5 minutes** away from now — in the past *or* the future (the check is `|now − webhook_timestamp| > 5 min`). This bounds replay attacks.

---

## 3. The envelope — exactly 8 fields

Every notification body is a JSON object with these **8 fields**, names and types verbatim:

| # | Field | Type | Notes |
|---|-------|------|-------|
| 1 | `message_id` | `string` | UUID. The **dedup key**. |
| 2 | `event_timestamp` | `string` | ISO-8601. When the underlying event occurred (defaults to the sent time). |
| 3 | `webhook_timestamp` | `string` | ISO-8601. The **sent** time — drives the 5-minute replay window. |
| 4 | `event_type` | `string` (3-value union) | One of the 3 event types below. |
| 5 | `type` | `"integration_notification"` (const) | Always this literal. |
| 6 | `version` | `"v1"` (const) | Always this literal. |
| 7 | `integration_id` | `string` | The recipient integration. Used to look up the verifying secret. |
| 8 | `payload` | object | **Identifier-only** (see below). |

### Payloads are identifier-only — re-fetch by id

Notifications **do not embed records**. The `payload` carries identifiers; you re-fetch the current state through the API (e.g. `repairer.jobs.get`, `repairer.procurements.get`). This avoids acting on a stale snapshot delivered out of order.

```jsonc
// payload for a repairer.jobs event
{ "job_id": "string", "change_action": "inserted" | "updated" | "deleted" }

// payload for a repairer.procurements / supplier.procurements event
{ "procurement_id": "string", "job_id": "string", "status": "order_requested" | "order_confirmed" }
```

A complete notification on the wire looks like:

```json
{
  "message_id": "a1b2c3d4-0000-4000-8000-000000000abc",
  "event_timestamp": "2026-06-05T03:14:00.000Z",
  "webhook_timestamp": "2026-06-05T03:14:00.000Z",
  "event_type": "supplier.procurements",
  "type": "integration_notification",
  "version": "v1",
  "integration_id": "0c000000-0000-4000-8000-000000000002",
  "payload": {
    "procurement_id": "10000000-0000-4000-8000-000000000001",
    "job_id": "0d000000-0000-4000-8000-000000000001",
    "status": "order_confirmed"
  }
}
```

> **Lifecycle note.** `order_confirmed` is the **terminal public state** of the `2026-01` contract. Events beyond that point (procurement placement, invoice reconciliation) belong to the proposed extensions, which fire under the same `repairer.procurements` / `supplier.procurements` event types but are clearly labelled in the [API Reference](./api-reference.md). The public webhook `status` enum in the payload is `order_requested | order_confirmed`.

---

## 4. The 3 event types

`event_type` is one of exactly these:

| `event_type` | Fires when |
|--------------|-----------|
| `repairer.jobs` | A repair job is inserted or updated (`change_action`). |
| `repairer.procurements` | A procurement the **repairer/buyer** is party to changes status. |
| `supplier.procurements` | A procurement the **supplier** is party to changes status (e.g. on confirm). |

A single state change (e.g. a confirm) fires both the buyer-facing and supplier-facing procurement events, each delivered to the integration that should receive it.

---

## 5. Signature scheme

Every delivery carries an HMAC signature in a header:

- **Header name (lowercase):** `partly-hmac-sha256`
- **Value:** `base64( HMAC-SHA256( secret, RAW_REQUEST_BODY ) )`

That is: the **secret is the HMAC key**, the message is the **raw request-body bytes**, and the digest is base64-encoded.

### The secret

The signing key is the integration's **`pwh_`-prefixed shared webhook secret**, looked up per `integration_id`. The seeded demo secrets are:

| Scope | Webhook secret |
|-------|----------------|
| Repairer | `pwh_demo_repairer_a1b2c3d4e5f6` |
| Supplier | `pwh_demo_supplier_9a8b7c6d5e4f` |

Your consumer reads `integration_id` from the body and looks up the corresponding `pwh_...` secret to verify.

### Sign and verify over the RAW bytes — never re-serialized JSON

> **Security-critical.** Compute the HMAC over the **exact bytes** of the request body you received (e.g. `await c.req.text()` in a Worker, `request.get_data()` in Flask). **Do not** `JSON.parse` / `json.loads` and then re-stringify — re-serialization reorders and re-spaces keys, which changes the bytes and **breaks the signature**. Capture the raw body first, verify, then parse.

### Use a constant-time compare

> **Security-critical.** Compare the recomputed digest against the received signature with a **constant-time** comparison (XOR-accumulate, or `hmac.compare_digest`). A naive `===` / `==` byte compare leaks timing information that can be used to forge a signature. Both functions below length-check first (to avoid a `RangeError`) and then compare in constant time.

### Reject reasons

The verifier distinguishes: `missing_signature`, `bad_signature`, `missing_timestamp`, `stale_timestamp`. It checks the signature first, then the timestamp's presence, validity, and freshness.

---

## 6. Verification — TypeScript (Web Crypto `crypto.subtle`)

Portable to Cloudflare Workers and Node 20+ (no `node:crypto`). Pass the **raw** body string.

```ts
// Verify a Procurement Sandbox webhook. Pass the RAW request body string (do NOT re-stringify
// a parsed object — that breaks the HMAC). Header: `partly-hmac-sha256`.
async function verifyWebhook(
  rawBody: string,
  signatureHeader: string | null | undefined, // value of `partly-hmac-sha256`
  secret: string,                              // the integration's pwh_... secret
  nowMs: number = Date.now(),
): Promise<{ ok: boolean; reason?: string }> {
  if (!signatureHeader) return { ok: false, reason: "missing_signature" };

  // 1) Recompute HMAC-SHA256 over the RAW body bytes, keyed by the secret.
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody));
  const expected = new Uint8Array(sigBuf);

  // 2) base64-decode the header, length-check, then constant-time compare.
  let received: Uint8Array;
  try {
    const bin = atob(signatureHeader);
    received = Uint8Array.from(bin, (c) => c.charCodeAt(0));
  } catch {
    return { ok: false, reason: "bad_signature" }; // malformed base64
  }
  if (received.length !== expected.length) return { ok: false, reason: "bad_signature" };
  let diff = 0;
  for (let i = 0; i < expected.length; i++) diff |= expected[i] ^ received[i];
  if (diff !== 0) return { ok: false, reason: "bad_signature" };

  // 3) Replay window: webhook_timestamp must be within 5 minutes of now.
  let ts: unknown;
  try { ts = (JSON.parse(rawBody) as { webhook_timestamp?: unknown }).webhook_timestamp; }
  catch { return { ok: false, reason: "missing_timestamp" }; }
  if (typeof ts !== "string") return { ok: false, reason: "missing_timestamp" };
  const tsMs = Date.parse(ts);
  if (Number.isNaN(tsMs)) return { ok: false, reason: "missing_timestamp" };
  if (Math.abs(nowMs - tsMs) > 5 * 60 * 1000) return { ok: false, reason: "stale_timestamp" };

  return { ok: true };
}
```

---

## 7. Verification — Python (`hmac` / `hashlib` / `hmac.compare_digest`)

Pass the **raw** body bytes (e.g. Flask `request.get_data()`), never a re-serialized dict.

```python
import hmac, hashlib, base64, json, time

def verify_webhook(raw_body: bytes, signature_header: str | None,
                          secret: str, now_ms: int | None = None) -> tuple[bool, str | None]:
    """Verify a Procurement Sandbox webhook. `raw_body` MUST be the exact request bytes
    (do NOT json.loads then re-dump — that breaks the HMAC).
    `signature_header` is the value of the `partly-hmac-sha256` header."""
    if now_ms is None:
        now_ms = int(time.time() * 1000)
    if not signature_header:
        return False, "missing_signature"

    # 1) Recompute HMAC-SHA256 over the RAW body bytes, keyed by the secret.
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()

    # 2) base64-decode the header; constant-time compare.
    try:
        received = base64.b64decode(signature_header, validate=True)
    except Exception:
        return False, "bad_signature"
    if not hmac.compare_digest(expected, received):
        return False, "bad_signature"

    # 3) Replay window: webhook_timestamp within 5 minutes of now.
    try:
        ts = json.loads(raw_body).get("webhook_timestamp")
    except Exception:
        return False, "missing_timestamp"
    if not isinstance(ts, str):
        return False, "missing_timestamp"
    # ISO-8601 → epoch ms (handles trailing 'Z').
    from datetime import datetime, timezone
    try:
        ts_ms = int(datetime.fromisoformat(ts.replace("Z", "+00:00"))
                    .replace(tzinfo=timezone.utc).timestamp() * 1000) \
                if ("+" not in ts and "Z" in ts) else \
                int(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() * 1000)
    except Exception:
        return False, "missing_timestamp"
    if abs(now_ms - ts_ms) > 5 * 60 * 1000:
        return False, "stale_timestamp"

    return True, None
```

Both functions are byte-identical in behaviour to the server's signer: `importKey`/`hmac.new` with the **secret as key**, `sign`/`.digest()` over the **raw body**, base64-decode the header, length-check, then constant-time compare, and the 5-minute `webhook_timestamp` window.

---

## 8. Consumer checklist

1. Capture the **raw** body bytes before parsing.
2. Read the `partly-hmac-sha256` header.
3. Look up the `pwh_...` secret for the body's `integration_id`.
4. Verify (HMAC over raw bytes, constant-time compare, 5-min `webhook_timestamp` window).
5. Dedup on `message_id`; ack `200` (and `{ deduped: true }` for repeats).
6. **Re-fetch** the referenced record by id — payloads are identifier-only.

---

## Related

- [Errors](./errors.md) — the coded error model and how failures surface.
- [API Reference](./api-reference.md) — the request/response methods, including the procurement lifecycle that emits these events.
- Machine-readable indexes: [`/llms.txt`](/llms.txt), [`/llms-full.txt`](/llms-full.txt), [`/openapi.json`](/openapi.json).
