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

# Webhooks

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

> Rails Sandbox is a Rails-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 Rails Sandbox will `POST` each signed notification to. Registration is a single dev-only call:

```
POST https://partifact-mock-rails.thanhvuttv.workers.dev/__webhooks/subscribe
Content-Type: application/json

{ "integration_id": "<your integration_id>", "url": "http://localhost:<port>/<your-endpoint>" }
```

- **`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. It **must be a loopback address** (`http(s)://localhost`, `127.0.0.1`, `[::1]`, or `0.0.0.0`); any other host is rejected. Responses: success `200 { "ok": true }`; non-loopback url `400 { "ok": false, "reason": "url_not_loopback" }`; a missing field `400 … "missing_field"`; an unparseable body `400 … "bad_body"`.

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 are **in-memory and per-process** (not persisted) — re-subscribe after a restart.

> **Local-dev only — by design.** The loopback restriction is both an SSRF guard and the line that keeps the deployed sandbox faithful. On the **deployed** Worker the edge cannot reach your laptop's `localhost`, so outbound delivery there is a **no-op**: the deployed sandbox keeps a faithful outbox-only posture and never calls out. To actually *receive* live deliveries, run the mock locally (`pnpm --filter @partifact/mock-rails dev`) and register a `localhost` url against that instance. Against the **deployed** sandbox, poll `repairer.procurements.get` for status instead of waiting on a push. This is a clearly-labelled simplification of a real outbound delivery — see the project README's fidelity notes.

### 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 Rails 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 Rails 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).
