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:
- 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. - 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.purchased — data 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/_fbccookies at create time so the post-payPurchaseevent has full attribution). - Wait for
product.purchasedto do the actual fulfilment side (delivery URL, licence key, shipment).checkout.completedis "money in";product.purchasedis "we delivered".
Common pitfalls
- Doing fulfilment here. Use
product.purchasedfor delivery actions.checkout.completedis 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.idas your idempotency key. - Reading
paymentMethodand assuming a settled rail.qrisandvaare paid by the time you see this, but funds settle to the merchant T+1 to T+3. TreatcompletedAtas "session-state-flip time", not "money-in-bank time". - Cart checkouts. A multi-product cart fires one
checkout.completed(the session is one) but multipleproduct.purchasedevents (one per line item).
Related events
product.purchased— per-delivery fulfilment side.