Skip to main content

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
}
}
}
FieldPurpose
idUnique per event. Use this for deduplication.
typeDescribes what happened. See the event catalog.
created_atWhen the event occurred, not when it was delivered.
api_versionThe API version used to serialize the payload.
data.objectThe 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_at when 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 returns invalid_webhook_url.
  • A POST endpoint that returns a 2xx response 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​