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; for how delivery errors surface, see Errors.
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 repairer0c000000-0000-4000-8000-000000000001, the seeded supplier0c000000-0000-4000-8000-000000000002, or theintegration_idyou minted viaintegrations.insert).url— where each delivery isPOSTed. 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 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
httpsurl — 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-httpspublic url on the deployed sandbox (https_required); a private/reserved host such as10.x,192.168.x, or169.254.169.254(private_or_reserved_host— an SSRF guard); a missing field (missing_field); an unparseable body (bad_body). Success is200 { "ok": true }.
- Local dev server (
x-partifact-tenant— send the same sandbox slot you use for your lifecycle calls (see the Quickstart) so the subscription and the events it should receive live in one isolated world.
Once registered, every signed webhook for that integration_id is POSTed 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
localhosturl 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 publichttpsurl it hands you — exactly how Stripe, GitHub, and other webhook platforms have you develop locally:# 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 3000is 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 pollrepairer.procurements.getfor 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 uniquemessage_id(a UUID). Keep a seen-set; if you have already processed amessage_id, ack200and 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
200as soon as you have verified and enqueued the event. Do the real work asynchronously.5-minute replay window. Reject any notification whose
webhook_timestampis 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.
// 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:
{
"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_confirmedis the terminal public state of the2026-01contract. Events beyond that point (procurement placement, invoice reconciliation) belong to the proposed extensions, which fire under the samerepairer.procurements/supplier.procurementsevent types but are clearly labelled in the API Reference. The public webhookstatusenum in the payload isorder_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 notJSON.parse/json.loadsand 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 aRangeError) 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.
// 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.
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
- Capture the raw body bytes before parsing.
- Read the
partly-hmac-sha256header. - Look up the
pwh_...secret for the body'sintegration_id. - Verify (HMAC over raw bytes, constant-time compare, 5-min
webhook_timestampwindow). - Dedup on
message_id; ack200(and{ deduped: true }for repeats). - Re-fetch the referenced record by id — payloads are identifier-only.
Related
- Errors — the coded error model and how failures surface.
- API Reference — the request/response methods, including the procurement lifecycle that emits these events.
- Machine-readable indexes:
/llms.txt,/llms-full.txt,/openapi.json.