Skip to content

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

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
});

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

  1. Verify over the exact received bytes. The signed content is {webhook-id}.{webhook-timestamp}.{body} where body is 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.
  2. Constant-time compare on the fixed 32 raw HMAC bytes — never a base64-string ===.
  3. HMAC-SHA256 is fixed by the verifier — never read an algorithm from input. v1, means HMAC-SHA256; unknown scheme prefixes are skipped, but zero v1, candidates fail closed (never “no candidates ⇒ ok”).
  4. Timestamp bounded both ways (too-old AND too-new), checked before the HMAC.
  5. Secret is exactly 32 bytes after stripping the rksec_ prefix and base64url-decoding.
HeaderSource specNotes
webhook-idStandard WebhooksStable message id — constant across all retries. The dedup key.
webhook-timestampStandard WebhooksUnix seconds (not milliseconds), stamped at send time.
webhook-signatureStandard Webhooksv1,<base64-hmac>; during rotation v1,<new> v1,<old> (space-delimited).
X-Rakomi-Delivery-IdRakomi extensionPer-delivery id — diagnostics/logging, NOT the dedup key.
X-Rakomi-EventRakomi extensionThe event type (the body carries no type field).
X-Rakomi-AttemptRakomi extensionPer-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.

verify needs the raw body. Most frameworks parse JSON by default, which breaks the signature.

FrameworkGet the raw body
Expressapp.post(path, express.raw({ type: 'application/json', limit: '256kb' }), …)req.body is a Buffer
FastifyaddContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body))
Honoawait 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 Lambdaevent.body (decode with Buffer.from(event.body, 'base64') when event.isBase64Encoded)
Cloudflare Workerawait 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.

  • 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/403 on 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.
  • At-least-once, no ordering. app.install.scope_bump may arrive before app.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.

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 (the webhook-id header) — the stable message identity, constant across all retry attempts. This is the field the helper surfaces as data.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_id is 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
}
}
  • 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).

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.

EventScopeStatus
app.installedinstall (transactional)active
app.uninstalledinstall (transactional)active
app.install.scope_bumpinstall (transactional)active
app.install.receipts_revokedinstall (transactional)active
publisher.createdpublisher (best-effort)active
publisher.domain_verifiedpublisher (best-effort)active
publisher.dpa_acceptedpublisher (best-effort)active
app.createdpublisher (best-effort)active
app.version_publishedpublisher (best-effort)active
app.state_changedpublisher (best-effort)active
publisher.review_requestedpublisher (best-effort)active
publisher.review_deniedpublisher (best-effort)active
publisher.review_stalepublisher (best-effort)active
publisher.verifiedpublisher (best-effort)active
publisher.deverifiedpublisher (best-effort)active
publisher.subscription_activatedpublisher (best-effort)active
publisher.subscription_lapsedpublisher (best-effort)active
publisher.subprocessor_addedaudit-only — NOT delivered
publisher.subprocessor_removedaudit-only — NOT delivered
compliance.pack_exportedaudit-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" }
// @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 throwing
let 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);

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.

BehaviourGoverning specPinned value
webhook-* headersStandard Webhookslowercase canonical; receiver treats case-insensitively (RFC 9110 §5.1)
signed content {id}.{ts}.{body}Standard Webhooksdot-delimited, raw body bytes as received
v1,<base64> schemeStandard Webhooksv1 = HMAC-SHA256; space-delimited multi-entry for rotation
HMACRFC 2104key = 32-byte decoded secret
hashFIPS 180-4SHA-256, hardcoded, never negotiated
signature encodingRFC 4648 §4standard base64 (+//, padded)
secret encoding rksec_RFC 4648 §5base64url (-/_)
timestamp unitPOSIX / Standard Webhooksseconds (not ms)
replay toleranceStandard Webhooks (~±5 min)300 s default, 600 s max clamp
receiver ackRFC 9110 §152xx = stop-retry; non-2xx/timeout = retry
no-alg-from-inputRFC 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 the v1, entry. Unknown prefixes are skipped, never error.
  • Event catalog: the event type is an open set — a delivery carrying a type your 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.

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.