Skip to content

[Sessions / Drop-in v6] onPaymentFailed and onPaymentCompleted both fire for the same 3DS transaction — 400ms apart #4014

Description

@FBalo45

Environment
SDK: @adyen/adyen-web ^6.29.0
Integration type: Sessions flow + Drop-in
Environment: Live (production only — not reproducible locally)
Flow: Card payment with 3DS challenge (long challenge, 15–30s user wait)
Description
In production, on certain 3DS-challenged card payments, both onPaymentFailed and onPaymentCompleted fire for the exact same transaction — approximately 400ms apart.

According to the v6 migration guide, these two callbacks are supposed to be mutually exclusive: onPaymentCompleted no longer fires for failed payments, and failed payments go exclusively through onPaymentFailed. This production behaviour violates that contract.

Observed timeline (production logs)
Image

Minimal reproduction
Not reproducible locally or in the test environment. Occurs only in production, specifically during 3DS challenges with a long response time (15–30 seconds).

Trigger conditions identified from logs:

Card payment requiring a 3DS challenge (native flow, not redirect)
Challenge takes 15–30 seconds to complete
High-latency network conditions (mobile, etc.)
Code
Our useEffect initialises a single Adyen checkout instance. The effect does not re-run during the payment — confirmed via logs. There is only ever one Drop-in instance in memory.

Image

Impact on our cart flow
We rely heavily on onPaymentFailed to handle a specific business case: when a 3DS challenge is refused, we need to immediately call our backend to dissociate the payment from the cart before allowing the user to retry. This is a critical step — leaving a refused payment linked to the cart breaks subsequent payment attempts.

Because of the double-fire, the sequence becomes catastrophic:

onPaymentFailed → cart dissociated, user redirected to /summary
onPaymentCompleted (431ms later, Authorised) → user pushed to /prepare-order with a dissociated cart — order creation fails
The real payment was authorised. The user paid. But our backend cannot process the order because the cart link was already destroyed by the premature onPaymentFailed.

Questions
Is this a known SDK bug in the ^6.x Sessions flow? Is there a patch version that addresses it?
Is it a race condition between the iFrame postMessage result and the /sessions/result network call, where both resolve independently and trigger separate callback paths?
Given that the v6 contract states these callbacks are mutually exclusive, what is the recommended pattern to safely handle Refused 3DS results when you cannot delay backend actions until onPaymentCompleted?
Should we defensively validate onPaymentFailed events by polling GET /sessions/{id} before acting — and if so, is there a recommended debounce window?
Workaround attempted
We added a paymentHandled mutex flag shared across both callbacks so only the first one to fire takes effect. This prevents the double-action, but it means that when onPaymentFailed fires first (with Refused), a genuine subsequent Authorised result from onPaymentCompleted is silently dropped — the user paid but gets stuck on the error screen. Not acceptable.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions