Concepts
Webhooks
Signed events, how to verify them, and every type we send.
Add an endpoint
curl https://jusa.localhost.co.zw/dev/v1/webhook-endpoints -H "Authorization: Bearer jusa_sk_test_…" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/jusa/webhooks", "enabled_events": ["send.*", "reward.*"]}'
The answer has the endpoint's signing secret (whsec_…). URLs must be https and
public. Subscribe to *, a family such as send.*, or exact types; up to 16 endpoints per mode.
Test-mode endpoints hear test-mode events only.
What arrives
POST /jusa/webhooks
Jusa-Signature: t=1759230000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Jusa-Event: send.delivered
Jusa-Delivery: whd_123
User-Agent: Jusa-Webhooks/1.0
Content-Type: application/json
{"id": "evt_…", "object": "event", "type": "send.delivered", "api_version": "2026-10-01",
"created": 1759230000, "livemode": true,
"data": {"object": {…the send as it is now…}, "previous_attributes": {"status": "submitted"}},
"request": {"id": "req_…", "idempotency_key": "order-8812"}}
Verify every delivery
Jusa-Signature is t=<unix>,v1=<hex>, where the hex is the HMAC-SHA256 of
"{t}.{raw body}" under your endpoint's secret. Compute it over the raw bytes you received
(parsed and re-serialised JSON will not match), compare in constant time with any v1, and refuse a
t more than 5 minutes from now. While a secret is rolling there are two v1 values.
event = jusa.Webhook.construct_event(request.data, request.headers["Jusa-Signature"], secret) if already_processed(event.id): # MUST: dedupe on the event id return "", 200
// express.raw({ type: 'application/json' }) keeps the raw body app.post('/jusa/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const event = Jusa.webhooks.constructEvent(req.body, req.headers['jusa-signature'], secret); if (alreadyProcessed(event.id)) return res.sendStatus(200); // MUST: dedupe … });
# 1. split the header on "," into t=… and one or more v1=… # 2. expected = hex(HMAC_SHA256(key=secret, message=t + "." + raw_body)) # 3. accept if any v1 equals expected (constant-time compare) # and |now - t| <= 300 seconds
Handling them well
- Dedupe on
event.id. This is a must. The id is stable across redeliveries and a delivery can arrive more than once. - Order is not guaranteed.
data.objectis the object as it is now; compare itsstatusrather than trusting arrival order. - Answer
2xxwithin 10 seconds, then do the work. Anything else, or a timeout, is a failure. - Retries back off exponentially for 3 days (about 17 tries); in test mode for 6 hours. After 3 days of failures
the endpoint is disabled,
webhook_endpoint.disabledfires, and its owners get an email and a text. - Missed some?
GET /events?created[gte]=…is the catch-up feed (30 days), andPOST /webhook-endpoints/{id}/replay {"from", "to", "types"}sends a range again.POST /webhook-deliveries/{id}/redeliversends one again;POST /webhook-endpoints/{id}/testsends a synthetic event. - Webhooks come from Jusa's servers. If you allowlist by IP, ask support for the current addresses; the signature is what proves a delivery is ours.
ZESA tokens
A send.delivered for ZESA carries the token masked, plus a token_url when the endpoint has
token_links: a link that reads the token once, within 15 minutes, with no key. Only a key or member
holding tokens:read turns token_links on (it is on by default when they make the endpoint),
and moving the endpoint's URL without tokens:read turns it off; replaying or redelivering send events to
an endpoint that gets tokens needs tokens:read too. An endpoint made with include_tokens: true
by a key holding account:manage and tokens:read gets the token itself. Stored events, the
delivery log and /events always show it masked.
Rolling the secret
POST /webhook-endpoints/{id}/roll-secret {"grace": "24h"}: the new secret signs at once, and the old one
signs alongside it (a second v1=) for the grace period, so you can deploy the new one without dropping
a delivery.
The legacy format
An endpoint moved from the old single webhook URL keeps format: "legacy_airtime": the flat body,
order.success|failed|unknown|retrying events and X-Airtime-Signature: sha256=…, with
Jusa-* headers alongside. PATCH {"format": "jusa"} moves it to the envelope above; it never
moves back. A legacy endpoint's secret rolls at once.
Every event type
85 types. send.meter_changed goes to every endpoint whatever it subscribed to.
send
send.created · send.submitted · send.delivered · send.token_delivered · send.token_delivery_failed · send.retrying · send.queued · send.unknown · send.requires_review · send.failed · send.credited_back · send.late_delivered · send.scheduled · send.cancelled · send.meter_changed (always delivered) · send.token_resent
batch
batch.created · batch.requires_approval · batch.processing · batch.completed · batch.partially_completed · batch.failed · batch.cancelled
schedule
schedule.occurrence.upcoming · schedule.occurrence.ran · schedule.occurrence.skipped_insufficient_credit · schedule.occurrence.retry_after_topup
credit
credit.topup.pending · credit.topup.succeeded · credit.topup.failed · credit.topup.expired · credit.balance.low · credit.shortfall.projected · credit.adjusted · credit.reserved · credit.released
program
program.activated · program.paused · program.budget.low · program.exhausted · program.reactivated · program.closed
reward
reward.created · reward.held · reward.released · reward.voided · reward.expired · reward.delivered · reward.failed · reward.duplicate · reward.rejected
reward_link
reward_link.created · reward_link.opened · reward_link.claimed · reward_link.expired · reward_link.revoked
review
review.opened · review.resolved
activity
activity.rule_matched
referral
referral.qualified
agent_approval
agent_approval.requested · agent_approval.approved · agent_approval.failed · agent_approval.rejected · agent_approval.expired
checkout
checkout.session.paid · checkout.session.expired · checkout.session.fulfilled · checkout.session.resent · checkout.session.credited
zesa
zesa.availability.down · zesa.availability.up
network
network.availability.degraded · network.availability.restored
recipient
recipient.created · recipient.updated · recipient.erasure_pending · recipient.erased
dispute
dispute.opened · dispute.resolved
api_key
api_key.created · api_key.rolled · api_key.revoked · api_key.expiring
webhook_endpoint
webhook_endpoint.disabled