Deep Link / Headless
Headless is the pattern where the destination calls Firmly’s REST API for every step of the purchase. There’s no iframe, no hosted page, no redirect — the destination’s code is the checkout UI, or the checkout has no UI at all.
This is the most flexible pattern and the most code to write on the destination side. Use it when you need conversational, voice, or autonomous flows that can’t fit a visual checkout.
When to use it
- Conversational and voice surfaces where checkout happens through dialogue
- Fully autonomous flows that complete purchases without per-step human confirmation
- Custom surfaces where neither hosted nor embedded fits (AR/VR, in-car systems, IoT, smart speakers)
- The destination wants total control over UX and accepts the corresponding compliance work (see Consent & Disclosure)
Don’t use this when Hosted or Embedded fits — they reduce both code and PCI scope on the destination side.
The full API sequence
A complete single-product purchase via headless is the sequence below. Each row links to the canonical API reference — payloads are documented there, not duplicated here. For a runnable end-to-end script, see the Advanced Checkout Guide.
| # | Call | Purpose |
|---|---|---|
| 1 | Browser session | Bootstrap auth — get a JWT |
| 2 | Discovery search | Find products (results carry a domain per product) |
| 3 | Add line item | Add an item (cart created implicitly) |
| 4 | Set shipping info | Set shipping address — response carries shipping_method_options per shipment |
| 5 | Get shipping availability | (Optional) Delivery dates / time slots / pickup locations |
| 6 | Set shipping method | Pick a method from the shipment’s shipping_method_options |
| 7 | Get consents → Set consents | (If the merchant requires consents) Fetch required consents, surface each to the buyer, capture acceptance. Skipping required consents makes complete-order return RequiredConsentsNotSigned (412) |
| 8 | Get payment public key | Fetch the public key for card encryption |
| 9 | (client-side) JWE-encrypt the card | No network call — see Get payment public key for the encryption requirements |
| 10 | Complete order (v2) | Finalize the cart built in steps 3–8 (body: encrypted_card + billing_info) |
For multi-product, repeat step 3 per item. For multi-shipment, repeat step 6 per shipment. For Klarna express, swap steps 8–10 for the Klarna express sequence.
Authentication choice
Headless integrations have two auth patterns:
| Pattern | Best for | Reference |
|---|---|---|
| Browser session | Client-side or ambient surfaces that act on behalf of a session | Browser session |
| Server-to-server | Backend services acting with a service identity | Server-to-server |
S2S is simpler for backend deployments. See the Authentication overview for picking between them.
Payment options when going headless
| Method | How |
|---|---|
| Card | JWE-encrypt with the payment public key, pass encrypted_card in complete-order |
| Klarna BNPL | Use the Klarna Express Checkout sequence — start → authorize → complete-order |
| PayPal Express | Use the PayPal Express Checkout sequence — start → authorize → complete-order |
| Click to Pay | When the user has a saved Visa/Mastercard via Click to Pay, the flow short-circuits payment — coordinate with Firmly |
Retries and idempotency
Headless flows are exposed to all the failure modes of distributed systems — network blips, retries, mid-flight cancellations. How you dedupe depends on the path:
- UCP protocol bridge — pass an
Idempotency-Key(a UUID generated once per logical attempt) on each mutation. The bridge caches the first response per key for 24 hours and returns it on retry, so the underlying operation runs at most once. Reuse the same key when retrying the same attempt; generate a fresh key for a genuinely new attempt. - Core REST — the path this page describes (
add-line-item,set-shipping-info,complete-order, etc.) does not deduplicate on theIdempotency-Keyheader today. A retry re-runs the operation. Guard against double-placement client-side: on a failed or timed-outcomplete-order, re-readcart_statusbefore retrying — if it’s alreadysubmitted, the order landed, so don’t resubmit.
// Core REST complete-order has no server-side dedup — guard by re-reading cart state.async function placeOrderSafely(order) {try {return await callFirmly(placeOrderUrl, {method: 'POST',headers: { ...auth },body: JSON.stringify(order),});} catch (e) {const cart = await callFirmly(getCartUrl, { headers: { ...auth } });if (cart.cart_status === 'submitted') return cart; // already placed — do not retrythrow e; // safe to retry}}
See Idempotency for the canonical guidance on the UCP bridge, and the scope note in Errors & Conventions for why core REST isn’t deduplicated.
Error handling shape
// A real Error subclass so stack traces and `instanceof Error` keep working.class FirmlyError extends Error {constructor({ status, code, error, description }) {super(description || error);this.name = 'FirmlyError';this.status = status; // HTTP statusthis.code = code; // numeric HTTP status from the envelopethis.error = error; // PascalCase error name, e.g. CreditCardDeclinedthis.description = description;}}async function callFirmly(url, options) {const resp = await fetch(url, options);const body = await resp.json();if (!resp.ok) {// Core REST envelope: { code, error, description }throw new FirmlyError({ status: resp.status, ...body });}return body;}try {await callFirmly(placeOrderUrl, { ... });} catch (e) {switch (e.error) {case 'NotEnoughStockError':// re-fetch product, ask user to confirm new quantitybreak;case 'CreditCardDeclined':// ask user for different payment methodbreak;case 'StoreUnavailable':// exponential backoff retrybreak;default:// surface to user}}
See Errors for the full catalog.
Concurrency patterns
Headless flows often have opportunities to parallelize:
- Catalog lookup + cart fetch (when resuming a conversation) — parallel
- Shipping availability for multiple shipments — parallel, one call per
shipment_id - Payment key fetch + JWE encryption — sequential, but key fetch can be cached briefly
Don’t parallelize:
- Calls that mutate the same cart — they’ll race and the last one wins
- Duplicate
complete-ordercalls for the same cart — core REST doesn’t dedupe them, so concurrent submits can double-place; serialize and re-readcart_statusbetween attempts
What the destination takes on by going headless
- The destination’s UI is the checkout UI — address forms, error messages, retry flows
- The destination’s PCI scope includes any handling of the encrypted card payload (transit, logging)
- The destination is responsible for pre-purchase consent and disclosure
- The destination owns retries, idempotency, and error recovery
In exchange the destination gets the most natural conversational flow and the most surface flexibility.