- Customer selects a plan in your application
- The React SDK calls your authenticated merchant callback with
{ planCode, returnUrl } - Your backend derives the customer, validates the plan and return origin, and calls Nozle with
sk_ - Nozle keeps the current plan active and computes the exact invoice or proration
- If money remains due, Nozle returns a provider result: an embedded Stripe Checkout Session secret, or a Razorpay order
- Customer completes payment in the provider’s UI
- The provider confirms the payment to Nozle (a signed webhook, and for Razorpay also your verify call)
- Nozle applies the paid plan change only after verified success
Plan checkout is payment-gated: a paid plan change is not applied until the payment provider confirms payment. If prepaid credits reduce the exact amount due to zero, Nozle applies the change transactionally and returns
type: "completed" without creating a provider session.payment_provider_required. Checkout charges only through the connection assigned to the customer and never picks another one; a customer with no assigned connection also gets payment_provider_required. Assign the connection in the dashboard, or send billing_configuration.payment_provider and payment_provider_code when you create the customer through the API; see the Razorpay and Stripe guides. At checkout Nozle creates the customer in Stripe when it is missing, and returns 422 stripe_customer_required if it cannot.
Stripe
Plan and invoice checkout always create an embedded Stripe Checkout Session and return itscs_ client secret. They never return a Stripe-hosted URL. Credit top-ups are the exception: they return a Stripe-hosted checkout.stripe.com payment_url that is valid for 24 hours. Mount the secret with Stripe Embedded Checkout (the React Checkout component does this for a cs_ secret).
Required Stripe webhook setup
When you connect a Stripe account, Nozle registers a Stripe webhook endpoint for that account and stores Stripe’s webhook signing secret. Your Stripe account must allow webhook endpoint creation. The webhook target is derived from your public Nozle API URL:payment_intent.succeededpayment_intent.payment_failedpayment_intent.canceledsetup_intent.succeededpayment_method.detached(received, but it does not clear the stored payment method today)customer.updatedcharge.refund.updatedcharge.dispute.closed
processing once it is complete, but it never settles a payment, so only the webhook does. See the Stripe integration for setup, flows and limits.
A new-subscription Stripe checkout returns no checkout ID to poll. After Embedded Checkout completes, reload the subscription or wait for your subscription.started webhook. A paid upgrade returns plan_upgrade_intent_id; poll its status with GET /checkout/{id} and the subscription scope.
Retrying a new-subscription checkout for the same plan while its subscription is still incomplete reopens the first checkout. A different plan replaces the abandoned checkout and returns the new one; 409 subscription_checkout_in_progress remains only while the provider is still processing a payment for the old checkout. An unpaid plan or single Entity checkout expires after 23 hours: the subscription is canceled with cancelation_reason: timeout, the invoice is closed and the credits it used are returned. See Unpaid checkout expiry.
Razorpay
Connect Razorpay in the dashboard with its key ID, key secret and webhook secret. Optional settings enable recurring mandates (with a maximum mandate amount). Razorpay supports only INR. Nozle does not register the Razorpay webhook for you: copy the webhook URL Nozle shows for the connection into your Razorpay dashboard, with the same webhook secret. See the Razorpay integration for setup, events and limits.X-Razorpay-Signature header against the webhook secret and de-duplicates on X-Razorpay-Event-Id. On payment.authorized, payment.captured, payment.failed, order.paid and payment_link.paid, Nozle fetches the payment from Razorpay and settles the checkout once the payment is captured. Refund, mandate and dispute events are also handled.
A Razorpay checkout runs in four steps:
- Create. Checkout returns
{"type": "razorpay", "checkout_id", "key_id", "order_id", "amount_cents", "currency", "expires_at", "invoice_id"}. A paid upgrade hasplan_upgrade_intent_idinstead ofinvoice_id. A mandate checkout (register_mandate) also hascustomer_id,recurring: trueandmandate_max_amount_cents. If the order is not ready yet, or has expired, the result is{"type": "processing", "checkout_id", "status"}instead. A bulk Entity checkout first answers{"entity_subscription_checkout": {"type": "processing", "status": "open", "checkout_id": "..."}}. Aprocessingresult carries an internal operation status such asready, not the state of the checkout. - Pay. Load
https://checkout.razorpay.com/v1/checkout.jsand opennew window.Razorpay({ key: key_id, order_id, handler }). - Verify. Send the three fields from the handler to your backend, which calls
POST /checkout/{checkout_id}/verifywithrazorpay_order_id,razorpay_payment_idandrazorpay_signature. A missing field returns400. Nozle checks that the order matches the checkout and that the signature is the HMAC-SHA256 oforder_id|payment_idwith your key secret; a mismatch returns422 validation_errorswithinvalid_checkout_signature. Nozle then fetches the payment from Razorpay and settles only acapturedpayment. The response is the checkout status. - Poll. Call
GET /checkout/{checkout_id}untilstatusandfulfillment_statusare bothsucceeded. Each call reconciles with Razorpay.
The nested
checkout of the status response is present while the checkout is awaiting_payment and also for an expired unpaid order. For the expired order it is {"type": "processing", "status": "ready"}, so do not reopen the modal for it. A status call without a customer and subscription scope needs a secret key; an unknown ID returns 404 checkout_session_not_found. The React CheckoutButton runs steps 2 to 4 for you when BillingProvider has verifyCheckout and getCheckoutStatus callbacks; it polls 30 times, one second apart.
To retry a new-subscription checkout, call checkout again for the same plan: while the first order is unpaid it returns that same order. The React “Payment failed. You can retry checkout.” button does this. See Retry a new-subscription checkout.
POST /checkout never returns type: "hosted" for Razorpay. A Razorpay payment link comes from an invoice payment URL (POST /invoices/{id}/payment_url) or a credit top-up purchase, as an rzp.io payment_url with no checkout ID to poll. Redirect the customer to it; the payment_link.paid webhook settles it.
API shape
Plan checkout:/checkout endpoint; browser sessions never call /billing/upgrade or /subscriptions/change.
Return URLs and your organization’s origins
A checkoutreturn_url (or success_url) must be an absolute HTTPS URL on an allowed origin. Core accepts the origins of the Nozle deployment and the origins you register for your own organization, for POST /checkout_sessions, Entity checkouts and Entity subscription checkout batches. Another organization’s origin is refused with 422 and return_url: [invalid_or_untrusted].
Register your origins with PUT /organizations (REST) or updateOrganization (GraphQL), which needs the developers:manage permission:
checkout_return_origins: up to 20 HTTPS origins, each a scheme and host only (no path, query or user info). They are stored in lowercase without a trailing slash. An invalid value answers422withcheckout_return_origins: [value_is_invalid].checkout_default_return_url: an HTTPS URL on one of the allowed origins, ornull. A URL off the allowed origins answers422withcheckout_default_return_url: [invalid_or_untrusted].- GraphQL:
checkoutReturnOriginsandcheckoutDefaultReturnUrlonupdateOrganizationandCurrentOrganization. GET /organizationsreturns both settings.
POST /checkout route still checks return_url against the origins configured for the Nozle deployment before it forwards the request, and does not read your registered origins yet; see create checkout.
Response
sk_. A Nozle publishable key is valid only for the plan catalog. If you mount embedded Stripe checkout yourself, your app also needs the Stripe publishable key for the same Stripe account connected in Nozle.
Credit-funded completion response:
effective_at uses the customer’s timezone offset, for example 2026-11-01T00:00:00.000000+05:30.
Changing an existing subscription needs a plan transition policy for that pair of plans. Without one, checkout returns 422 plan_transition_policy_required. See Create checkout for every result type and error.
React implementation
Option A — CheckoutButton (simplest)
Option B — Custom flow with useCheckoutSession
Option C — Embedded Stripe Checkout
onComplete does not fire when the session redirects to the return_url, and Nozle sessions redirect. Show success from your return page, or by reading billing status for the customer.
Server-side plan changes
For B2B scenarios where payment is already handled outside embedded checkout:subscribe() requires a secret key (sk_) and an existing customer: an unknown customer returns 404 customer_not_found. Embedded checkout should be used when Nozle needs to collect payment before activating a paid plan.
Mixed Entity plan checkout
Seat-based products can sell different Entity plans in one invoice and one payment. Every plan in the basket must require an up-front payment: a free, trial or pay-in-arrears plan is refused withpaid_plan_required. Your backend first creates or reuses active placeholder Entities, then calls the bulk Entity checkout endpoint with the selected plan for each placeholder.
{ proSeats: 2, maxSeats: 1 }; your backend derives the customer, creates stable Entity IDs, validates the return origin, and calls Nozle.
Use billing_time: "anniversary" when the first successful purchase should establish the customer’s renewal day. Later Entity purchases align to that day and use Nozle’s invoice proration.
See Entity Subscriptions for the complete request and response.