Pular para o conteúdo principal

Subscribe a tenant to the Pro plan

You can put a tenant on the Pro plan with one Logto Cloud API call, without opening Console. Together with tenant creation, this lets your automation create a production tenant and subscribe it in two calls.

The call charges the first invoice to the card saved on your billing account. No one is present to confirm the payment, so the call either succeeds completely or leaves nothing behind: a failed payment never creates a subscription.

Before you start​

You need:

  • A Logto Cloud Personal Access Token (PAT) with access to this API. Contact us to get one.
  • The Admin role on the tenant. The user who creates a tenant is its Admin.
  • A billing account with a saved card. Your Logto Cloud account gets one the first time you subscribe any tenant to the Pro plan in Console > Settings > Plan and Billing. The API charges the card of your default billing account, unless the tenant has been on a paid plan before or you choose another one.
  • A production tenant on the Free plan. Development tenants and tenants covered by an enterprise contract are not supported.
VariableDescription
CLOUD_API_ENDPOINTThe Logto Cloud API endpoint. For Logto Cloud, use https://cloud.logto.io.
LOGTO_CLOUD_PATA PAT for your Logto Cloud account.
TENANT_IDThe ID of the tenant to subscribe.

Subscribe the tenant​

Call POST /api/tenants/{tenantId}/subscription with an Idempotency-Key header:

export IDEMPOTENCY_KEY="$(uuidgen)"

curl -X POST "$CLOUD_API_ENDPOINT/api/tenants/$TENANT_ID/subscription" \
-H "Authorization: Bearer $LOGTO_CLOUD_PAT" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{ "skuId": "pro-202509" }'
  1. Idempotency-Key: REQUIRED. A unique string of 1 to 255 characters that identifies this attempt. Generate one per attempt, store it, and send the same value when you retry the attempt.
  2. skuId: REQUIRED. The plan to subscribe to. Use pro-202509 for the Pro plan.
  3. customerId: OPTIONAL. The billing account to charge, when you have more than one. When omitted, a tenant that has been on a paid plan before is charged to its previous billing account; any other tenant is charged to your default billing account. See Choose the billing account.

The response status tells you what happened:

  • 201: the subscription was created and the tenant is on the Pro plan.
  • 200: this key already created the subscription. Nothing was charged again.

Example response:

{
"subscription": {
"id": "sub_1Qabc...",
"planId": "pro-202509",
"status": "active",
"currentPeriodStart": "2026-09-20T06:00:00.000Z",
"currentPeriodEnd": "2026-10-20T06:00:00.000Z",
"isEnterprisePlan": false,
"isDevPlan": false,
"quotaScope": "dedicated"
},
"tenant": {
"id": "abc123",
"tag": "production",
"planId": "pro-202509"
}
}

Choose the billing account​

Your Logto Cloud account can hold more than one billing account, each with its own card and billing address; for example, one per company you pay for. One of them is the default, and the API charges it unless you name another one.

List your billing accounts with GET /api/me/stripe-customers:

curl "$CLOUD_API_ENDPOINT/api/me/stripe-customers" \
-H "Authorization: Bearer $LOGTO_CLOUD_PAT"
[
{
"customerId": "cus_Rabc...",
"isDefault": true,
"name": "Acme Inc.",
"email": "[email protected]",
"createdAt": "2026-03-02T09:12:45.000Z"
},
{
"customerId": "cus_Rdef...",
"isDefault": false,
"name": null,
"email": "[email protected]",
"createdAt": "2026-08-14T15:30:00.000Z"
}
]

name and email are the ones saved on the billing account and can be null. Billing accounts that no longer exist are left out.

To charge a specific billing account for one subscription, send its customerId in the body:

curl -X POST "$CLOUD_API_ENDPOINT/api/tenants/$TENANT_ID/subscription" \
-H "Authorization: Bearer $LOGTO_CLOUD_PAT" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{ "skuId": "pro-202509", "customerId": "cus_Rdef..." }'

To change which billing account is charged by default, for the API and for Console alike (this does not move a tenant that has been on a paid plan before, see the note below):

curl -X PATCH "$CLOUD_API_ENDPOINT/api/me/stripe-customers/cus_Rdef..." \
-H "Authorization: Bearer $LOGTO_CLOUD_PAT" \
-H "Content-Type: application/json" \
-d '{ "isDefault": true }'

The call answers 204 on success, 404 when the billing account is not one of yours, and 422 customer_unavailable when it no longer exists.

nota:

A tenant that has been on a paid plan before, even if that subscription was cancelled since, stays on the billing account that paid for it. Such a tenant is always charged to that billing account, whatever your default is. Leave customerId out: naming a billing account answers 422 customer_fixed. Contact us to move a tenant to another billing account.

A retry with the same Idempotency-Key must name the same billing account as the first request, or none; another one answers 400 idempotency_key_mismatch.

Retry safely​

A network timeout does not tell you whether the card was charged. The Idempotency-Key is what makes a retry safe: Logto performs the charge at most once per key, so retrying with the same key is always safe.

  1. When the outcome is unknown, retry with the same key. That covers a client timeout or dropped connection, a 5xx response, and 409 attempt_in_progress (an earlier request with this key may still be running, so wait a few seconds first).
  2. The retry settles the attempt. It answers 201 or 200 once the subscription exists, or the error that ended the attempt.
  3. Start a new attempt with a new key only when the attempt has ended in an error. A same-key retry then answers with a message that the attempt already failed. Fix the cause first, for example by updating the card.
  4. Stop and contact us when the message asks you to. Include error.requestId when the response has one.
nota:

While an attempt is open, the tenant is reserved for it: a request with a different key gets 409 attempt_in_progress until the open attempt ends. Retry the open attempt with its own key rather than starting a new one. Keys are scoped to your account.

Retry within 23 hours. After that, Logto can no longer guarantee a single charge and holds the attempt instead: the same-key retry answers 409 attempt_in_progress with a message to contact support, and we resolve it by hand.

Errors​

Errors use the HTTP status and a JSON body with a message and, for most errors, an error.code:

{
"message": "The card was declined.",
"error": { "code": "card_declined", "declineCode": "insufficient_funds" }
}
Statuserror.codeMeaning and what to do
400(none)The Idempotency-Key header is missing or longer than 255 characters, or the body has no skuId.
400invalid_skuThe skuId cannot be bought through the API.
400idempotency_key_mismatchThe key was already used for another tenant, plan or billing account. Use a new key, unless the message asks you to contact support.
402card_declined, expired_card, incorrect_cvc, incorrect_numberThe card could not be charged. declineCode is included when the card issuer shares it. Update the card in Console, then start a new attempt.
402processing_errorThe card could not be processed this time. Start a new attempt shortly.
402authentication_requiredThe card issuer requires the cardholder to confirm the payment, which an API call cannot do. Subscribe this tenant in Console instead.
403insufficient_roleYou are a member of the tenant but not an Admin.
403(none)The token lacks access to this API, or the tenant is covered by an enterprise contract or is in a private region.
404(none)The tenant does not exist, or you are not a member of it.
404customer_not_foundThe customerId is not one of your billing accounts. List them with GET /api/me/stripe-customers.
409subscription_existsThe tenant already has a subscription.
409attempt_in_progressAn attempt for this tenant is open, or this attempt is held. See Retry safely.
409no_customer, no_payment_methodYour account has no billing account, or it has no saved card. Subscribe a tenant in Console once, or add a card there.
409tax_location_invalidThe billing address cannot be used to calculate tax. Update it in Console.
409customer_not_owned, customer_unavailable, currency_mismatch, attempt_abandonedThe tenant's billing account could not be verified as yours, or it needs our help. Contact us.
422customer_fixedThe tenant stays on the billing account that paid for it before. Leave customerId out, or contact us to move the tenant.
422dev_tenant_not_supportedDevelopment tenants cannot be subscribed through the API. Convert the tenant in Console.
422subscription_status_exceptionThe tenant's latest subscription needs attention first. Contact us.
500internal_error or noneSomething went wrong on our side. Retry with the same key, and contact us if it repeats.
502stripe_errorOur payment provider refused or failed the request. Retry with the same key, and contact us if it repeats.
503stripe_unavailable, provisioning_failedThe outcome is unknown, or the payment succeeded and the setup is still to finish. Retry with the same key.

You can update the card and the billing address in Console > Settings > Plan and Billing of any tenant that uses the same billing account.

Create and subscribe in one script​

Tenant creation has no idempotency key. If POST /api/tenants times out, list your tenants with GET /api/tenants before creating again, so a retry does not create a second tenant.

import { randomUUID } from 'node:crypto';

const cloudApiEndpoint = process.env.CLOUD_API_ENDPOINT ?? 'https://cloud.logto.io';
const headers = {
authorization: `Bearer ${process.env.LOGTO_CLOUD_PAT}`,
'content-type': 'application/json',
};

const created = await fetch(`${cloudApiEndpoint}/api/tenants`, {
method: 'POST',
headers,
body: JSON.stringify({ name: 'My automated tenant', tag: 'production', regionName: 'EU' }),
});

if (!created.ok) {
throw new Error(`Tenant creation failed: ${await created.text()}`);
}

const tenant = await created.json();

// Store the key with the tenant, so a later run can retry the same attempt.
const idempotencyKey = randomUUID();

const subscribe = async () =>
fetch(`${cloudApiEndpoint}/api/tenants/${tenant.id}/subscription`, {
method: 'POST',
headers: { ...headers, 'idempotency-key': idempotencyKey },
body: JSON.stringify({ skuId: 'pro-202509' }),
});

const isOutcomeUnknown = async (response) =>
!response ||
response.status >= 500 ||
(response.status === 409 &&
(await response.clone().json()).error?.code === 'attempt_in_progress');

let subscription;

for (let attempt = 0; attempt < 5 && !subscription; attempt += 1) {
const response = await subscribe().catch(() => undefined);

if (response?.ok) {
subscription = await response.json();
} else if (await isOutcomeUnknown(response)) {
// Safe: the same key never charges twice.
await new Promise((resolve) => setTimeout(resolve, 2000 * (attempt + 1)));
} else {
throw new Error(`Subscription refused: ${await response.text()}`);
}
}

if (!subscription) {
throw new Error(`Still unknown. Retry later with the same key: ${idempotencyKey}`);
}