Skip to content

[Detail Bug] Checkout: Success postMessage can be sent before fulfillment when embed_origin is set without ?embed=true #12059

@detail-app

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_ecfbc7cf-6d21-4ce1-8c61-cda1d650ccf7/bugs/bug_10ecc79a-80c4-43f6-bb1f-1109f0c5c35a

Introduced in #11941 by @sebastianekstrom on May 27, 2026

Summary

  • Context: The useCheckoutConfirmedRedirect hook has a bug on line 55 where it checks embed (the URL parameter) instead of checkout.embed_origin (the server-side field) to determine whether to wait for payment fulfillment before sending the success postMessage.
  • Bug: It uses embed to decide whether to await listenFulfillment, but the intended condition is whether checkout.embed_origin is set.
  • Actual vs. expected: Actual: when embed=false but checkout.embed_origin is set, the hook sends a success postMessage immediately without waiting for fulfillment. Expected: it should wait for fulfillment whenever checkout.embed_origin is set (or when the success URL is external).
  • Impact: When embed=false but checkout.embed_origin is set, the hook sends a success postMessage immediately without waiting for fulfillment. Merchants may receive a success message for payments that later fail during fulfillment.

Code with Bug

// clients/apps/web/src/hooks/checkout.ts
if ((!isInternalSuccessURL || embed) && listenFulfillment) {
// <-- BUG 🔴 checks URL param `embed` instead of `checkout.embed_origin`, so fulfillment wait is skipped when embed_origin is set but embed=false

Explanation

The fulfillment wait is meant to prevent declaring success before the payment reaches a terminal state when either:

  • the redirect destination can’t observe in-flight state (external success URL), or
  • a merchant is listening for postMessages on the parent page (checkout.embed_origin).

However, the hook gates the wait on embed (UI chrome flag derived from ?embed=true) rather than checkout.embed_origin (server-provided communication address). This creates a real gap in flows where embed_origin is set server-side but the checkout URL does not include ?embed=true.

A concrete triggering flow exists via the checkout link redirect endpoint, which accepts embed_origin as a query parameter, persists it on the checkout session, and redirects to a checkout URL that does not include embed_origin or embed=true. In that case the frontend sees embed=false but checkout.embed_origin is set, so it skips listenFulfillment and can post success too early.

Codebase Inconsistency

Within the same hook, checkout.embed_origin is already used to decide whether to send postMessages (e.g., confirmed and success). The fulfillment wait should use the same signal (merchant listener presence), but it is the only place using embed instead.

Failing Test

it('waits for listenFulfillment when embed_origin is set (even without embed=true)', async () => {
  const listenFulfillment = vi.fn(() => Promise.resolve())

  await callHook({
    embed: false,
    listenFulfillment,
    checkout: baseCheckout({ embed_origin: EMBED_ORIGIN }),
  })

  expect(listenFulfillment).toHaveBeenCalledTimes(1)
})

Test output:

FAIL  src/hooks/checkout.test.ts > useCheckoutConfirmedRedirect > waits for listenFulfillment when embed_origin is set (even without embed=true)
AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times

Recommended Fix

Change the fulfillment-wait condition to check checkout.embed_origin:

if ((!isInternalSuccessURL || checkout.embed_origin) && listenFulfillment) {

History

This bug was introduced in commit ddffa36. The commit added logic to wait for payment fulfillment before emitting a success event to embedded checkouts, expanding the existing condition from !isInternalSuccessURL to (!isInternalSuccessURL || embed). The bug is a typo: the author wrote embed (the URL parameter controlling UI chrome) when the accompanying comment explicitly states the check should be for "a merchant is listening on the parent page (embed_origin)" — meaning checkout.embed_origin was the intended variable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions