# Fulfillment & errors

## Fulfillment & reconciliation

Fulfill and reconcile through **LedgerBee webhooks**, not the client redirect or
the `checkoutConfirmed` browser event. The bound subscription fires
`subscription.started` / `subscription.assigned` (and every later subscription
webhook) carrying `partnerReferenceId` — the `clientReferenceId` you passed at
provision — so you can match our subscription to your order. Subscribe to those
topics and key your fulfillment on `partnerReferenceId`.

**Recover a missed webhook.** If a delivery is lost (your endpoint was down, you
never subscribed yet, a backfill), resolve our subscription from your own order id
over the public API: `GET /v1/subscriptions?partnerReferenceId=<your clientReferenceId>`.
The subscription read responses now carry `partnerReferenceId`, and the list
accepts it as a filter — so the join key is reachable without replaying the
webhook. (Requires the `subscriptions-read` scope; see the
[list-subscriptions operation](/api/subscriptions#list-customer-subscriptions).)

The `ledgerbee:embed:checkoutConfirmed` browser event AND the breakout `returnUrl`
redirect are **UX-only** — use them to remove the iframe, show a thank-you, or
hand the buyer back to your app, never as a fulfillment trigger. A browser that
closes mid-redirect drops the event, and the anonymous path redirects to
`returnUrl` with `?checkout=pending` once a magic-link has only been **sent** —
the subscription does not exist until the buyer clicks it. `returnUrl` therefore
carries `?subscriptionId=` ONLY on `CONFIRMED`; `?checkout=pending` is "inbox
sent", not a completion. The webhook is the source of truth.

## Errors & failure states

| Situation | What you observe | What to do |
|---|---|---|
| Plan not public yet | the `/embed/<vanity>` iframe shows a "not found" state | operator publishes the plan + allows it via a portal routing rule |
| Your origin not allowlisted | the browser refuses to frame the embed (CSP `frame-ancestors`); a console error, and no events fire | operator adds your site's origin to the partner-origin allowlist (Settings → Portal → Embedding) |
| Provision: missing scope | HTTP `403`, body `{ "code": "INSUFFICIENT_PERMISSIONS" }` | the API key needs `portal-provision` (+ `portal-sso-mint` for the token) |
| Provision: unknown `customerId` | HTTP `404`, body `{ "code": "CUSTOMER_NOT_FOUND" }` | the `customerId` must reference an existing customer in your tenant; this call never creates one from an id (send a `customer` object to create) |
| Bind ref expired / reused / no provider | the embed proceeds with the anonymous (magic-link) checkout; no hard error | register a `fetchBindToken` provider that mints a fresh ref per call; if a buyer dwells past the ~60s TTL, re-starting checkout mints a new one |
| Bind ref for the wrong tenant, or for a different customer than a signed-in buyer | `POST /api/checkout/session` returns `400 PORTAL_CHECKOUT_SESSION_INVALID` (`reason: TENANT_OR_ITEM_MISMATCH`); the embed bounces back to the cards and fires `ledgerbee:embed:checkoutError` | a security reject; don't hand a checkout a foreign-tenant ref, or an authenticated buyer a foreign-customer ref |
| Checkout session expired (30-min TTL) or already consumed | confirm / resume returns `410 PORTAL_CHECKOUT_SESSION_INVALID` (`reason: EXPIRED_OR_NOT_FOUND`) | re-start checkout for a fresh session — distinct from a forged id (`400`) |
| Duplicate confirm (double-click, network retry) | `409 PORTAL_CHECKOUT_IN_PROGRESS` | the first submit is being processed; don't resubmit — exactly one subscription is created |
| Buyer abandons | nothing is persisted | server-side checkout sessions expire on their own (30 min) |

## Operator prerequisites (configured in LedgerBee → Settings → Portal → Embedding)

- The plan has a public vanity URL and is published + allowed by a routing rule.
- Your site's origin(s) are on the tenant's partner-origin allowlist. This drives
  both CORS and the `frame-ancestors` policy; the embed refuses to frame on an
  un-allowlisted origin.
- "Allow in-frame checkout" is on if you want `target=inline` or any item-pinned checkout.
