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",
"sessionId": "665f1c2ab8d3a2f4e1a9c000",
"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.
sessionId is the same id returned by createCheckout (session._id) —
use it to correlate an event back to the checkout you started, without having to store the
orderId mapping yourself. It appears on order.created, order.completed, benefit.granted,
and checkout.session.expired.
benefit.granted data: grantId, benefitId, benefitType, orderId, sessionId,
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.
