Publisher webhooks — receiver contract
This is the canonical receiver contract for the Rakomi publisher-webhook transport — the
server-to-server channel that notifies your app’s backend when install-lifecycle and
publisher-platform events occur (app.installed, app.uninstalled, app.install.scope_bump,
app.install.receipts_revoked, and the publisher-scoped events below).
Every Rakomi SDK ships a battle-tested receiver helper so you never re-implement the crypto:
verifyPublisherWebhook in @rakomi/node, Swift (RakomiSDK), Flutter
(rakomi_flutter), and React-Native (@rakomi/react-native).
Quickstart (Node/Express)
Section titled “Quickstart (Node/Express)”import express from 'express';import { verifyPublisherWebhook } from '@rakomi/node';
const app = express();// Mount express.raw on the webhook route — verify needs the RAW body bytes, never parsed JSON.app.post('/webhooks/rakomi', express.raw({ type: 'application/json', limit: '256kb' }), async (req, res) => { const result = await verifyPublisherWebhook(req.body, req.headers, process.env.RAKOMI_WEBHOOK_SECRET!); if (!result.ok) return res.sendStatus(400); // log result.error server-side; don't leak on the wire const { webhookId, eventType, payload } = result.data; if (await alreadyProcessed(webhookId)) return res.sendStatus(200); // idempotent: dedup on webhookId await handle(eventType, payload); // your business logic await markProcessed(webhookId); // persist BEFORE acking res.sendStatus(200); // 2xx only after durable accept});Irreducible Receiver Contract
Section titled “Irreducible Receiver Contract”Five first-principles invariants every correct receiver MUST hold. The SDK helpers enforce all five; this block is the porting spec if you ever hand-roll a verifier (e.g. a fifth-language port).
- Verify over the exact received bytes. The signed content is
{webhook-id}.{webhook-timestamp}.{body}wherebodyis the raw bytes as received on the wire — never a re-serialized parse, decompression, charset re-encode, JSON pretty-print, BOM strip, or trailing-newline change. - Constant-time compare on the fixed 32 raw HMAC bytes — never a base64-string
===. - HMAC-SHA256 is fixed by the verifier — never read an algorithm from input.
v1,means HMAC-SHA256; unknown scheme prefixes are skipped, but zerov1,candidates fail closed (never “no candidates ⇒ ok”). - Timestamp bounded both ways (too-old AND too-new), checked before the HMAC.
- Secret is exactly 32 bytes after stripping the
rksec_prefix and base64url-decoding.
Headers & signature format
Section titled “Headers & signature format”| Header | Source spec | Notes |
|---|---|---|
webhook-id | Standard Webhooks | Stable message id — constant across all retries. The dedup key. |
webhook-timestamp | Standard Webhooks | Unix seconds (not milliseconds), stamped at send time. |
webhook-signature | Standard Webhooks | v1,<base64-hmac>; during rotation v1,<new> v1,<old> (space-delimited). |
X-Rakomi-Delivery-Id | Rakomi extension | Per-delivery id — diagnostics/logging, NOT the dedup key. |
X-Rakomi-Event | Rakomi extension | The event type (the body carries no type field). |
X-Rakomi-Attempt | Rakomi extension | Per-attempt counter (1, 2, 3 …) — never a dedup key. |
The X-Rakomi-* headers are Rakomi proprietary extensions, not part of Standard Webhooks.
Signed content: `${webhookId}.${timestamp}.${body}` → HMAC-SHA256 → standard base64 (+//,
padded), prefixed v1,. A webhook-id containing a . joins literally (the verifier does not re-split).
The two-alphabet rule: the secret is base64url (-/_, RFC 4648 §5); the signature is
standard base64 (+//, RFC 4648 §4). A receiver that uses one decoder for both silently mis-verifies
~half of all signatures.
Framework raw-body adapters
Section titled “Framework raw-body adapters”verify needs the raw body. Most frameworks parse JSON by default, which breaks the signature.
| Framework | Get the raw body |
|---|---|
| Express | app.post(path, express.raw({ type: 'application/json', limit: '256kb' }), …) → req.body is a Buffer |
| Fastify | addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body)) |
| Hono | await c.req.text() |
| Next.js (App Router) | await req.text() in the route handler |
| Next.js (Pages, legacy) | export const config = { api: { bodyParser: false } }, then read the stream |
| AWS Lambda | event.body (decode with Buffer.from(event.body, 'base64') when event.isBase64Encoded) |
| Cloudflare Worker | await request.text() |
Broken anti-pattern — never do this:
// ❌ WRONG — re-serializing a parsed body changes the bytes; the signature will never match.verifyPublisherWebhook(JSON.stringify(req.body), req.headers, secret);A CDN, proxy, or API gateway that re-serializes JSON breaks verification (a DoS-by-misconfig that looks like “all webhooks broken”) — terminate signature verification at the origin, before any re-serializing hop.
Responding to a delivery
Section titled “Responding to a delivery”- Return 2xx (
200/204) only after the event is durably accepted (dedup key persisted / job enqueued). Returning 2xx before persistence loses the event on a crash, with no retry. - Non-2xx or a timeout → Rakomi retries per the backoff schedule (at-least-once).
- Do not signal verify failures on the wire. Returning
401/403on a verify failure invites probing — log it server-side and return a generic non-2xx (or 2xx-and-drop if you prefer to starve retries of an attacker’s forgeries). Genuine Rakomi deliveries always verify.
Ordering & delivery guarantees
Section titled “Ordering & delivery guarantees”- At-least-once, no ordering.
app.install.scope_bumpmay arrive beforeapp.installed. - Process events order-independently; dedup + idempotency on the stable id is the only ordering-independent safety.
- Rotation is sender-driven (24 h overlap). The receiver configures no window — it just tries every
v1,entry. The 24 h overlap is your entire window to update a stored secret after a rotation; a stale secret fails loud (a typed invalid-secret/invalid-signature error), never silently drops events.
Idempotency & replay
Section titled “Idempotency & replay”At-least-once delivery means you will receive duplicates (retries) — dedup is a security control, not a convenience: without it, your endpoint is replayable for the freshness window AND retries double-process side effects (double-provision / double-charge).
- Dedup on
webhookId(thewebhook-idheader) — the stable message identity, constant across all retry attempts. This is the field the helper surfaces asdata.webhookId. deliveryId(X-Rakomi-Delivery-Id) is per-delivery diagnostics — log it, don’t dedup on it.- For deep cross-system reconciliation, the payload’s
correlation_idis a stable forensic join key that also survives a reconciliation re-enqueue; it is not the primary receiver dedup key. - Use a bounded / TTL’d dedup store — never an unbounded in-memory
Set. Retention must be ≥ the 600 s max freshness window + your retry horizon (24–48 h is a safe default).
// A pluggable ReplayStore seam — swap the in-memory dev adapter for Redis in production.interface ReplayStore { seenBefore(key: string): Promise<boolean>; } // returns true if already processed
// In-memory (dev only — not multi-instance safe, not durable):class MemoryReplayStore implements ReplayStore { private seen = new Map<string, number>(); async seenBefore(key: string) { this.#sweep(); if (this.seen.has(key)) return true; this.seen.set(key, Date.now() + 48 * 3600_000); // 48h TTL return false; } #sweep() { const now = Date.now(); for (const [k, exp] of this.seen) if (exp < now) this.seen.delete(k); }}
// Redis (production) — atomic set-if-absent with a TTL; first writer wins:class RedisReplayStore implements ReplayStore { constructor(private redis: { set(k: string, v: string, opts: { NX: true; EX: number }): Promise<unknown> }) {} async seenBefore(key: string) { const ok = await this.redis.set(`rakomi:wh:${key}`, '1', { NX: true, EX: 48 * 3600 }); return ok === null; // SET NX returned nil ⇒ key existed ⇒ already processed }}Security & operational cautions
Section titled “Security & operational cautions”- Raw-body / CDN re-serialization: verify at the origin, before any re-serializing hop (see the adapter matrix).
- Max request-body guard: cap the raw body (e.g. 256 KiB) before verifying — HMAC is O(body size), so an unbounded body is an asymmetric receiver-DoS vector. Rakomi event envelopes are small.
- Dedup retention ≥ 600 s (the max freshness window) + your retry horizon.
- Secret format:
rksec_<base64url>; strip trailing whitespace from a copied secret. The publisher entry point rejects any non-rksec_secret. - Replay window: 300 s default, 600 s max — sender-driven; the receiver configures none.
- Troubleshooting: NTP-sync your server clock; strip whitespace from the copied secret; on a rotation,
try every
v1,entry (the SDK helpers already do).
Event catalog
Section titled “Event catalog”Sourced from PUBLISHER_EVENT_TYPES (packages/shared/src/constants/event-types.ts) and the
EVENT_DELIVERY_SEMANTICS registry (packages/api/src/services/publisher-webhook-registry.ts) — cite
these symbols if you re-derive this table; a stale doc is diff-detectable against them.
| Event | Scope | Status |
|---|---|---|
app.installed | install (transactional) | active |
app.uninstalled | install (transactional) | active |
app.install.scope_bump | install (transactional) | active |
app.install.receipts_revoked | install (transactional) | active |
publisher.created | publisher (best-effort) | active |
publisher.domain_verified | publisher (best-effort) | active |
publisher.dpa_accepted | publisher (best-effort) | active |
app.created | publisher (best-effort) | active |
app.version_published | publisher (best-effort) | active |
app.state_changed | publisher (best-effort) | active |
publisher.review_requested | publisher (best-effort) | active |
publisher.review_denied | publisher (best-effort) | active |
publisher.review_stale | publisher (best-effort) | active |
publisher.verified | publisher (best-effort) | active |
publisher.deverified | publisher (best-effort) | active |
publisher.subscription_activated | publisher (best-effort) | active |
publisher.subscription_lapsed | publisher (best-effort) | active |
publisher.subprocessor_added | — | audit-only — NOT delivered |
publisher.subprocessor_removed | — | audit-only — NOT delivered |
compliance.pack_exported | — | audit-only — NOT delivered |
The three audit-only events are never webhook-delivered — do not wait for them on the transport.
The body is a flat metadata object (no id/type field — the type is in X-Rakomi-Event). It
carries no end-user personal data (operational references / counts only). Example shapes:
// app.installed / app.uninstalled{ "publisher_id": "…", "app_id": "…", "app_version_id": "…", "installation_id": "…", "correlation_id": "…", "actor_axis": "tenant_admin" }
// app.install.scope_bump{ "publisher_id": "…", "app_id": "…", "app_version_id": "…", "installation_id": "…", "correlation_id": "…", "install_state_from": "active", "install_state_to": "scope_bump_pending" }
// app.install.receipts_revoked{ "publisher_id": "…", "installation_id": "…", "correlation_id": "…", "actor_axis": "system_drain", "revoked_count": 3, "already_revoked_count": 0 }
// publisher-scoped (e.g. publisher.subscription_lapsed){ "publisher_id": "…", "correlation_id": "…", "subscription_status": "past_due" }Per-SDK usage
Section titled “Per-SDK usage”// @rakomi/node (sync-style Promise)import { verifyPublisherWebhook } from '@rakomi/node';const r = await verifyPublisherWebhook(rawBody, headers, secret);if (r.ok) handle(r.data.eventType, r.data.payload);// Swift (RakomiSDK) — Result, not throwinglet result = WebhookVerifier.verifyPublisherWebhook(body: data, headers: headers, secret: secret)if case .success(let d) = result { handle(d.eventType, d.payload) }// Flutter (rakomi_flutter)final r = verifyPublisherWebhook(body: bytes, headers: headers, secret: secret);if (r is WebhookVerifyOk) handle(r.eventType, r.payload);// @rakomi/react-native — ASYNC (WebCrypto); await it.import { verifyPublisherWebhook } from '@rakomi/react-native';const r = await verifyPublisherWebhook(rawBody, headers, secret);if (r.ok) handle(r.data.eventType, r.data.payload);Conformance & References
Section titled “Conformance & References”Rakomi webhooks conform to the Standard Webhooks specification, using HMAC-SHA256 (RFC 2104 / FIPS 180-4). Standard Webhooks is a community specification, not an IETF standard.
| Behaviour | Governing spec | Pinned value |
|---|---|---|
webhook-* headers | Standard Webhooks | lowercase canonical; receiver treats case-insensitively (RFC 9110 §5.1) |
signed content {id}.{ts}.{body} | Standard Webhooks | dot-delimited, raw body bytes as received |
v1,<base64> scheme | Standard Webhooks | v1 = HMAC-SHA256; space-delimited multi-entry for rotation |
| HMAC | RFC 2104 | key = 32-byte decoded secret |
| hash | FIPS 180-4 | SHA-256, hardcoded, never negotiated |
| signature encoding | RFC 4648 §4 | standard base64 (+//, padded) |
secret encoding rksec_ | RFC 4648 §5 | base64url (-/_) |
| timestamp unit | POSIX / Standard Webhooks | seconds (not ms) |
| replay tolerance | Standard Webhooks (~±5 min) | 300 s default, 600 s max clamp |
| receiver ack | RFC 9110 §15 | 2xx = stop-retry; non-2xx/timeout = retry |
| no-alg-from-input | RFC 8725 (analog) | algorithm fixed by verifier; non-v1, entries skipped |
Contract stability (forward-compatibility)
Section titled “Contract stability (forward-compatibility)”A receiver built today survives future change without a rebuild on three axes:
- Scheme version: a future co-signed
v2,<sig> v1,<sig>(e.g. during an Ed25519 migration) still verifies on thev1,entry. Unknown prefixes are skipped, never error. - Event catalog: the event
typeis an open set — a delivery carrying atypeyour SDK has never heard of still verifies and is returned with the raw type preserved. Handle what you know; ignore the rest. - Payload fields: the verifier authenticates the bytes, then exposes the parsed object without
rejecting unknown keys — additive sender-side fields (e.g. a future agent-provenance field) are tolerated.
An agent-mediated install (via a future MCP tool) emits a byte-identical
app.installed; no receiver change is needed for agent-mediated events.
Operational SLAs: a key rotation keeps a 24 h dual-sign overlap; the public SDK API surface is additive-only after its first published release.
Security & vulnerability disclosure
Section titled “Security & vulnerability disclosure”Report a suspected vulnerability in the webhook transport or any Rakomi SDK to
security@rakomi.com (a role-based address, monitored by the security
team). We follow coordinated disclosure; please give us a reasonable window to remediate before any public
disclosure. See also SECURITY.md in each SDK package.
See also
Section titled “See also”verifyWebhook()(tenant webhooks) — the tenant (Epic-24) webhook contract.- Webhook events reference — the tenant event catalog.