Verifying Deliveries
Request headers
Every webhook request is a POST with Content-Type: application/json, User-Agent: RynoPay-Webhooks/1.0, and these headers:
| Header | Value |
|---|---|
Ryno-Event | The event name, one of the event names. |
Ryno-Event-Id | The event. The same on every retry and every replay — this is your dedupe key. |
Ryno-Delivery | This delivery (one per event for your endpoint); a replay is a new delivery of the same event. |
Ryno-Attempt | 1, 2, … for this delivery. |
Ryno-Timestamp | Unix seconds when this attempt was signed. |
Ryno-Signature | t=<timestamp>,v1=<hex>[,v1=<hex>] — see below. |
Ryno-Replay | true on a replay you or RynoPay triggered; absent otherwise. |
Signature
hex = HMAC-SHA256(secret, "{t}.{body}")
hex is lower-case hex over the timestamp from the header, a dot, and the exact bytes of the request body. Recompute it, compare in constant time with any v1 value present, and reject deliveries whose t is more than five minutes old.
During the 24 hours after a secret rotation there are two v1 values, newest first, one per live secret; accept the delivery if either matches.
Example (Node.js)
const crypto = require('crypto');
// rawBody: the exact bytes of the request body, before any JSON parsing.
function verifyRynoSignature(rawBody, signatureHeader, secret) {
const parts = signatureHeader.split(',').map((p) => p.trim());
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!t || signatures.length === 0) return false;
// Reject deliveries whose t is more than five minutes old.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest('hex');
// Accept if any v1 value matches (two are present during a rotation).
const expectedBytes = Buffer.from(expected, 'hex');
return signatures.some((sig) => {
const actual = Buffer.from(sig, 'hex');
return actual.length === expectedBytes.length && crypto.timingSafeEqual(actual, expectedBytes);
});
}
The body
{
"id": "0f8c2b1e-...",
"type": "customer.onboarding.approved",
"specVersion": "1.0",
"eventVersion": 1,
"occurredAt": "2026-09-20T10:15:00.0000000+00:00",
"correlation": { "kind": "customer", "id": "b3f1..." },
"data": { }
}
| Field | Notes |
|---|---|
id | The event, equal to Ryno-Event-Id. The same on every retry and replay — treat a repeated id as a duplicate and ignore it. That is the safe way to make your handler idempotent. |
type | The event name, matching Ryno-Event. Ignore a type you do not recognise rather than failing: every partner receives every event, so new ones start arriving without a subscription step. |
specVersion | Version of the envelope. Fields will only be added within 1.0. |
eventVersion | Version of data for this type. A breaking payload change ships as a new version; you receive the latest. |
occurredAt | When the change happened, not when this attempt was made — a retry a day later still reports the original time. Deliveries are not ordered; use it to discard an update you have already superseded. |
correlation | What the event is about: kind is customer for every business onboarding event and id the customer's id, so you can route without parsing data. |
data | The event's payload. Its shape depends on type; see Events and payloads. |
Acknowledging
Return any 2xx within 10 seconds once you have stored the event, and do your own processing afterwards: a 2xx that arrives later than that counts as a failure and the delivery is retried, so you would see it again. Do not follow the delivery with a lookup unless you need to; the payload is complete.
If your endpoint does not acknowledge in time, the delivery is retried — see Retries and endpoint health.