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. See
Webhooks for registering an endpoint, verifying the
Standard-Webhooks signature, and the delivery-envelope shape.
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.)
Anonymous purchases and in-portal changes. A purely anonymous (magic-link)
purchase still fires the same subscription webhooks, but with
partnerReferenceId: null — you get the customerId and the stable
subscriptionId, not your own order id. To tie a purchase back to your record,
pass a clientReferenceId when you mint the token — either
bound
or vouched. A
vouched checkout also echoes it on the customer-created webhook (see
event types), so a customer created at confirm
carries your order id from the moment it exists.
An upgrade or downgrade the buyer makes inside the portal arrives as a
structural-change subscription webhook (transitionType: plan_replaced) on that
same stable subscriptionId — see the event types —
so you follow one subscription across plan changes.
The ledgerbee:embed:checkoutConfirmed browser event AND the 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. The webhook is
the source of truth. Three reasons:
- The browser event can be lost. A browser that closes mid-redirect drops it, and nothing replays it.
?checkout=pendingmeans "inbox sent", not "subscribed". An anonymous checkout has only mailed a magic link at that point; the subscription does not exist until the buyer clicks it.?subscriptionId=arrives only onCONFIRMED. For an anonymous buyer that is a later, separate call to your page, after they verify — see when your page is called for which rails call you once and which call you twice.
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) |
| Tenant lacks the CustomerPortal license | resolve and the catalogue reads still answer — checkout is license-free. provision returns HTTP 403, body { "code": "LICENSE.REQUIRED" } (with details.licenseKey) | the tenant must hold the CustomerPortal license to provision portal access |
| 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
the
frame-ancestorspolicy; the embed refuses to frame on an un-allowlisted origin. Allowlisting is the only LedgerBee setup on your side. Loading the iframe and exchangingpostMessageevents with it are not governed by CORS, and the checkout's own requests run from inside the frame to the portal API, whose policy already permits the portal origin — nothing there needs configuring by you. Requests your own page makes directly, such as thefetchBindTokenprovider calling your backend, follow ordinary CORS rules and are yours to configure. A development server may be allowlisted over plainhttp, for the hostslocalhost,127.0.0.1, and[::1]only. - "Allow in-frame checkout" is on if you want
target=inlineor any item-pinned checkout.