Webhook Signature Specification¶
Canonical signing rules for outbound webhooks the RGS dispatches to operators. Parallel to the inbound wallet signing scheme (wallet-api.md §Authentication), same primitive (HMAC-SHA256), same clock-skew window, different canonical string (webhooks are a one-way notification, not a request / response).
Types and helpers are in packages/webhook-spec; this document is the
wire-level specification.
Contents
- Event envelope
- Headers
- Canonical string
- Canonical JSON
- Verification recipe
- Secret rotation
- Retry + idempotency semantics
- Event types
Event envelope¶
Every webhook POST body is a JSON object with this shape:
interface WebhookEnvelope<T> {
eventId: string; // UUID v4, deduplication key
eventType: WebhookEventType;
occurredAt: string; // ISO-8601
operatorId: string;
dataVersion: number; // Bumped when `data` shape breaks
data: T; // Per-event payload
}
WebhookEventType is one of:
round.settledround.voidedsession.terminatedsession.rg_limit_trippedreconciliation.discrepancycredential.rotatedcredential.revokedwebhook.test
Per-event data shapes are defined in packages/webhook-spec/src/index.ts
and documented in integration-guide.md#webhooks.
Headers¶
Every webhook carries six headers:
| Header | Value | Purpose |
|---|---|---|
X-Yantra-Signature |
base64( HMAC_SHA256(secret, canonical) ) |
Authenticity + integrity |
X-Yantra-Signature-Alg |
HMAC-SHA256 |
Algorithm agility for future migrations |
X-Yantra-Signature-Version |
Integer matching WebhookSubscription.secretVersion |
Which secret produced the signature (supports overlap-then-cut rotation) |
X-Yantra-Event-Id |
UUID v4 from the envelope | Cheap dedupe without parsing the body |
X-Yantra-Event-Type |
Event type from the envelope | Cheap routing without parsing the body |
X-Yantra-Timestamp |
Unix seconds at dispatch time | ±30s replay-window bound |
Canonical string¶
The string signed is exactly (no trailing newline):
Where:
<path>is the webhook URL's path (the operator's side, e.g./webhooks/yantra). Query string is not included (webhooks don't use query strings).<timestamp>is theX-Yantra-Timestampinteger, as a string.<sha256_hex(canonical_body)>is the hex-encoded SHA-256 of the canonicalised JSON body (see below).
The canonical string for bets is structurally identical to the wallet-call signing scheme, operators that already implemented wallet-call verification can reuse the canonicaliser.
Canonical JSON¶
Webhooks are signed over a canonical JSON encoding of the envelope, not the raw bytes, so operators can verify without re-serialising with the same library.
Rules (implemented in packages/webhook-spec/src/index.ts::canonicalJson):
- Object keys are sorted lexicographically (code-point order).
- No whitespace between tokens.
undefinedvalues are omitted (not serialised asnull).nullserialises asnull.bigintvalues serialise as quoted decimal strings (not JSBigIntsyntax). Wallet amounts are decimal strings already so this only matters for per-event internal fields.Datevalues serialise as ISO-8601 strings.- Strings, numbers, booleans: standard
JSON.stringifysemantics.
Example: the envelope
{ eventType: 'round.settled', occurredAt: new Date('2026-04-24T10:15:30Z'),
data: { roundId: 'r1', totalBetsMicro: '100000' }, eventId: 'e1',
operatorId: 'op1', dataVersion: 1 }
canonicalises to (newlines added for readability, actual encoding has none):
{"data":{"roundId":"r1","totalBetsMicro":"100000"},
"dataVersion":1,
"eventId":"e1",
"eventType":"round.settled",
"occurredAt":"2026-04-24T10:15:30.000Z",
"operatorId":"op1"}
Verification recipe¶
Node.js (identical to the wallet-side verify in packages/wallet-spec):
import crypto from 'node:crypto';
import { canonicalJson, WEBHOOK_HEADERS } from '@yantra/webhook-spec';
export function verifyWebhook(
req: { headers: Record<string, string>; body: unknown; path: string },
secretsByVersion: Record<string, string>, // { "1": "abc...", "2": "def..." }
): boolean {
const sig = req.headers[WEBHOOK_HEADERS.signature.toLowerCase()];
const alg = req.headers[WEBHOOK_HEADERS.signatureAlg.toLowerCase()];
const ver = req.headers[WEBHOOK_HEADERS.signatureVersion.toLowerCase()];
const ts = req.headers[WEBHOOK_HEADERS.timestamp.toLowerCase()];
if (alg !== 'HMAC-SHA256') return false;
const secret = secretsByVersion[ver];
if (!secret) return false;
// Replay window: reject > 300s skew for webhooks (looser than wallet
// calls because retries can arrive late).
const tsNum = Number.parseInt(ts, 10);
if (!Number.isFinite(tsNum)) return false;
if (Math.abs(Date.now() / 1000 - tsNum) > 300) return false;
const body = canonicalJson(req.body);
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const canonical = `POST\n${req.path}\n${ts}\n${bodyHash}`;
const expected = crypto.createHmac('sha256', secret).update(canonical).digest('base64');
const a = Buffer.from(expected, 'base64');
const b = Buffer.from(sig, 'base64');
if (a.length === 0 || a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Non-Node operators implement the same four steps:
- Look up the secret by
X-Yantra-Signature-Version. - Validate
X-Yantra-Timestampis within ±300s (wall-clock; assumes NTP). - Compute the canonical string (
POST+ path + timestamp + sha256 hex). - HMAC-SHA256 + base64 + constant-time compare to
X-Yantra-Signature.
Python, Go, PHP, Java, and C# reference snippets will ship with the
auto-generated SDKs in v1.1 (see B2B_ROADMAP.md §17).
Secret rotation¶
Webhook secrets rotate on the same overlap-then-cut pattern as operator API credentials (security.md#rotation):
- The operator issues a new secret via
POST /v1/admin/webhooks/:id/rotate(portal UI at Settings → Webhooks → Rotate). WebhookSubscription.secretVersionincrements; both old and new secrets are stored.- The RGS signs outbound webhooks with the new secret, stamping
X-Yantra-Signature-Versionwith the new version. - The operator verifier is expected to accept both old and new for a configurable overlap window (default 24h).
- After confirmation, the operator revokes the old secret via
POST /v1/admin/webhooks/:id/revoke-version?v=<old>. The RGS drops the old secret.
Retry + idempotency semantics¶
- Retries: exponential backoff, 8 attempts, cap 24h. Per-attempt timeout
is 5 s. A retry carries the same
eventId,eventType,occurredAt, body, and signature, signatures are deterministic given the secret + canonical string, so retries are bit-identical to the first dispatch. - Success: any HTTP 2xx response from the operator. The RGS does not read the response body.
- Failure: any other status (3xx, 4xx, 5xx) or timeout. The next attempt is scheduled per the backoff schedule (roughly: 30s, 2min, 5min, 15min, 1h, 4h, 12h, 24h).
- Idempotency: operators MUST dedupe on
eventId. The RGS emits at least once; retries are expected to be idempotent on the operator side. - Persistent failure: after 8 attempts, the event is dead-lettered into the operator-portal "failed webhooks" view, alerting the on-call team. The operator can manually re-dispatch from the portal.
Event types¶
| Type | dataVersion |
Meaning |
|---|---|---|
round.settled |
1 | A round reached SETTLED state. Payload includes outcome + bet + payout totals. |
round.voided |
1 | A round moved to VOIDED (e.g. startup recovery found it unsettleable). |
session.terminated |
1 | A session ended, player logout, TTL expiry, or operator-initiated termination. |
session.rg_limit_tripped |
1 | A session's RG limit was hit at bet-time. Bet rejected. |
reconciliation.discrepancy |
1 | Daily reconciliation found a drift between RGS and operator ledgers. status: MINOR_DRIFT | MAJOR_DRIFT. |
credential.rotated |
1 | A credential on the Operator was rotated. Informational, no action required by the operator. |
credential.revoked |
1 | A credential was revoked. If it was the credential the operator was using, subsequent calls will fail with RS_ERROR_INVALID_SIGNATURE. |
webhook.test |
1 | Dispatched by the portal "Send test event" button. |
dataVersion is bumped whenever the payload shape changes in a
backward-incompatible way; operators that handle only their supported
dataVersion should ignore unrecognised versions safely.