checkout.completed

Fires when a checkout session transitions to completed. This is the money's in event: the buyer paid (or the merchant manually confirmed), the session is terminal, and downstream fulfilment is about to fire.

If you only subscribe to one event, this is usually the one. It precedes product.purchased (the per-line-item fulfilment event); together they give you full coverage of "buyer paid → we delivered".

When it fires

Two code paths emit it:

  1. Provider-confirmed payment. Xendit's webhook (legacy mode) or Plugipay's checkout_session.completed (Plugipay mode) confirms the underlying payment, and Storlaunch flips the session.
  2. PayPal capture path. The PayPal order is captured server-side and the session is flipped.

In Plugipay mode the manual-confirm path doesn't fire this event from Storlaunch's outbox; Plugipay itself emits its own checkout_session.completed with metadata.source = "manual_confirm". Subscribe to one or the other depending on your wiring.

The session transitions to completed only once — the event is single-shot per session. Replays via the outbox retry mechanism share event.id (use it for dedup).

Payload

{
  "id": "evt_01HX...",
  "type": "checkout.completed",
  "createdAt": "2026-05-13T10:43:22Z",
  "accountId": "acc_01HX...",
  "data": {
    "sessionId": "cs_01HX...",
    "deliveryId": "del_01HX...",
    "productId": "prod_01HX...",
    "amount": 75000,
    "currency": "IDR",
    "paymentMethod": "qris",
    "customerEmail": "alice@example.com"
  }
}

The payload mirrors the slim notification style of product.purchaseddata carries the IDs and the headline economics; fetch the full session or delivery for the rest.

For PayPal-paid sessions, paymentMethod is paypal. For Xendit-paid: qris, va, ewallet, or card. For manually-confirmed: manual (in legacy mode).

Handler examples

// Node
if (event.type === 'checkout.completed') {
  const { sessionId, amount, currency, customerEmail } = event.data;
  await orders.markPaid(sessionId, { amount, currency, paidAt: event.createdAt });
  await emails.sendOrderConfirmation(customerEmail, sessionId);
}
# Python
if event["type"] == "checkout.completed":
    d = event["data"]
    orders.mark_paid(d["sessionId"], amount=d["amount"], currency=d["currency"], paid_at=event["createdAt"])
    emails.send_order_confirmation(d["customerEmail"], d["sessionId"])
// Go
if event.Type == "checkout.completed" {
    var d struct {
        SessionID     string `json:"sessionId"`
        Amount        int64  `json:"amount"`
        Currency      string `json:"currency"`
        CustomerEmail string `json:"customerEmail"`
    }
    _ = json.Unmarshal(event.Data, &d)
    orders.MarkPaid(ctx, d.SessionID, d.Amount, d.Currency, event.CreatedAt)
}

What to do

  • Mark the order paid in your own DB. Reconcile via sessionId.
  • Trigger your post-pay flow. Send your branded order-confirmation email. Storlaunch sends a receipt; your app's confirmation is separate.
  • Update analytics (revenue dashboards, conversion funnels). For pixel events, prefer Storlaunch's server-side CAPI integration (the session stashes _fbp / _fbc cookies at create time so the post-pay Purchase event has full attribution).
  • Wait for product.purchased to do the actual fulfilment side (delivery URL, licence key, shipment). checkout.completed is "money in"; product.purchased is "we delivered".

Common pitfalls

  • Doing fulfilment here. Use product.purchased for delivery actions. checkout.completed is the financial signal; the fulfilment signal is the next event.
  • Not deduping. At-least-once. The outbox can replay this event under network blips. Use event.id as your idempotency key.
  • Reading paymentMethod and assuming a settled rail. qris and va are paid by the time you see this, but funds settle to the merchant T+1 to T+3. Treat completedAt as "session-state-flip time", not "money-in-bank time".
  • Cart checkouts. A multi-product cart fires one checkout.completed (the session is one) but multiple product.purchased events (one per line item).

Related events

Next