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

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.object is the object as it is now; compare its status rather than trusting arrival order.
  • Answer 2xx within 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.disabled fires, and its owners get an email and a text.
  • Missed some? GET /events?created[gte]=… is the catch-up feed (30 days), and POST /webhook-endpoints/{id}/replay {"from", "to", "types"} sends a range again. POST /webhook-deliveries/{id}/redeliver sends one again; POST /webhook-endpoints/{id}/test sends 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.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