Webhooks
Signed event notifications with automatic retries.
Register endpoints in Automation → Webhooks. Each subscription gets a secret (shown once) and a list of events to receive.
Subscriptions belong to the active organization. If you create a sandbox org for testing, register your endpoint there too — events from one org never reach another org's subscriptions.
Events
| Event | Fired when |
|---|---|
order.created | An order is created (payment confirmed) |
order.completed | The order is finalized — for one-time products this fires together with order.created |
benefit.granted | A benefit (e.g. license key) was granted for a paid order |
checkout.session.expired | A session failed, was cancelled, or hit its 30-minute TTL without payment |
Payload envelope
{
"id": "665f1c2ab8d3a2f4e1a9c001",
"type": "order.created",
"created": "2026-07-11T09:30:00.000Z",
"livemode": false,
"data": {
"orderId": "665f1c2ab8d3a2f4e1a9c111",
"productId": "665f1c2ab8d3a2f4e1a9c222",
"customerId": "665f1c2ab8d3a2f4e1a9c333",
"customerEmail": "buyer@example.com",
"customerExternalId": null,
"amount": 10000,
"currency": "ETB",
"status": "paid",
"metadata": { "campaign": "launch" },
"paidAt": "2026-07-11T09:29:58.000Z"
}
}livemode is false for sandbox organizations — filter on it if one endpoint serves both
environments. amount is what the buyer paid (your listed price), in ETB minor units.
benefit.granted data: grantId, benefitId, benefitType, orderId, customerId,
and payload (for license_key benefits, payload.licenseKey holds the key).
Verifying signatures
Every delivery is signed with HMAC-SHA256 over "{timestamp}.{rawBody}" using your
subscription secret:
X-JamiDev-Signature: t=1783695518,v1=5257a869e7...
X-JamiDev-Event: order.createdThe jami-sdk does the verification (timing-safe compare + replay
guard) and returns the typed event:
import { Jami, JamiSignatureError } from 'jami-sdk';
const jami = new Jami({ token: process.env.JAMI_TOKEN! });
const event = await jami.webhooks.verify({
payload: rawBody, // the RAW request body string
signature: req.headers['x-jamidev-signature'],
secret: process.env.JAMI_WEBHOOK_SECRET!,
});
// event.type is 'order.created' | 'order.completed' | 'benefit.granted' | 'checkout.session.expired'Verifying manually (any language) works the same way:
import crypto from 'node:crypto';
function verifyJamiDevSignature(rawBody: string, header: string, secret: string): boolean {
const t = /t=(\d+)/.exec(header)?.[1];
const v1 = /v1=([a-f0-9]+)/.exec(header)?.[1];
if (!t || !v1) return false;
// Reject stale timestamps to prevent replay (5 minutes is a sane window)
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Always verify against the raw request body — parsing and re-serializing JSON can change key order and break the signature.
Delivery & retries
- Respond with any 2xx within 10 seconds to acknowledge.
- Failed deliveries retry with backoff: 1m → 5m → 30m → 2h → 12h (5 attempts total).
- Every delivery and its response code is visible on the Webhooks page, with a manual resend button.
- Handle events idempotently — retries and manual resends mean you may see the same event id more than once.
