Entity Subscriptions
curl --request POST \
--url https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout"
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, headers=headers)
print(response.text)const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout"
req, _ := http.NewRequest("POST", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
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/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_body<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$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/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout")
.header("Authorization", "Bearer <token>")
.asString();Checkout & Subscriptions
Entity Subscriptions
Give individual customer Entities independent plans, billing periods, and subscription lifecycles.
POST
/
core
/
api
/
v1
/
customers
/
{customer_id}
/
entity-subscriptions
/
checkout
Entity Subscriptions
curl --request POST \
--url https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout"
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, headers=headers)
print(response.text)const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout"
req, _ := http.NewRequest("POST", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
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/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_body<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.nozle.app/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$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/core/api/v1/customers/{customer_id}/entity-subscriptions/checkout")
.header("Authorization", "Bearer <token>")
.asString();Base URL:
Nozle validates the live Entity before creating or changing its subscription. Suspended or deleted Entities cannot start checkout. A newly created Entity that reuses an older external ID cannot inherit the older Entity’s subscription.
This operation is idempotent. It creates the stable Entity-to-subscription identity but does not select or activate a plan. It returns
List all Entity subscriptions for a customer:
Reading one Entity returns the subscription under
Each item also has
The Python SDK exposes the same backend operation:
The Go SDK exposes the grouped checkout result with typed Entity items:
On the frontend, pass a Stripe result to the React checkout component:
The endpoint below remains available for backends that call HTTP directly.
The request accepts 1 to 100 unique Entities and requires an
Create each Entity first with Customer Entities. An Entity that does not exist currently makes the request fail with a bare
Nozle supports these result types:
The idempotency key covers the customer, billing mode, return URL, Entity IDs, and plans. Retry the exact request with the same key: Nozle returns the same batch with
The plan transition policy for the current and target plans determines whether the change is immediate, payment-gated, or scheduled for the renewal boundary. Without a policy for the pair the request returns
Cancellation supports the same settlement controls as customer subscriptions. Send
Removal is allowed only when the identity has no active, pending, or incomplete subscription. It then returns
https://api.nozle.app/core/api/v1.
Entity subscriptions let one customer pay for multiple independently managed subscriptions. Each Entity keeps a stable subscription identity while using the standard Nozle plan, invoice, checkout, renewal, and reporting lifecycle.
Auth: secret key only. Publishable keys are rejected.
Customer: workspace_123
├── Entity: user_42 → Pro monthly
└── Entity: user_43 → Max annual
Ensure a subscription identity
PUT /customers/workspace_123/entities/user_42/subscription
Authorization: Bearer sk_...
201 when it creates the identity and 200 when the identity already exists, with the identity under entity_subscription.
Read subscriptions
Read one Entity subscription:GET /customers/workspace_123/entities/user_42/subscription
Authorization: Bearer sk_...
GET /customers/workspace_123/entity-subscriptions
Authorization: Bearer sk_...
entity_subscription. The list returns an entity_subscriptions array, ordered by external_entity_id. Each item reports the current and pending plans independently:
{
"entity_subscription": {
"external_customer_id": "workspace_123",
"external_entity_id": "user_42",
"external_subscription_id": "entity-sub-7e1782f4-...",
"status": "active",
"current_plan": {
"code": "pro_monthly",
"name": "Pro",
"interval": "monthly",
"amount_cents": 1499,
"amount_currency": "USD",
"status": "active",
"effective_at": "2026-10-01T00:00:00Z"
},
"pending_plan": null,
"billing_time": "anniversary",
"cancel_at_period_end": false
}
}
billing_authority, credit_import_completed_at, subscription_at, started_at, current_period_start, current_period_end, ending_at, canceled_at, created_at and updated_at. status is unsubscribed when the Entity has no current or incomplete subscription.
current_plan and pending_plan have the same fields. Their effective_at is the subscription’s scheduled change time when one is set, otherwise its start date. An unknown Entity returns 404 entity_subscription_not_found.
Start checkout
POST /customers/workspace_123/entities/user_42/subscription/checkout
Authorization: Bearer sk_...
Idempotency-Key: checkout-user-42-pro-v1
Content-Type: application/json
{
"plan_code": "pro_monthly",
"billing_time": "anniversary",
"return_url": "https://app.example.com/settings/billing"
}
billing_time accepts anniversary or calendar for the initial subscription. Paid plans remain incomplete until verified payment succeeds. Retrying an incomplete checkout for the same plan reuses its subscription and invoice instead of creating another charge. A different plan replaces the abandoned checkout; 409 entity_subscription_checkout_in_progress remains only while the provider is still processing a payment for it. An unpaid 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.
Purchase multiple Entity plans together
Use bulk Entity checkout when a customer purchases a mixed pool of plans in one payment. The merchant backend supplies one item per Entity; Nozle creates one invoice and one Stripe or Razorpay checkout for the complete basket. Use a server SDK from your trusted backend. These SDK methods validate the basket and return the checkout contract. In Node.js:import { Nozle } from '@nozle-js/node'
const nozle = new Nozle({
apiKey: process.env.NOZLE_SECRET_KEY!,
eventsUrl: 'https://api.nozle.app/core',
})
const checkout = await nozle.entitySubscriptions.checkoutMany('workspace_123', {
billingTime: 'anniversary',
returnUrl: 'https://app.example.com/settings/billing',
idempotencyKey: 'workspace-123-seat-purchase-v1',
items: [
{ externalEntityId: 'seat_pro_001', planCode: 'pro_monthly' },
{ externalEntityId: 'seat_max_001', planCode: 'max_monthly' },
],
})
// Return this result to your frontend. Never expose NOZLE_SECRET_KEY.
import os
from nozle import Nozle
nozle = Nozle(
api_key=os.environ["NOZLE_SECRET_KEY"],
events_url="https://api.nozle.app/core",
)
checkout = nozle.entity_subscriptions.checkout_many(
"workspace_123",
billing_time="anniversary",
return_url="https://app.example.com/settings/billing",
idempotency_key="workspace-123-seat-purchase-v1",
items=[
{"external_entity_id": "seat_pro_001", "plan_code": "pro_monthly"},
{"external_entity_id": "seat_max_001", "plan_code": "max_monthly"},
],
)
import nozle "github.com/nozle-dev/nozle-go"
client := nozle.New("") // reads NOZLE_SECRET_KEY
checkout, err := client.CheckoutEntitySubscriptions(ctx, "workspace_123", []nozle.EntitySubscriptionCheckoutItem{
{ExternalEntityID: "seat_pro_001", PlanCode: "pro_monthly"},
{ExternalEntityID: "seat_max_001", PlanCode: "max_monthly"},
}, "https://app.example.com/settings/billing", "anniversary", "workspace-123-seat-purchase-v1")
if err != nil { return err }
_ = checkout
import { Checkout } from '@nozle-js/react'
if (checkout.type === 'stripe' && checkout.client_secret) {
return (
<Checkout
clientSecret={checkout.client_secret}
publishableKey={import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY}
/>
)
}
POST /customers/workspace_123/entity-subscriptions/checkout
Authorization: Bearer sk_...
Idempotency-Key: workspace-123-seat-purchase-v1
Content-Type: application/json
{
"entity_subscription_checkout": {
"billing_time": "anniversary",
"return_url": "https://app.example.com/settings/billing",
"items": [
{
"external_entity_id": "seat_pro_001",
"plan_code": "pro_monthly"
},
{
"external_entity_id": "seat_max_001",
"plan_code": "max_monthly"
}
]
}
}
Idempotency-Key header of at most 255 bytes. Every plan must require an up-front payment, use the same currency, and use the same interval. A free plan, a plan with a trial, or a plan billed in arrears is refused, even though single Entity checkout accepts it. Each Entity must be active and must not already have an active, pending, or incomplete subscription.
Item problems fail the whole basket with 422 and the reason in error_details.items:
{
"status": 422,
"error": "Unprocessable Entity",
"code": "validation_errors",
"error_details": { "items": ["paid_plan_required"] }
}
error_details | Cause |
|---|---|
items: ["paid_plan_required"] | A plan does not require an up-front payment. |
items: ["mixed_currencies"], items: ["mixed_intervals"] | Plans differ in currency or interval. |
items: ["duplicate_external_entity_id"] | The same Entity appears twice. |
items: ["value_is_mandatory"], items: ["value_is_too_large"] | No items, or more than 100. |
items: ["value_is_invalid"] | items is not a list, or an item has an empty plan_code or an empty or over-255-byte external_entity_id. |
items: ["entity_subscription_already_exists"] | An Entity already has an active, pending, or incomplete subscription. |
billing_time: ["value_is_invalid"] | billing_time is not anniversary or calendar. When it is omitted, the organization’s billing alignment is used. |
idempotency_key: ["value_is_mandatory"], idempotency_key: ["value_is_too_long"] | The header is missing, or longer than 255 bytes. |
idempotency_key: ["idempotency_key_reused"] | The key was used for a different basket. |
checkout: ["payment_provider_changed"] | A replayed Razorpay batch, but the customer is now assigned to another payment connection. |
currency: ["customer_currency_mismatch"] | The plans’ currency differs from the customer’s. |
checkout: ["billing_anchor_checkout_in_progress"] | A first paid checkout for this customer is still open. |
checkout: ["checkout_not_available"] | A replay of a batch that has failed or expired. Start a new checkout with a new key. |
500, not a 404; this is a known defect.
An unknown customer or plan returns 404 (customer_not_found, plan_not_found). Without a connected payment provider the request returns 422 payment_provider_required, as a single Entity checkout does; 422 stripe_customer_required means Nozle could not create the customer in Stripe.
{
"entity_subscription_checkout": {
"id": "checkout_batch_uuid",
"type": "stripe",
"status": "open",
"client_secret": "cs_...",
"invoice_id": "invoice_uuid",
"amount_cents": 4498,
"currency": "USD",
"replayed": false,
"expires_at": "2026-10-13T17:00:00Z",
"items": [
{
"external_entity_id": "seat_pro_001",
"external_subscription_id": "entity-sub-...",
"plan_code": "pro_monthly",
"subscription_status": "incomplete"
}
]
}
}
stripe: mount the returnedcs_client secret with Stripe Embedded Checkout;razorpay: the result also hascheckout_id,key_id, andorder_id; open the Razorpay modal and verify as described in Razorpay. Razorpay needs INR plans (razorpay_requires_inrotherwise);processing: the payment completed, or the Razorpay order is not ready, and the verified payment event is still being processed; andcompleted: the invoice is already settled, so no provider session is required.
status is the batch status: pending, open, completed, failed, or expired. An unpaid checkout expires after 23 hours (expires_at).
A bulk Razorpay checkout answers first with
type: "processing", status: "open", client_secret: null and a checkout_id, because Nozle creates the Razorpay order in the background. Either poll GET /checkout/{checkout_id} until its nested checkout is {"type": "razorpay", "key_id", "order_id", ...}, or replay the same request with the same Idempotency-Key: once the order exists the replay returns type: "razorpay" with checkout_id, key_id and order_id. Then open the modal. After the payment, status goes succeeded with fulfillment_status processing, then succeeded, and every Entity subscription in the basket becomes active.replayed: true and reuses the open Stripe session. A different basket with the same key returns 422 with idempotency_key: ["idempotency_key_reused"]. The result also carries clientSecret, a camel-case alias of client_secret.
Shared customer billing anchor
Bulk Entity checkout establishes one stable billing calendar for the paying customer:- if the customer has never completed a paid Entity checkout, the first successful payment establishes the anchor;
- failed or abandoned first checkout does not establish an anchor;
- concurrent first-paid checkouts are rejected while the anchor is unresolved;
- later Entity purchases align to the established anchor and prorate their first period; and
- the anchor remains stable even if the customer temporarily has no paid Entities.
billing_time: "anniversary". For example, a successful first payment on August 6 renews on the sixth. A seat added on August 20 is charged only through September 6, then joins the normal renewal invoice.
The anchor is established only after confirmed payment. Creating an invoice or receiving a browser redirect is not enough.
Change plan
POST /customers/workspace_123/entities/user_42/subscription/change-plan
Authorization: Bearer sk_...
Idempotency-Key: change-user-42-max-v1
Content-Type: application/json
{
"plan_code": "max_annual",
"return_url": "https://app.example.com/settings/billing"
}
422 plan_transition_policy_required (a change from a free plan to a paid plan uses the organization’s free-to-paid policy instead). Change-plan takes no billing_time. Changing one Entity never changes another Entity’s subscription.
The result is a checkout result with external_entity_id and external_subscription_id added. A scheduled change:
{
"type": "scheduled",
"status": "pending",
"subscription_id": "pending_subscription_uuid",
"plan_code": "starter_monthly",
"transition_direction": "downgrade",
"transition_policy": "next_period",
"effective_at": "2026-11-01T00:00:00.000000Z",
"external_entity_id": "user_42",
"external_subscription_id": "entity-sub-..."
}
effective_at carries the customer’s timezone offset, for example 2026-11-01T00:00:00.000000+05:30. Changing again while a scheduled change is pending replaces the pending change. A paid upgrade does not replace or withdraw a pending downgrade: it is refused with 409 subscription_change_pending, so withdraw it first, as described in Paid upgrades: current limits. The target being the current plan returns 422 plan_already_active.
Cancel
POST /customers/workspace_123/entities/user_42/subscription/cancel
Authorization: Bearer sk_...
Idempotency-Key: cancel-user-42-v1
Content-Type: application/json
{
"timing": "end_of_period",
"credit_action": "none"
}
credit_action explicitly: for a plan billed in arrears, a request without it returns 422 with credit_action: not_supported_for_pay_in_arrears. timing accepts immediate or end_of_period. Prefer end_of_period unless the merchant explicitly intends to terminate access immediately.
The Idempotency-Key header is required (at most 255 characters); without it the request returns 422 with idempotency_key: value_is_mandatory. The response has the updated identity under entity_subscription and the recorded cancellation under subscription_transition, with its id and replayed. An identity with no current subscription returns 404 subscription_not_found.
Remove an unused identity
DELETE /customers/workspace_123/entities/user_42/subscription
Authorization: Bearer sk_...
204 with no body, including for an identity that never had a subscription. An identity with an active, pending, or incomplete subscription returns 403 entity_subscription_has_billing_history. An identity that does not exist returns 404 entity_subscription_not_found. Historical billing records remain immutable.
Payment provider webhooks (Stripe, or Razorpay together with your verify call) are authoritative for paid activation. Do not grant paid access from a browser redirect or checkout callback alone.