Webhooks & Events
Be notified of status updates during workflows such as KYC, Onramp, and Offramp
Alfred pushes events to you. Every flow in this documentation has at least one point where the next call is only correct after an event has arrived, so this page is worth reading before you build anything else.
You do not need a polling loop. If you miss a delivery, GET /v1/events replays it.
What your endpoint has to do
Your endpoint has to meet all five of these requirements. An endpoint that does not meet them will leave events sitting in our retry queue for three days, and the events it holds up are usually the ones you need.
- Return a 2xx first and do the work afterwards on your own queue. Anything you do inline is time we spend holding the connection open, including database writes, downstream calls and ledger updates.
- Handle repeat deliveries of the same event. Delivery is at-least-once. Alfred guarantees that an event arrives, and it does not guarantee that the event arrives only once. The same event will reach you more than once and that is normal behavior rather than a fault. Deduplicate on
idbefore you act on anything. - Return a 2xx for events you do not care about as well. Ignoring an event is fine, but you must not fail on it. A non-2xx tells us the delivery did not work and we retry for 72 hours, so an endpoint that rejects the events it has no handler for generates retry traffic for events nobody wanted. Log the event, return a 2xx and move on.
- Do not break on an event type you have never seen. New event types get added without a version bump, so your handler needs a default branch that accepts and ignores an unknown type. An exhaustive switch that throws will break.
- Apply changes in
resource_versionorder instead of arrival order. Two events about one resource can arrive out of order. Compare against the version you last applied and drop anything older.
Rules 1 and 3 cause most of the integration problems we see. Both of them affect whether your own events get delivered on time.
Set up an endpoint
Register a URL and the events you want with POST /v1/webhook_endpoints.
{
"url": "https://your-app.example.com/alfred/events",
"event_types": [
"customer.*",
"deposit.*",
"deposit_intent.*",
"virtual_account.*",
"conversion.*",
"payout.*",
"linked_account.*",
"rfi.*"
]
}That subscription is the minimum needed to run all nine flows. You can subscribe to individual events, but most integrations take the whole set and branch on type in one handler.
The response carries a secret and it is returned only once. Store it before you do anything else. When you rotate a secret, Alfred issues the second one with an overlap window, so nothing becomes unverifiable partway through.
The envelope
Every delivery has the same shape.
{
"id": "evt_...",
"type": "deposit.credited",
"created": "2026-09-18T14:02:11Z",
"resource_version": 7,
"data": { "object": { } }
}| Field | What to do with it |
|---|---|
id | Deduplicate on this. The same event will arrive more than once. |
type | Branch on this value. |
created | The time the event happened. Delivery can be later. |
resource_version | This value is monotonic per resource. Apply changes in this order. |
data.object | The resource as it stands after the change. |
Delivery retries for 72 hours. If your endpoint does not return a 2xx, we keep trying for three days and the event then dead-letters. The event stays readable through GET /v1/events either way, so a failed delivery is recoverable at the cost of the delay and the queue traffic.
Verify the signature
Alfred signs every delivery. Reject anything that does not verify. Also reject anything whose timestamp falls outside your tolerance window, because otherwise a captured delivery can be replayed at you later.
Registering an endpoint returns a secret, shown once. That secret is the key you verify with, and like your API secret it is yours alone. Store it when you see it, because it is not retrievable afterwards.
Whatever the scheme turns out to be, build for two things. Verify against the raw request body before any JSON parsing, because re-serializing the body changes the bytes and breaks the comparison. Compare the signatures in constant time, because a byte-by-byte comparison that returns early leaks the signature one character at a time.
What each event means
Onboarding
These are the events you wait on between creating a customer and being able to collect for them.
| Event | Fires when | What to do |
|---|---|---|
customer.updated | The customer's status changed | Read the new status. INCOMPLETE, AWAITING_UBO and AWAITING_QUESTIONNAIRE each tell you what is outstanding, so prompt your end customer accordingly. |
customer.verification.updated | A verification moved state | ACTIVE means approved and REJECTED is terminal. Any other status means the decision is still in progress. |
customer.endorsement.updated | An endorsement changed | This fires on every transition. Only proceed to the instrument endpoint on APPROVED. Handle PAUSED and REVOKED as well, since they arrive here with no other warning and they stop new movement on existing instruments. |
rfi.updated | An RFI opened, was answered, resolved, rejected or went overdue | Check rfi_owner before chasing anyone. Under a reliance model the answer is often owed by you instead of your customer. A missed SLA is blocking. |
virtual_account.activated | A virtual account became usable | The deposit instructions are now safe to give to a payer. |
Money in
| Event | Fires when | What to do |
|---|---|---|
deposit_intent.matched | A deposit was attributed to one of your intents | Carries amount_outcome as EXACT, UNDER or OVER, along with how the match was made. A match is not a credit, because settlement and compliance still have to clear. |
deposit_intent.partially_matched | The deposit underpaid but stayed inside tolerance | This is a flavor of matched rather than a third outcome. The intent is consumed and completes the same way. If you previously treated it as still open and waiting for more money, you need to change that behavior. |
deposit_intent.expired | The intent expired with nothing attributed | This always reports zero. It tells you that nothing was attributed to this intent. It does not tell you that no money arrived. |
deposit.created | An arrival was detected with no intent behind it | This is an unannounced collection. There is no announced price for it, so the first rate you see is the settled one. |
deposit.updated | Any of the four state machines moved | Read all four statuses instead of inferring one from another. |
deposit.credited | The deposit settled | The deposit reaches transaction_status: COMPLETE with settlement_status: SETTLED. The funds are spendable from this point, and this is the only place the collection rate is disclosed. |
Money out
| Event | Fires when | What to do |
|---|---|---|
conversion.updated | A conversion moved state | Carries the quote, the execution rate and itemized fees. Use these to reconcile what was quoted against what was charged. |
payout.fiat.updated | A fiat payout moved state | COMPLETE is the only terminal success. A 202 earlier in the flow meant the payout went to policy review and was not rejected. |
payout.stablecoin.updated | A stablecoin payout moved state | The lifecycle is the same, and the event carries the chain reference once the payout is broadcast. |
refund.updated | A refund progressed or completed | A refund is a request. This event is how you learn whether it was honored. |
Operational
| Event | Fires when | What to do |
|---|---|---|
linked_account.updated | A destination's relationship status changed | Only a VERIFIED account can receive a payout. Watch for EXPIRED as closely as REJECTED. A relationship has a validity window, so a destination that worked last quarter can stop working without anyone touching it. |
country.route.updated | A route changed status, or a corridor opened | Re-read GET /v1/countries/{country}. A route that is not LIVE is refused before anything is locked, so this event tells you when a route you could not use before has become available. |
Replay
If your endpoint was down or you dropped a delivery, backfill the events instead of reconciling by hand.
GET /v1/events?since=2026-09-18T00:00:00Z
Deduplicate on id and apply in resource_version order, exactly as you would for live deliveries. GET /v1/events/{eventId} returns a single event if you have the ID from a log.
Common mistakes
A match is not a credit. deposit_intent.matched records which intent a deposit belongs to and consumes that intent. The money still has to settle and clear compliance. If you release goods on matched instead of credited, you are releasing against funds that have not landed.
Silence does not mean nothing happened. If you stopped receiving events, check your endpoint before you assume there was nothing to receive. Backfill with GET /v1/events?since= instead of reconciling by hand.
An endorsement can be taken away. customer.endorsement.updated is the only warning you get before PAUSED or REVOKED stops new movement on instruments that were working fine yesterday. Most integrations handle INCOMPLETE through to APPROVED and stop there.
Updated 5 days ago

