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.

  1. 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.
  2. 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 id before you act on anything.
  3. 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.
  4. 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.
  5. Apply changes in resource_version order 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": { } }
}
FieldWhat to do with it
idDeduplicate on this. The same event will arrive more than once.
typeBranch on this value.
createdThe time the event happened. Delivery can be later.
resource_versionThis value is monotonic per resource. Apply changes in this order.
data.objectThe 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.

EventFires whenWhat to do
customer.updatedThe customer's status changedRead the new status. INCOMPLETE, AWAITING_UBO and AWAITING_QUESTIONNAIRE each tell you what is outstanding, so prompt your end customer accordingly.
customer.verification.updatedA verification moved stateACTIVE means approved and REJECTED is terminal. Any other status means the decision is still in progress.
customer.endorsement.updatedAn endorsement changedThis 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.updatedAn RFI opened, was answered, resolved, rejected or went overdueCheck 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.activatedA virtual account became usableThe deposit instructions are now safe to give to a payer.

Money in

EventFires whenWhat to do
deposit_intent.matchedA deposit was attributed to one of your intentsCarries 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_matchedThe deposit underpaid but stayed inside toleranceThis 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.expiredThe intent expired with nothing attributedThis always reports zero. It tells you that nothing was attributed to this intent. It does not tell you that no money arrived.
deposit.createdAn arrival was detected with no intent behind itThis is an unannounced collection. There is no announced price for it, so the first rate you see is the settled one.
deposit.updatedAny of the four state machines movedRead all four statuses instead of inferring one from another.
deposit.creditedThe deposit settledThe 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

EventFires whenWhat to do
conversion.updatedA conversion moved stateCarries the quote, the execution rate and itemized fees. Use these to reconcile what was quoted against what was charged.
payout.fiat.updatedA fiat payout moved stateCOMPLETE is the only terminal success. A 202 earlier in the flow meant the payout went to policy review and was not rejected.
payout.stablecoin.updatedA stablecoin payout moved stateThe lifecycle is the same, and the event carries the chain reference once the payout is broadcast.
refund.updatedA refund progressed or completedA refund is a request. This event is how you learn whether it was honored.

Operational

EventFires whenWhat to do
linked_account.updatedA destination's relationship status changedOnly 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.updatedA route changed status, or a corridor openedRe-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.


Did this page help you?