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; for how delivery errors surface, see Errors.
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 repairer0c000000-0000-4000-8000-000000000001, the seeded supplier0c000000-0000-4000-8000-000000000002, or theintegration_idyou minted viaintegrations.insert).url— where each delivery isPOSTed. It must be a loopback address (http(s)://localhost,127.0.0.1,[::1], or0.0.0.0); any other host is rejected. Responses: success200 { "ok": true }; non-loopback url400 { "ok": false, "reason": "url_not_loopback" }; a missing field400 … "missing_field"; an unparseable body400 … "bad_body".
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 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 alocalhosturl against that instance. Against the deployed sandbox, pollrepairer.procurements.getfor 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 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 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.
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
- 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.