Webhooks
The platform tells your product what happened by calling an address you registered: a webhook. Every call is signed, retried for 72 hours if you do not answer, and carries a stable event id so you can deduplicate.
Register an endpoint
const endpoint = await trida.webhookEndpoints.create({
url: 'https://app.example.com/webhooks/trida',
eventTypes: ['pay.intent.succeeded'],
});
endpoint.secret; // "whsec_..." : returned ONCE, store it now
- The address must be public HTTPS: no
http, no credentials in the URL, and no private or reserved address (the platform checks every address the name resolves to). Redirects are never followed. eventTypesare exact event types of the formdomain.object.action(pay.intent.succeeded). An endpoint only receives the types it subscribed to.- A project holds at most 20 endpoints.
- The secret is shown once. The platform keeps it encrypted and can never show it again: if you lose it, rotate.
What you receive
POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
Trida-Signature |
t=<unix seconds>,v1=<hmac> (two v1 during a rotation) |
Trida-Event-Id |
the event id: the same on every attempt, deduplicate on it |
Trida-Delivery-Attempt |
1 for the first delivery, 2 for the first retry... |
Trida-Request-Id |
the id of the request that produced the event, for your logs (absent if none) |
User-Agent |
Trida-Webhooks/1 |
{
"id": "6f1c2a52-4f3d-4c53-9a3e-1b8f0d6a7c11",
"type": "pay.intent.succeeded",
"createdAt": "2026-10-18T10:00:00.000Z",
"data": { "...": "the event payload" }
}
Verify the signature
The signature is HMAC-SHA256 (hex) of the string "<t>.<raw body>", keyed with the endpoint secret. Always verify the raw bytes you received, never a parsed and re-serialized JSON. In TypeScript the SDK does it:
import { verifyWebhook } from '@tridafactory/sdk';
const result = verifyWebhook(rawBody, headers, [process.env.TRIDA_WEBHOOK_SECRET!]);
if (!result.valid) throw new Error(result.reason); // 'missing-signature' | 'malformed' | 'expired' | 'mismatch'
verifyWebhook accepts the two secrets of a rotation, refuses a signature older than five minutes (or that far in the future), and compares in constant time. In another language, implement exactly these steps:
- Read the
Trida-Signatureheader and split it on commas intot=...and one or morev1=.... - Reject if
|now - t| > 300seconds. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex, for each secret you know. - Accept if any computed value equals any
v1(compare in constant time).
A complete, tested receiver:
import { verifyWebhook } from '@tridafactory/sdk';
/**
* Receive a Trida webhook: verify the signature on the RAW body, then act. Framework independent: pass the raw body exactly
* as received (never a parsed and re-serialized JSON), the request headers, and the secrets you know.
*
* `secrets` holds ONE secret normally, TWO during a rotation (the new one and the previous one): either signature is accepted.
*/
export interface WebhookResult {
status: number;
body: string;
}
export function handleTridaWebhook(
rawBody: string,
headers: Record<string, string | string[] | undefined>,
secrets: string[],
seenEventIds: Set<string>,
): WebhookResult {
const verification = verifyWebhook(rawBody, headers, secrets);
if (!verification.valid) {
// 400, not 401: the sender is Trida, and a retry with the same bytes would fail the same way.
return { status: 400, body: `invalid signature: ${verification.reason}` };
}
// The same event can arrive more than once (delivery is at least once): deduplicate on the event id.
const eventId = verification.eventId;
if (eventId !== null && seenEventIds.has(eventId)) {
return { status: 200, body: 'already processed' };
}
if (eventId !== null) seenEventIds.add(eventId);
const event = JSON.parse(rawBody) as {
id: string;
type: string;
createdAt: string;
data: unknown;
};
switch (event.type) {
case 'core.webhook.test':
console.log('Trida test event received', event.id);
break;
default:
console.log('event', event.type, event.id);
}
// Any 2xx tells Trida the event is delivered. Anything else is retried (1 min, 5 min, 30 min, 2 h, 8 h, 24 h...).
return { status: 200, body: 'ok' };
}
Answer fast, answer 2xx
- Any 2xx answer means delivered. Anything else (a 4xx, a 5xx, a redirect, a timeout, a refused connection) is a failure.
- The platform waits 10 seconds at most. Do the work after answering, not before.
- Delivery is at least once: you can receive the same event more than once. Deduplicate on
Trida-Event-Id.
Retries and the dead letter
A failed delivery is retried at 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours, then every 24 hours, until 72 hours after the event was created. After that the delivery is dead: the platform stops, and the platform operators are alerted. Each attempt is signed afresh with the current time, so a retry hours later is still valid.
Each endpoint has its own state: an event delivered to endpoint A and failing for endpoint B keeps retrying for B only.
If an endpoint keeps failing, the platform suspends calls to that host for a minute after five consecutive failures, instead of piling up requests that would each wait 10 seconds; its deliveries stay scheduled and resume.
Replay an event
An event that failed or died can be sent again without recreating it:
await trida.events.replay(eventId);
The same event id goes to the active endpoints subscribed to its type today (an endpoint created since receives it; one deleted does not), signed with the secrets of the moment. Each replay opens a new generation of deliveries with a fresh 72-hour budget; earlier deliveries keep their history.
Send yourself a test event
await trida.webhookEndpoints.test(endpointId);
A core.webhook.test event, signed and delivered like any other, to that endpoint only (whatever it subscribed to). Use it to check your receiver.
Rotating a secret
Rotate without interrupting anything:
trida.webhookEndpoints.rotateSecret(id)returns a new secret. The previous one keeps signing next to it: every delivery now carries twov1signatures.- Deploy your receiver with both secrets (
verifyWebhook(rawBody, headers, [newSecret, oldSecret])). trida.webhookEndpoints.retirePreviousSecret(id)ends the rotation: only the new secret signs.
Rotating twice in a row drops the oldest secret.
Delete an endpoint
trida.webhookEndpoints.delete(id) removes the endpoint, its secrets and its pending deliveries.