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>" }

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


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_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. 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:

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.

// 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

  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