Adyen transfer webhook processing¶
This page explains how Adyen transfer webhooks reach our system, what they trigger, and how delivery can fail. It does not contain procedural steps — see Re-issue missing webhooks for that.
The delivery contract¶
Adyen sends one webhook per transfer state change. The contract:
- Each webhook carries a
data.sequenceNumber, monotonically increasing from 1 per transfer. - The
data.eventsarray is cumulative: a webhook at sequence N contains all N events that have happened so far. Earlier events are repeated unchanged on later webhooks. - Adyen retries on non-2xx responses (limited attempts / window). After exhaustion the webhook is dropped.
- Past webhook payloads are visible in Customer Area → Developers → Event logs, with a 30-day retention.
Our processing pipeline¶
flowchart TD
A[Adyen] -->|HTTP POST + HMAC| W[Webhook controller]
W -->|publish| T1[adyen_transfer_topic]
T1 -->|subscribe| S[TransferTopicSubscriber]
S -->|dispatch by type/category| P[Card/Bank/Account processor]
P -->|insert| U[(TransferUpdate)]
P -->|insert| E[(TransferEvent)]
P -->|publish| T2[transfer_update_topic]
T2 -->|subscribe| D1[PaymentRequest status update]
T2 -->|subscribe| D2[Ledger entries]
T2 -->|subscribe| D3[ES TransactionDetails<br/>+ push notification]
A webhook arrives at the HMAC-verified controller and gets published to adyen_transfer_topic. The topic fans out to several independent subscribers — the one relevant here is TransferTopicSubscriber, which dispatches by (data.type, data.category) to the right processor (card / bank / account). The processor records the TransferUpdate row, persists per-event TransferEvent rows, and emits to transfer_update_topic. Downstream consumers in payment_gateway and es/healthy_benefits pick it up from there.
The topic between ingest and processing decouples retry/error behavior from delivery acknowledgement.
Other subscribers on adyen_transfer_topic
adyen_authorisation_relay (in authorizations/adapters/adyen/) also listens — it handles the authorization relay flow (separate concern, not covered here). Re-injecting a synthetic webhook will fan out to every subscriber on the topic, including the auth relay's on_payment_event path. For most reconstructed transfers the authorization is already finalized, so this is a no-op in practice; verify on a case-by-case basis if you're unsure.
What depends on TransferUpdate¶
Anything downstream of the transfer_update_topic emission only fires if the TransferUpdate row lands. Concretely:
PaymentRequeststatus advances (pending→succeeded/failed).- Ledger entries get written.
TransferEventrows persist per accounting event of the transfer.- (Spain)
EsTransactionDetailsrow is created by thecard_payment_update_notificationconsumer — this is what powers the user transactions screen. - Other pub/sub consumers subscribing to
transfer_update_topicare notified.
If the webhook is lost, none of the above happens for that sequence number.
How webhooks can be missed¶
Four named failure modes:
- Network loss between Adyen and us. The request never reaches the webhook controller. Adyen retries, but if the connectivity issue persists past Adyen's retry budget, the webhook is dropped.
- Our controller returns non-2xx. A bug, a transient error, a deploy window. Adyen retries up to its limit, then drops. The body lands in Adyen's event logs but is never processed on our side.
- Out-of-order delivery. Adyen does not guarantee strict ordering. Sequence N+1 can arrive before sequence N. Gaps may close on their own once the late delivery lands, so a fresh gap is not necessarily a missing webhook.
- Processor error after ack. We acknowledge the webhook but fail before persisting the
TransferUpdate. This shouldn't happen — the pubsub contract forbids one subscriber's failure from impacting another's transaction — so if you see it, treat it as a bug to fix upstream of the missing-webhook tooling.
Detecting a missed webhook¶
Patterns to look for:
- Gap in
transfer_update.sequence_numberfor a given(workspace_key, external_transfer_id). MAX(occurred_at)for the transfer is older than the late-delivery window (typically 6–24h).- Customer-facing symptoms: transfer stuck in a non-final state, or missing in the transactions screen.
The list_missing_transfer_webhooks command surfaces gaps that match these criteria — see the how-to.
Reconstruction strategy (high level)¶
When you find a confirmed gap, the synthetic-webhook reconstruction lets you re-emit it through adyen_transfer_topic so the downstream chain runs as if Adyen had delivered it.
- Stable fields (transfer id, account holder, payment instrument, counterparty) come from any sibling — they're identical across all sequences of the same transfer.
- Sequence-specific fields (
status,events,eventId) are derived from the next sibling's cumulative events list, sliced to[:N]. Adyen's 1-1 mapping (the N-th event of any sibling with seq ≥ N is the event that triggered sequence N) gives us the right values. - Optional Adyen dashboard payload fills per-sequence detail that siblings cannot supply:
balances,categoryData.validationFacts, exactcreationDate/updatedAt. Redacted leaves (***, partial***...arn) fall back to the sibling-derived values. - Marker on
data.description:ALAN_EXTRAPOLATED_WEBHOOK from=<seqs> [dashboard] | <original>. Survives thedataclasses_jsonroundtrip; queryable viaraw->>'description' LIKE 'ALAN_EXTRAPOLATED_WEBHOOK%'. - Idempotency: re-firing after the real webhook eventually arrives is a no-op. The unique constraint on
(workspace_key, external_transfer_id, sequence_number)+ON CONFLICT DO NOTHINGin the broker make double-injection safe.
Algorithm details live in synthetic_webhook.
See also¶
- Re-issue missing webhooks — the procedure when you've found a gap
- Transfers context — broader Transfers subcomponent overview
- Adyen webhook docs ⧉
- PAYM-439 ⧉