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_urlimmediately (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_urlpointing 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, atest_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.