Overview and delivery guarantees
Card issuing is asynchronous. A card can take days to be personalized and shipped. An authorization happens when a cardholder taps a terminal you don't control. A dispute might not show up until weeks after the original transaction.
You can't get that information from the response to an API request you made earlier. Webhooks are how those events reach your system.
Polling isn't a good substitute. It's slower, and polling endpoints are heavily rate-limited.
The event object
Every webhook is delivered as a POST request with a body like this:
{
"id": "evt_01M480J2CTN4T8D9E8D2TBN1KC",
"object": "event",
"type": "cardholder.created",
"api_version": "2026-08-01",
"environment": "sandbox",
"created_at": "2026-10-06T07:06:30Z",
"data": {
"object": {
"id": "chd_01M480J2CAQ99KCTMWDJTF9ENN",
"type": "individual",
"email": "weichun004@example.test",
"object": "cardholder",
"last_name": "User 1",
"reference": "ctrip.1",
"created_at": "2026-10-06T07:06:30Z",
"first_name": "Ctrip",
"kyc_status": "not_started",
"updated_at": "2026-10-06T07:06:30Z",
"has_identity_document": true
}
}
}
| Field | Purpose |
|---|---|
id | Unique per event. Use this for deduplication. |
type | Describes what happened. See the event catalog. |
created_at | When the event occurred, not when it was delivered. |
api_version | The API version used to serialize the payload. |
data.object | The affected object, using the same shape returned by the REST API. |
data.object contains the full object rather than just the fields that changed.
In most cases, you don't need to fetch the object again.
If you need the object's current state rather than the state captured when the event was created, fetch it again.
Headers
Webhook requests also include a few headers:
POST /webhooks/aleta HTTP/1.1
Content-Type: application/json
Aleta-Signature: t=1785508332,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Aleta-Event-Id: evt_01HX8ZQ2K4M7P9RSTV
Aleta-Event-Type: card.frozen
Aleta-Delivery-Attempt: 1
Aleta-Event-Id and Aleta-Event-Type are also included in the request body.
That's intentional: it lets a gateway route or deduplicate a request without
having to parse the payload first.
Don't trust these headers until you've verified the signature. Until then, treat everything in the request as untrusted input.
What is guaranteed
At-least-once delivery: An event is delivered at least once as long as your
endpoint eventually returns a 2xx response. We retain events and retry failed
deliveries for 72 hours.
Signed payloads: Every delivery includes an HMAC-SHA256 signature covering the timestamp and raw request body. See signature verification.
Durable events: Events are stored for 30 days and remain available in Cockpit for replay, regardless of whether the original delivery succeeded.
What is not guaranteed
A few important things are deliberately not guaranteed. Most webhook bugs in production tend to come from assuming one of these is true when it isn't.
Events are not guaranteed to arrive in order.
For example, card.activated can arrive before card.created. Retries can
change the order of delivery, and unrelated events can race with each other.
Use
created_atwhen you need to reason about event order, and don't apply an event if it represents a state older than the state you've already recorded. Don't rely on a fixed event sequence.
Exactly-once delivery is not guaranteed.
You can receive the same event more than once. This can happen when your
endpoint sends a 2xx response but we don't receive it successfully. Use the
event id to deduplicate.
Delivery time is not guaranteed.
Most events arrive in under a second, but a retry can arrive hours later. Don't make your UI wait for a webhook before it can continue.
You aren't guaranteed to receive a complete sequence within a particular time window.
If your system is offline for a while, you may come back to a burst of events covering several days. Process each event based on its own contents rather than assuming you're receiving a perfect, uninterrupted stream.
Responding to a webhook
Your endpoint should return a 2xx response within 10 seconds.
A 4xx, 5xx, timeout, or TLS failure is treated as a failed delivery and causes a retry.
The most important rule is simple:
Acknowledge first, process afterwards.
For example:
app.post('/webhooks/aleta', express.raw({ type: 'application/json' }), (req, res) => {
const event = verifyAndParse(req); // fast, in-process
queue.enqueue(event); // durable queue
res.sendStatus(200); // acknowledge immediately
});
Keep the webhook handler small. Database writes, third-party API calls, emails, and other slow operations should happen after the event has been acknowledged.
If one of those dependencies becomes slow or unavailable, doing the work inside the handler can cause webhook failures, which leads to more retries and eventually retry storms. An endpoint that keeps failing can also be disabled.
The response body doesn't matter. We ignore it, so there's no need to put anything meaningful there.
Endpoint requirements
Your webhook endpoint must meet these requirements:
- HTTPS with a certificate that validates against a public root. Self-signed certificates aren't accepted.
- A publicly resolvable hostname. Private addresses,
localhost, and internal-only DNS aren't accepted. If you try to register one, the API returnsinvalid_webhook_url. - A
POSTendpoint that returns a2xxresponse within 10 seconds.
You can register up to 10 endpoints per environment. Each endpoint can subscribe to its own set of event types.
It's also fine to send all the events to a single endpoint and filter them in your application. That's a common setup.
Failure handling
Failed deliveries are retried using exponential backoff for up to 72 hours. If an endpoint fails continuously for 72 hours, it is automatically disabled. The events aren't lost when this happens. They're still stored and can be replayed after you re-enable the endpoint.
Next
- Signature verification — verify the webhook before doing anything else.
- Card issuing events — every event type and its payload.