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.)
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-ancestorspolicy; the embed refuses to frame on an un-allowlisted origin. - "Allow in-frame checkout" is on if you want
target=inlineor any item-pinned checkout.