Create Checkout Session
curl --request POST \
--url https://api.nozle.app/engine/api/v1/checkout \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"customer_id": "<string>",
"plan_code": "<string>",
"invoice_id": "<string>",
"subscription_id": "<string>",
"quote_id": "<string>",
"return_url": "<string>",
"success_url": "<string>"
}
'import requests
url = "https://api.nozle.app/engine/api/v1/checkout"
payload = {
"customer_id": "<string>",
"plan_code": "<string>",
"invoice_id": "<string>",
"subscription_id": "<string>",
"quote_id": "<string>",
"return_url": "<string>",
"success_url": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
customer_id: '<string>',
plan_code: '<string>',
invoice_id: '<string>',
subscription_id: '<string>',
quote_id: '<string>',
return_url: '<string>',
success_url: '<string>'
})
};
fetch('https://api.nozle.app/engine/api/v1/checkout', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.nozle.app/engine/api/v1/checkout"
payload := strings.NewReader("{\n \"customer_id\": \"<string>\",\n \"plan_code\": \"<string>\",\n \"invoice_id\": \"<string>\",\n \"subscription_id\": \"<string>\",\n \"quote_id\": \"<string>\",\n \"return_url\": \"<string>\",\n \"success_url\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}require 'uri'
require 'net/http'
url = URI("https://api.nozle.app/engine/api/v1/checkout")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"customer_id\": \"<string>\",\n \"plan_code\": \"<string>\",\n \"invoice_id\": \"<string>\",\n \"subscription_id\": \"<string>\",\n \"quote_id\": \"<string>\",\n \"return_url\": \"<string>\",\n \"success_url\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.nozle.app/engine/api/v1/checkout",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'customer_id' => '<string>',
'plan_code' => '<string>',
'invoice_id' => '<string>',
'subscription_id' => '<string>',
'quote_id' => '<string>',
'return_url' => '<string>',
'success_url' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}HttpResponse<String> response = Unirest.post("https://api.nozle.app/engine/api/v1/checkout")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"customer_id\": \"<string>\",\n \"plan_code\": \"<string>\",\n \"invoice_id\": \"<string>\",\n \"subscription_id\": \"<string>\",\n \"quote_id\": \"<string>\",\n \"return_url\": \"<string>\",\n \"success_url\": \"<string>\"\n}")
.asString();{
"type": "<string>",
"client_secret": "<string>",
"clientSecret": "<string>",
"invoice_id": "<string>",
"plan_upgrade_intent_id": "<string>",
"amount_cents": 123,
"currency": "<string>"
}Checkout & Subscriptions
Create Checkout Session
POST
/
engine
/
api
/
v1
/
checkout
Create Checkout Session
curl --request POST \
--url https://api.nozle.app/engine/api/v1/checkout \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"customer_id": "<string>",
"plan_code": "<string>",
"invoice_id": "<string>",
"subscription_id": "<string>",
"quote_id": "<string>",
"return_url": "<string>",
"success_url": "<string>"
}
'import requests
url = "https://api.nozle.app/engine/api/v1/checkout"
payload = {
"customer_id": "<string>",
"plan_code": "<string>",
"invoice_id": "<string>",
"subscription_id": "<string>",
"quote_id": "<string>",
"return_url": "<string>",
"success_url": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
customer_id: '<string>',
plan_code: '<string>',
invoice_id: '<string>',
subscription_id: '<string>',
quote_id: '<string>',
return_url: '<string>',
success_url: '<string>'
})
};
fetch('https://api.nozle.app/engine/api/v1/checkout', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.nozle.app/engine/api/v1/checkout"
payload := strings.NewReader("{\n \"customer_id\": \"<string>\",\n \"plan_code\": \"<string>\",\n \"invoice_id\": \"<string>\",\n \"subscription_id\": \"<string>\",\n \"quote_id\": \"<string>\",\n \"return_url\": \"<string>\",\n \"success_url\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}require 'uri'
require 'net/http'
url = URI("https://api.nozle.app/engine/api/v1/checkout")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"customer_id\": \"<string>\",\n \"plan_code\": \"<string>\",\n \"invoice_id\": \"<string>\",\n \"subscription_id\": \"<string>\",\n \"quote_id\": \"<string>\",\n \"return_url\": \"<string>\",\n \"success_url\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.nozle.app/engine/api/v1/checkout",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'customer_id' => '<string>',
'plan_code' => '<string>',
'invoice_id' => '<string>',
'subscription_id' => '<string>',
'quote_id' => '<string>',
'return_url' => '<string>',
'success_url' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}HttpResponse<String> response = Unirest.post("https://api.nozle.app/engine/api/v1/checkout")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"customer_id\": \"<string>\",\n \"plan_code\": \"<string>\",\n \"invoice_id\": \"<string>\",\n \"subscription_id\": \"<string>\",\n \"quote_id\": \"<string>\",\n \"return_url\": \"<string>\",\n \"success_url\": \"<string>\"\n}")
.asString();{
"type": "<string>",
"client_secret": "<string>",
"clientSecret": "<string>",
"invoice_id": "<string>",
"plan_upgrade_intent_id": "<string>",
"amount_cents": 123,
"currency": "<string>"
}Base URL:
Plan checkout is payment-gated. Free-to-paid checkout uses an incomplete subscription. Paid-to-paid upgrades keep the old subscription active in a durable pending intent and apply the billing change only after the payment provider confirms the required payment and Nozle fulfills the change.
If prepaid credits fully cover a paid plan change, Nozle applies it without an external payment and returns
Use an
A
Checkout relies on these Stripe events:
https://api.nozle.app/engine/api/v1.
Create a payment-gated plan checkout from a trusted merchant backend.
Auth: secret key only. Publishable keys are always rejected.
The merchant backend derives
customer_id from its authenticated user or team. Do not trust a browser-provided customer ID. The response can contain a Stripe or Razorpay checkout, a processing state, or a completed or scheduled transition.Modes
Plan checkout
Use this when a customer is selecting a plan.string
required
External customer ID. The customer must already exist.
string
required
The plan code to subscribe the customer to (e.g.
pro).type: "completed". A downgrade or other cycle-end transition may return type: "scheduled".
Checkout is only for plans that need an up-front payment. For a customer with no subscription, a free plan, a plan with a trial, or a plan billed in arrears returns 422 checkout_not_required: start it with subscribe instead. A plan with a pay-in-advance fixed charge is the exception, even with a trial or billed in arrears: checkout invoices the fixed charge in the request and returns a payable session for it, like any paid plan. The customer’s saved card is never charged off-session for that invoice. Entity checkout is the exception and completes these plans with payment_source: "none".
Changing the plan of an existing subscription needs a plan transition policy for that pair of plans, except from a free plan to a paid plan. The selected self-service downgrade flow uses a signed quote with the end-of-period transition endpoint described below.
Invoice checkout
string
Nozle invoice to collect payment for. With
invoice_id, plan_code is not required. It cannot be combined with subscription_id.Selected subscription and signed quote
string
External subscription ID. Select it explicitly for self-service. An unselected checkout for a customer with multiple active subscriptions is rejected as ambiguous.
string
Opaque signed quote from
POST /subscriptions/preview. Requires subscription_id; forward it unchanged after the customer confirms the amounts and dates.Idempotency-Key header for the confirmed action and retain it across uncertain retries. A new-subscription checkout retried while its first attempt is unpaid reopens that attempt; see Retry a new-subscription checkout. Invalidated or expired new quotes require a fresh preview/confirmation; replay of an already accepted action reuses its recorded checkout. See Subscription self-service for quote, downgrade and recovery contracts.
Return URL
string
URL Stripe should return to after embedded checkout completes. It must use an exactly allowlisted HTTPS origin. The Engine checks it against the origins configured for the Nozle deployment; the origins you register for your organization (
checkout_return_origins) are accepted by Core’s checkout routes, but this Engine check does not read them yet. If omitted, Nozle uses the configured dashboard return URL. See return URLs and your organization’s origins.string
Backward-compatible alias for
return_url.Request examples
Plan checkout:{
"customer_id": "customer_123",
"plan_code": "growth",
"return_url": "https://app.example.com/settings/billing"
}
Response
string
required
stripe, razorpay, processing, completed, or scheduled. This endpoint never returns hosted. The SDK types still declare it, and only the deprecated POST /credits/purchase of the Engine API returns it. That endpoint requires an Idempotency-Key header and a currency field (the ISO 4217 code of the top-up package, for example USD), and answers 400 with the code idempotency_key_required or currency_required without them; a retry with the same key returns the first purchase. Use Create Credit Top-up Purchase (POST /credit-top-up-purchases) instead.string
Stripe results only: the
cs_ secret of an embedded Stripe Checkout Session. Mount it with Stripe Embedded Checkout (the React Checkout component does this). Plan and invoice checkout never return a Stripe-hosted URL. Credit top-ups do: see Create Credit Top-up Purchase.string
Camel-case alias for
client_secret.string
Nozle invoice ID being paid. A paid upgrade returns
plan_upgrade_intent_id instead.string
Paid upgrades only: the pending upgrade. Use it with the subscription scope to poll status; the old plan stays active until payment succeeds.
integer
Amount due for the checkout session, in the invoice currency’s smallest unit.
string
Invoice currency.
Razorpay and recovery fields
Arazorpay result supplies checkout_id, order_id, public key_id, amount_cents, currency, expires_at, and invoice_id or plan_upgrade_intent_id. A mandate checkout (register_mandate: true) adds customer_id, recurring: true, and mandate_max_amount_cents. Open the Razorpay modal, then verify the handler’s signature on your backend. Activation requires captured payment and completed fulfillment. See the Razorpay flow.
A processing result supplies checkout_id and status. Razorpay returns it while the order is not ready yet or after it expired. Stripe returns it for a replayed quoted upgrade while the payment is complete and its webhook is pending. Recover it rather than starting another checkout. A bulk Entity checkout first answers {"entity_subscription_checkout": {"type": "processing", "status": "open", "checkout_id": "...", ...}}. The status of a processing result is an internal operation status. The nested checkout of an expired unpaid Razorpay order in checkout status is {"type": "processing", "status": "ready"} while the top-level status is expired. Both scoped verification and status recovery are described in Subscription self-service.
A Razorpay payment link is never a result of this endpoint. It comes from an invoice payment URL (POST /invoices/{id}/payment_url, which returns {"invoice_payment_details": {"customer_id", "external_customer_id", "payment_provider", "invoice_id", "payment_url"}}) or from a credit top-up purchase, and has no checkout ID to poll.
Retry a new-subscription checkout
A new-subscription checkout leaves an incomplete subscription until its first payment succeeds. Calling checkout again for the same customer and plan while that subscription is incomplete reopens the first checkout for the same invoice instead of failing, with or without the sameIdempotency-Key. For Razorpay the reopened checkout is the same order. An invoice checkout (invoice_id in the request) for the invoice_id of the first response works too.
Asking for a different plan replaces the abandoned checkout: the old checkout expires as described below and the new checkout is returned. 409 subscription_checkout_in_progress (entity_subscription_checkout_in_progress for a single Entity checkout) is returned only while the provider is still processing a payment for the old checkout, or when that checkout turns out to be paid. The same applies to single Entity checkouts.
Unpaid checkout expiry
An unpaid plan checkout or single Entity checkout expires after 23 hours, the same lifetime as an Entity checkout batch. The Stripe Checkout Session and the Razorpay order (expires_at) expire at the same time. When it expires, Nozle:
- cancels the incomplete subscription with
cancelation_reason: timeoutand sendssubscription.canceled; - closes the payment-gated invoice;
- returns the coupons, credit-note credits and wallet credits that invoice used;
- expires the Stripe Checkout Session or Razorpay order that is still open.
Upgrade checkout that ran out of time
Before a paid upgrade checkout whose time ran out is reportedexpired or replaced, Nozle asks the payment provider what became of it:
- Closed without a payment (a Stripe Checkout Session that is still open is expired at Stripe first, or a Razorpay order without a payment): the upgrade expires, and a new checkout opens a new session or order.
- Paid in time, webhook late: Nozle settles the payment as the webhook would and applies the upgrade. No second checkout is offered, so the customer is not charged twice.
- Payment still processing, or the provider cannot be reached: nothing changes and a new checkout answers
409 plan_upgrade_checkout_in_progress. Poll the checkout status and try again later.
422 plan_already_active, with Stripe and Razorpay alike. If the checkout request that found the late payment asked for another plan, it answers 409 plan_upgrade_checkout_in_progress with the checkout_id of the paid checkout; reload the subscription before you start the next change.
Credit-funded response
When no external payment remains due:{
"type": "completed",
"status": "succeeded",
"payment_source": "credits",
"subscription_id": "subscription_uuid",
"plan_code": "pro",
"invoice_id": "invoice_uuid",
"amount_cents": 0,
"currency": "USD"
}
completed result has payment_source credits when the change was funded without an external payment (including a free-to-free change, which still creates a zero-amount invoice) and none when nothing was invoiced. Entity checkouts add external_entity_id and external_subscription_id to every result.
Scheduled response
{
"type": "scheduled",
"status": "pending",
"subscription_id": "pending_subscription_uuid",
"plan_code": "starter",
"transition_direction": "downgrade",
"transition_policy": "next_period",
"effective_at": "2026-11-01T00:00:00.000000Z"
}
subscription_id is the pending subscription; there is no separate pending_subscription_id field. A new scheduled change while a change is pending replaces that change and returns a new pending ID. A paid upgrade checkout is refused with 409 subscription_change_pending while a change is pending: withdraw it first. See Paid upgrades: current limits.
effective_at is an RFC 3339 timestamp rendered in the customer’s timezone. For a customer in Asia/Kolkata it reads 2026-11-01T00:00:00.000000+05:30, which is the same instant as 2026-10-31T18:30:00Z. Parse it as a timestamp and do not assume a trailing Z. A scheduled change takes effect at that instant: the active subscription is terminated and the pending subscription becomes active, keeping its billing_time.
Paid upgrades: current limits
These limits were observed on a paid upgrade checkout that returns astripe result with plan_upgrade_intent_id. Configure around them until Core is fixed.
-
Invoice grace period. If the billing entity has an invoice grace period above 0 days, the upgrade invoice stays a draft. The customer’s payment is still collected, but Nozle cannot apply the upgrade. With
credit_action: "credit"the upgrade ends inneeds_reviewwithplan_upgrade_amount_changed, because the quoted amount credited the unused time and the draft invoice did not. Without a credit, the payment handler fails withpayment_status_update_on_draft_invoiceand the upgrade stays pending. Set the grace period to 0 for billing entities that sell paid upgrades, or refund and reconcile an affected payment by hand. -
Pending change. A paid upgrade checkout is refused with
409 subscription_change_pendingwhile the subscription has a scheduled cancellation or a scheduled plan change. Withdraw the pending downgrade withPOST /subscriptions/transitions/withdraw, or keep the subscription, then start the upgrade. Cancelling at period end, scheduling a plan change or applying an immediate downgrade cancels an open upgrade checkout: its Stripe Checkout Session or Razorpay order is expired. A payment the provider is already processing is left to settle. -
Status. Poll the upgrade with
GET /checkout/{plan_upgrade_intent_id}?customer_id=…&subscription_id=…(Engine names).statusisawaiting_paymentuntil payment, thensucceededwithfulfillment_statussucceededand aninvoice_id.needs_reviewmeans the payment was taken but the upgrade was not applied. See Subscription self-service. -
Failed upgrades leave nothing behind. A quoted upgrade with nothing to pay that fails after the change was prepared (for example because the grace period keeps its invoice a draft) is rolled back: the subscription stays on its plan, no credit note is created, and the same request can be retried once the cause is fixed. An upgrade whose credit for the unused time of the old plan cannot be computed (the credit usage of a plan family interval switch is unavailable) fails with
503 credit_usage_unavailableand applies nothing, instead of applying without the credit. Retry later.
billing_time is unchanged.
Errors
The Engine checks the request in this order: valid JSON body, customer scope, thesubscription_id combinations, the required fields, then return_url, then the Idempotency-Key length. Only then is the request sent to Core, which returns the other errors below.
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | subscription_id combined with invoice_id or external_entity_id, or neither plan_code with customer_id nor invoice_id given. |
400 | invalid_return_url | return_url is not an absolute HTTPS URL, or its origin is not on the Engine’s allowlist. |
400 | — | The body is not valid JSON, or the Idempotency-Key is longer than 255 bytes. |
404 | plan_not_found, customer_not_found | Unknown plan or customer. |
422 | plan_transition_policy_required | The customer has an active subscription and the pair of plans has no transition policy. |
422 | plan_upgrade_invoice_missing | An upgrade to a plan billed in arrears: nothing is due up front. |
422 | plan_already_active, target_plan_is_not_a_downgrade | See transition policies. |
422 | subscription_not_active | Raised by plan-transition calls on a subscription that is not active. A checkout that names a terminated subscription ID was treated as a new checkout in testing, so do not rely on this code from checkout. |
409 | subscription_selection_required | The customer has several active subscriptions and no subscription_id was given. |
409 | plan_upgrade_checkout_in_progress | Another upgrade checkout for the same subscription is open, or its time ran out while the provider reports a payment in progress or cannot be reached. Recover it with checkout status. See Upgrade checkout that ran out of time. |
409 | entity_subscription_checkout_in_progress | A single Entity checkout on a different plan is still being paid: the provider is processing its payment, or it was paid. An abandoned one is replaced instead. |
409 | billing_anchor_checkout_in_progress | A single Entity checkout found that the customer’s first paid Entity checkout is still open and no billing anchor is set yet. |
422 | validation_errors | error_details.checkout is billing_anchor_checkout_in_progress: a bulk Entity checkout found an Entity with an incomplete subscription, or a first paid checkout for the customer still open. It is a 422, not a 409. |
409 | idempotency_key_reused | The key was used with a different request. An invoice checkout on Stripe returns it when Stripe refuses the reused key. |
409 | plan_change_quote_changed | The quote no longer matches the subscription. Preview again. |
409 | subscription_change_pending | The subscription has a scheduled cancellation or plan change, or an upgrade payment in progress. Withdraw the change, or wait for the payment, then preview again. |
422 | checkout_not_required | The plan needs no up-front payment (free, trial, or billed in arrears) and the customer has no subscription. Use subscribe. |
422 | payment_provider_required | The customer is not assigned to Razorpay and the organization has no Stripe connection. |
422 | stripe_customer_required | Nozle could not create the customer in Stripe. A customer that is not in Stripe yet normally gets one at checkout, so this code appears only when that creation fails. See the Stripe integration. |
422 | razorpay_requires_inr | A Razorpay checkout for an invoice that is not in INR. See the Razorpay integration. |
422 | plan_already_active | The customer, or Entity, is already on this plan, including right after a late upgrade payment for it was applied. |
409 | subscription_checkout_in_progress | A new-subscription checkout for this customer on a different plan is still being paid: the provider is processing its payment, or it was paid. An abandoned one is replaced instead. |
422 | validation_errors | For an invoice checkout, error_details.base is invalid_invoice_status_or_payment_status when the invoice is paid, voided, a draft, closed, failed or has nothing due. |
422 | razorpay_customer_connection_cannot_change | The customer already started a Razorpay checkout and was moved to another Razorpay connection. Create a new customer instead. See the Razorpay integration. |
404 | payment_provider_not_found | The customer’s connection code matches no Razorpay connection. |
422 | third_party_error | The provider rejected the request, for example Razorpay with a wrong key secret. |
422 | subscription_and_idempotency_key_required | quote_id sent without subscription_id or an Idempotency-Key. |
503 | credit_usage_unavailable | The credit for the unused time of the old plan could not be computed. Nothing was applied. Retry later. |
Webhook requirement
Stripe must be able to reach your Nozle public API URL. When a Stripe provider is connected, Nozle registers the Stripe webhook endpoint for that tenant’s Stripe account:https://api.nozle.app/core/webhooks/stripe/{organization_id}?code={stripe_provider_code}
payment_intent.succeededpayment_intent.payment_failedpayment_intent.canceled
setup_intent.succeeded, payment_method.detached, customer.updated, charge.refund.updated and charge.dispute.closed. See the Stripe integration.
Razorpay webhooks are configured by you in the Razorpay dashboard. See the Razorpay integration for the URL, headers, and events.