Pharos Docs
Collections

Cart Checkout

Build a backend-owned dynamic cart with Checkout Sessions and hosted checkout.

Use Checkout Sessions when your backend owns a concrete cart or purchase attempt. The session may reference catalog Prices with priceId, or snapshot inline priceData without creating Product, Plan, or Price records.

Keep x-api-key on your backend. Never call /api/v1 directly from browser code and never accept authoritative prices, recurrence, or business scope from the browser.

Runtime architecture

Buyer browser
    → your backend
        → Pharos /api/v1/checkout-sessions
            → Pharos-hosted /checkout/session/:id
                → shared quote, invoice, subscription, and payment pipeline
                    → signed webhook to your backend

Your backend owns cart construction and session mutations. Pharos owns the hosted buyer experience and the downstream commercial records. The browser only needs the returned checkout url.

Runtime flow

  1. Browser → your backend: The buyer chooses Checkout. Send only your own cart identity or authenticated cart context—not prices supplied by the browser.
  2. Your backend → Pharos API: Create a Checkout Session with POST /api/v1/checkout-sessions and a stable Idempotency-Key for that create operation.
  3. Your backend stores the response: Associate the returned id, version, ETag, and url with your cart.
  4. Your backend optionally mutates the cart: Add, update, or remove items while the session is open and unprepared. Every mutation uses a unique Idempotency-Key and the latest ETag in If-Match.
  5. Backend → browser: Return only the hosted url; the browser redirects to /checkout/session/:id.
  6. Buyer → Pharos Checkout: The buyer completes the same hosted checkout used by Payment Links. Browser-side quantity changes are constrained by the server-owned item snapshot.
  7. Checkout → shared commerce pipeline: Pharos prepares the final quote and creates the applicable customer, invoice, subscription, payment, and payment-attempt records.
  8. Pharos ↔ payment provider: The result may be immediate, redirect-based, actionable, or asynchronous.
  9. Pharos → buyer: successUrl or cancelUrl controls navigation when applicable; neither URL proves payment success or expires the session.
  10. Pharos → your webhook endpoint: Reconcile by checkoutSession.id and clientReferenceId. Fulfill only after verifying and deduplicating purchase_succeeded.

Create a session on your backend

const pharosApi = 'https://api.example.com/api/v1'

type Cart = {
  id: string
  items: Array<{ priceId: string; quantity: number }>
}

export async function createCheckout(cart: Cart) {
  const response = await fetch(`${pharosApi}/checkout-sessions`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-api-key': process.env.PHAROS_API_KEY!,
      'idempotency-key': `cart:${cart.id}:checkout-session`,
    },
    body: JSON.stringify({
      mode: 'PAYMENT',
      theme: 'light',
      clientReferenceId: cart.id,
      lineItems: cart.items.map((item) => ({
        ...item,
        adjustableQuantity: {
          enabled: true,
          minimum: 1,
          maximum: 10,
        },
      })),
      successUrl: 'https://shop.example/success?session={CHECKOUT_SESSION_ID}',
      cancelUrl: 'https://shop.example/cart',
    }),
  })

  if (!response.ok) {
    throw new Error(await response.text())
  }

  return {
    session: await response.json(),
    etag: response.headers.get('etag'),
  }
}

Use a stable create key derived from your cart or checkout attempt. Retrying the same request with the same key returns the same logical session; reusing that key with a different body causes an idempotency conflict.

Choose catalog or inline pricing

Use priceId when the offer already exists in the Pharos catalog. Use inline priceData when your backend needs to snapshot a contextual price without creating catalog records.

const customLineItem = {
  priceData: {
    currency: 'USD',
    unitAmount: '25.00',
    productData: {
      name: 'Custom support package',
      description: 'Configured for this order',
    },
    recurring: {
      interval: 'MONTH',
      intervalCount: 1,
      trialDays: 7,
    },
  },
  quantity: 1,
}

Inline data becomes a durable session snapshot. The hosted browser must never submit or override unit amounts or recurrence.

Mutate an open cart safely

export async function addItem(sessionId: string, etag: string, priceId: string) {
  const response = await fetch(`${pharosApi}/checkout-sessions/${sessionId}/line-items`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-api-key': process.env.PHAROS_API_KEY!,
      'idempotency-key': crypto.randomUUID(),
      'if-match': etag,
    },
    body: JSON.stringify({
      lineItems: [{ priceId, quantity: 1 }],
    }),
  })

  if (response.status === 409) {
    throw new Error('Reload and reconcile the session before retrying')
  }
  if (!response.ok) {
    throw new Error(await response.text())
  }

  return {
    session: await response.json(),
    etag: response.headers.get('etag'),
  }
}

After every successful mutation, replace your stored session, version, and ETag with the response. A 409 SESSION_VERSION_CONFLICT means another writer changed the session. Retrieve it, reconcile it with your cart, and retry as a new mutation. Do not blindly replay a stale body.

You may also:

  • PATCH /checkout-sessions/{id} to update non-monetary settings such as theme, redirects, email, reference, or metadata.
  • PATCH /checkout-sessions/{id}/line-items/{itemId} to change quantity or adjustable bounds.
  • DELETE /checkout-sessions/{id}/line-items/{itemId} to remove an allowed item.
  • POST /checkout-sessions/{id}/expire when the cart should no longer be usable.

API-owned sessions can be mutated only while they are open and payment preparation has not locked the snapshot.

Redirect from the browser

const response = await fetch('/api/cart/checkout', { method: 'POST' })
if (!response.ok) throw new Error('Checkout could not be created')

const { url } = await response.json()
window.location.assign(url)

Your /api/cart/checkout handler performs the authenticated Pharos request and returns the hosted URL. Do not expose the Pharos API key in this response or in client-side code.

Lifecycle and fulfillment

Session status describes the buyer journey:

  • open: the session may be used; API mutations also require it to be unprepared.
  • complete: the buyer flow reached a completed state.
  • expired: the session is no longer usable.

paymentStatus independently describes money movement:

  • unpaid: no successful payment result exists yet.
  • processing: an asynchronous or provider-side result is pending.
  • paid: payment succeeded.
  • failed: the current attempt failed and may allow a retry.
  • no_payment_required: a free or trial flow completed without a charge.

The success redirect and checkout_session_completed are not fulfillment signals. Retrieve the session to reconcile clientReferenceId, customer, invoice, payment, subscription, and original cart, but fulfill only on the signed purchase_succeeded webhook.

Failure handling

  • Retry network failures with the same idempotency key only when retrying the exact same operation.
  • On 400, fix the request or cart composition instead of retrying unchanged.
  • On 401 or 403, check the API key, environment, and required permission.
  • On 404, confirm that the API key and session belong to the same business.
  • On 409, distinguish an idempotency conflict from a version conflict and reconcile before retrying.
  • If the session is locked, create a new cart attempt rather than mutating a prepared or completed snapshot.
  • If payment is processing, wait for the signed webhook instead of polling the success page for fulfillment.

Next steps

On this page