POST /api/v1/checkout-sessions

Creates a one-time payment or a subscription checkout session. Requires auth (HMAC or Firebase ID Token).

Request body

Field Type Required Notes
external_ref string Only for HMAC — ignored (and not required) for Firebase ID Token, where it’s always derived from the token instead Your identifier for the end user — see 07 — Understanding external_ref
mode string Yes "payment" (one-time) or "subscription"
success_url string Yes Where the end user lands after a successful payment
cancel_url string Yes Where the end user lands after cancelling
payment_method string No "stripe", "payos", "bank_transfer", or "test" — see below

The remaining fields depend on mode. The request has two shapes.

mode: "payment" — one-time payment

Field Type Required Notes
plan_ref string Yes Free-form label you assign, e.g. lifetime_pass
amount integer Yes Smallest currency unit (e.g. cents for USD, whole units for currencies like VND — check with your gateway operator which convention their Stripe account uses), must be > 0
currency string Yes Exactly 3 characters (e.g. USD, VND)

mode: "subscription" — recurring subscription

PayGate owns the subscription: amount, currency and billing interval all come from a price you created beforehand (03.07 — Plans and prices), and the person billed is a customer you created beforehand (03.06 — Customers).

Field Type Required Notes
price_id string Yes A price_… id. Both the price and its plan must be active
customer_id string Yes A cus_… id belonging to your app (with Firebase ID Token: one created by the same end user)
skip_trial boolean No true to charge the regular price right away even if the price has a trial

Do not send plan_id, amount, currency, interval, interval_count or plan_ref with mode: "subscription" — they are derived from the price, and sending any of them returns 400 invalid_request. The subscription’s plan_ref is the plan’s code.

If the end user already owns a lifetime subscription the call fails with 409 already_lifetime. Paying for a lifetime price activates a subscription with current_period_end: null (no expiry).

A trial is applied automatically when the price has one, unless you send skip_trial: true or the end user already had a trial on any price of the same plan (one trial per end user and plan). See 03.08 — Subscription lifecycle.

Errors specific to this mode: 400 invalid_request (missing price_id/customer_id, or a derived field was sent), 400 price_not_found (unknown, archived, or its plan is archived), 400 customer_not_found (unknown, or belongs to another app/user). See Errors.

The payment_method field — you usually don’t need it

Omit payment_method entirely in production integrations. What happens next depends on your app’s configured environment and how many payment providers it has active:

  • Exactly one provider available → PayGate returns that provider’s real checkout_url immediately (e.g. Stripe or PayOS). Bank transfer is the exception: it always needs PayGate’s hosted QR page, so it is never returned as a bare provider URL (see below).
  • More than one provider available (or the app’s environment also allows the Test Payment Method) → PayGate returns a checkout_url pointing to its own hosted picker page instead (GET /checkout/{public_token}) — the end user chooses a payment method (and, if Test Payment Method is offered, a test_card_code) there, on a separate page/request, never in this call.

Only send payment_method explicitly (stripe, payos, bank_transfer, or test) if you’re calling this endpoint from automated tests/QA scripts and already know which method to use, and want to skip the picker page.

This endpoint never resolves a payment result and never accepts a test_card_code — it only ever returns a checkout_url. Sending payment_method="test" does not skip straight to a pass/fail result: if your app’s environment allows the Test Payment Method, you get back a checkout_url pointing at the same hosted picker page (GET /checkout/{public_token}) — the test_card_code is only ever submitted there, on that page’s own resolve step (see 03.04 — Testing).

payment_method="bank_transfer" (VND only, for staging/production apps with an active bank-transfer provider) returns a checkout_url of the form https://<gateway>/checkout/{public_token}/bank — a hosted page showing a VietQR code and the transfer details. The end user is redirected to your success_url once the transfer is confirmed from the bank’s balance-change email (usually within seconds to a few minutes). The transaction.completed webhook and success_url behave exactly as for PayOS.

If you send a payment_method your app’s environment/configuration doesn’t allow (including "test" against an app that doesn’t permit it), you get a clear 403 payment_method_not_allowed.

Response — success (a real provider or the picker page)

{ "success": true, "data": { "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..." } }

or, when the picker page applies:

{ "success": true, "data": { "checkout_url": "https://payments.example.com/checkout/9f3a...64hexchars" } }

A mode: "payment" response has exactly this shape — { checkout_url }, nothing else. Redirect the end user to it. Don’t try to distinguish the cases — all of them eventually redirect back to your success_url/cancel_url.

Response — mode: "subscription"

A subscription response carries two extra fields, activated and trial:

{
  "success": true,
  "data": {
    "checkout_url": "https://payments.example.com/checkout/9f3a...64hexchars",
    "activated": false,
    "trial": null
  }
}
Field Meaning
activated true only when this very call activated the subscription (free trial, below); otherwise false — the end user still has to pay on checkout_url
trial null when no trial applies. Otherwise { "applied": true, "amount", "interval_unit", "interval_count", "ends_at" }: amount is what the trial costs (0 = free) and ends_at is null until the subscription is active

When a paid trial applies (trial.amount > 0), the end user pays the trial amount on checkout_url and the first period lasts for the trial’s interval.

Free trial — activated immediately

When the trial amount is 0, no payment is needed. PayGate activates the subscription during the call and answers 201 Created:

{
  "success": true,
  "data": {
    "checkout_url": "https://yourapp.com/success?paygate_transaction_id=42&paygate_external_ref=user-42&paygate_status=completed",
    "activated": true,
    "trial": {
      "applied": true,
      "amount": 0,
      "interval_unit": "day",
      "interval_count": 14,
      "ends_at": "2026-10-17 08:00:00"
    }
  }
}

checkout_url is then simply your own success_url with the paygate_* params appended (see below), so redirecting to it works the same as in every other case. No payment method is involved.

What comes back on success_url/cancel_url

PayGate appends its own query params to whichever URL it redirects to (in addition to anything already in your URL, and to Stripe’s own {CHECKOUT_SESSION_ID} placeholder if you used one in success_url):

Param Meaning
paygate_transaction_id PayGate’s internal transaction id — pass this to GET /api/v1/transactions/{transaction_id} to verify this specific attempt
paygate_external_ref Echoes the external_ref this checkout session was created for
paygate_status completed on success_url, canceled on cancel_url — reflects which URL you landed on, not a verified payment state

Example: https://yourapp.com/success?paygate_transaction_id=42&paygate_external_ref=user-123&paygate_status=completed.

paygate_status=completed is not proof of payment — it only means the redirect reached your success URL, which for Stripe happens client-side after Stripe’s own confirmation but without a signature you can verify. Use paygate_external_ref/paygate_transaction_id to know which user and attempt to check, then confirm via GET /api/v1/transactions/{transaction_id}, GET /api/v1/subscriptions/{external_ref}, or a registered webhook (04.01) before unlocking anything — see 04 — Webhooks.


This site uses Just the Docs, a documentation theme for Jekyll.