Membership
Prepare paid subscriptions and deliver signed charge results.
Start with SDK basics and credentials. Keep the payment steps below on your trusted app server.
Current-ready Sandbox supports the same delegated billing connection, charge/query and billing custody flows as Live. Separately provisioned test workspaces and retired/non-ready environments remain unavailable.
Console performs no delegated payment-mode verification. LIVE keys can charge real cards even in Sandbox. Check your app server's TEST keys and provider account before testing. The workspace administrator is responsible for selecting and verifying the payment mode.
Sandbox reset creates a new workspace without copying billing connections or ciphertext. Charges pending or in flight at reset are no longer reconciled by automatic queries in the retired generation. Reset does not cancel requests already sent; later signed results or responses already in flight may still be applied under the existing payment-evidence rules.
Retained billing and audit records can block permanent deletion of the retired generation. That generation continues to count toward the limit of seven unpurged archives, which can prevent further resets. Confirm that charges have fully settled before resetting. Customer email and ordinary webhook delivery remain captured in Sandbox.
Encrypted billing-key storage
Your server encrypts billing keys and uses server.memberships.billingCustody
to store and retrieve the ciphertext. Obtain the current restore generation
from Console for the prepared Membership:
This excerpt assumes an existing server client, Membership ID, and webhook secret.
const { generation } = await server.memberships.billingCustodyContext({
membershipId,
webhookSecret,
})
const stored = await server.memberships.billingCustody({
action: 'read',
membershipId,
webhookSecret,
generation,
})Keep this generation and stored.version for every update based on that read.
For a new encrypted upload, obtain a context immediately before put with
expectedVersion: 0. Configure the server API key and delegated connection
secret for the same workspace and API address. The context request uses both
credentials and the Membership's active payment method.
The Membership and CLUB templates obtain this value automatically. Existing apps with an explicit generation can continue using it. Use a Console deployment that supports context before upgrading the SDK and template.
If a generation mismatch occurs, stop before charging. After platform recovery, wait for the operator's reopening notice, re-authorize the payment method with the provider, and upload and retrieve a fresh encrypted billing key. Keep pending charge evidence for reconciliation. Resolve unavailable contexts with the platform operator; automatically rebinding an old update to a new generation can restore stale payment credentials.
Paid Membership Subscription
Enable customers and membership for this flow. Select ecommerce separately if you also need products and orders.
server.memberships.subscribe() prepares a paid membership. It does not accept a provider payment identifier and does not activate the membership. Use a server-held Secret Key with write scope in a workspace where Membership is in effect (selected and allowed by the plan), then branch on the response union:
- A paid result is
incomplete, with the exact amount, currency, period start, expiry, andfirstCharge.idempotencyKeyfor the workspace PSP. Charge the PSP with that key and post the outcome to the existing signed charge-result webhook. - A trial result is immediately
trialingand has no first-charge instruction. - Renewals keep the billing day in UTC: the first paid period start, or the trial end for a trial. In a shorter month the period ends on that month's last day, then returns to the billing day (Jan 31 → Feb 28 → Mar 31).
To end an abandoned signup attempt, a trusted server can call server.memberships.endSubscribeIntent({ customerId, subscribeIntentKey }).
- Only
status: 'ended'confirms that the identity can no longer be used by a later subscribe.existingmeans recover or inspect the existing membership. - It never cancels a membership or payment.
- Use an opaque random key without personal data: 1–512 visible ASCII characters without whitespace, the same identity as the subscribe header.
- Treat unsupported versions, timeouts, malformed responses, and errors as an unconfirmed end.
- Deploy the Console migration and server before the SDK and its consumers.
Trial eligibility. In the same workspace, a customer gets a trial at most once per plan, including after an immediate cancellation or soft trash.
- A started period with a positive trial snapshot uses up eligibility. A started legacy row with a null or missing snapshot is conservatively treated as used.
- An unfinished setup without a started period, and a paid-only snapshot of zero, do not use it up.
- Catalog trial days and a free-trial badge describe the plan; check the subscribe response for this customer's actual result.
- A customer who already used this plan's trial receives the paid
incompleteresponse. Without an active delegated billing connection, that is409 conflictwith no lifecycle writes.
Before any PSP call. Clearly show the paid amount, currency, and period start from firstCharge and get the customer's confirmation. The response has no period end, so keep the selected billing cycle visible without inventing a period-end field. If the customer declines, do not call the PSP.
The preparation is evidence of neither payment nor consent. Record the customer's confirmation in your app, and confirm activation with the me() check below.
Billing connection. Before a paid subscribe, a tenant-admin selects the workspace in Console and opens Settings → Commerce → Payments → Membership billing connections, then configures an active connection in the Membership delegated payment connection panel. The entry requires the Membership feature and administrator access to that workspace. Store that connection's webhookSecret, which is revealed only once when the connection is provisioned or reissued, in your app server's secret store. It signs every result callback, is separate from the subscribe Secret Key, and must never reach the browser. Without an active connection, Console returns 409 conflict without lifecycle writes.
When the connection panel rejects a request, check the displayed reason. Reconcile pending membership charges before trying again. If an active connection already exists, refresh the page to view it.
Implement retain as durable app storage before any PSP call. Keep the complete prepare response (membershipId and every firstCharge field) and your lossless mapping to the PSP identity. The subscribe intent key identifies the subscribe request, and firstCharge.idempotencyKey identifies the charge; your charge adapter uses the latter to deduplicate PSP execution.
import { createServerClient } from '@01.software/sdk/server'
type Server = ReturnType<typeof createServerClient>
type Prepared = Awaited<ReturnType<Server['memberships']['subscribe']>>
type FirstCharge = Extract<Prepared, { status: 'incomplete' }>['firstCharge']
export async function prepareSubscription(
server: Server,
input: Parameters<Server['memberships']['subscribe']>[0],
tenantPsp: {
retain: (prepared: Prepared) => Promise<void>
confirm: (charge: FirstCharge) => Promise<boolean>
charge: (charge: FirstCharge) => Promise<void>
},
) {
const prepared = await server.memberships.subscribe(input)
await tenantPsp.retain(prepared)
if (prepared.status === 'incomplete') {
// Present amount, currency and periodStart; obtain explicit paid consent.
if (!(await tenantPsp.confirm(prepared.firstCharge))) return prepared
// Confirmation may take time. Recheck absolute expiry before PSP dispatch.
const expiresAt = Date.parse(prepared.firstCharge.expiresAt)
if (!Number.isFinite(expiresAt) || Date.now() >= expiresAt) {
throw new Error('First charge instruction expired or invalid')
}
await tenantPsp.charge(prepared.firstCharge)
}
return prepared
}Only the signed result webhook, or its existing reconciliation query, decides activation.
- Reuse the same subscribe idempotency key while the first charge is pending. A replay after the first charge is settled as paid or failed, or after
expiresAt, receives409, not a new payment instruction. - Do not start the PSP payment if
expiresAtis invalid, or if the current time is at or after it. - The workspace PSP must deduplicate on
firstCharge.idempotencyKey. A late paid result is kept as evidence but does not activate the Membership, so the workspace must handle the resulting refund outside this API.
Replays and duplicates:
- Existing intent snapshots never change, and a completed trial intent keeps its cached trial response.
- The existing payload checks and the live duplicate check run first. Another live (
trialing,active, orpast_due) Membership returns409 duplicate_subscription. - If an unfinished trial intent passes those checks after a different intent received the trial, it returns
409 idempotency_conflictwithout lifecycle writes and is not converted into a paid intent. Start a new, confirmed paid intent instead. - The expiry job moves unfinished setup to
expired. After 72 hours past setup expiry and at least three post-expiry pending query answers, its unresolved first charge may close asvoidedif no unapplied paid evidence exists. Renewals are excluded. Membership and charge evidence are kept.
Report the first charge
After your server verifies the PSP outcome, keep that outcome with the preparation. Report only a confirmed paid or a definitive failed. A timeout, missing response, or unknown outcome stays unknown: query and reconcile the PSP with the same saved charge and PSP identity, and never report it as failed. A definitive first-charge failure expires the incomplete Membership, and a later paid observation may not activate it.
This app-local Node server module posts the current HTTP protocol. Pass the saved preparation and the verified outcome; it does not call the PSP. For paid results, keep the exact Money and pgPaymentId. Failed results omit Money and include pgPaymentId only when known.
import { createHmac, randomUUID } from 'node:crypto'
import type { createServerClient } from '@01.software/sdk/server'
type Server = ReturnType<typeof createServerClient>
type Prepared = Awaited<ReturnType<Server['memberships']['subscribe']>>
type PaidPreparation = Extract<Prepared, { status: 'incomplete' }>
// Supply only outcomes verified by your trusted server against the PSP.
export type VerifiedOutcome =
| { status: 'unknown' }
| { status: 'paid'; amount: number; currency: string; pgPaymentId: string }
| { status: 'failed'; pgPaymentId?: string }
export async function reportFirstCharge(
prepared: PaidPreparation,
outcome: VerifiedOutcome,
connection: { apiUrl: string; webhookSecret: string },
) {
if (outcome.status === 'unknown') return { status: 'unconfirmed' as const }
if (
outcome.status === 'paid' &&
(outcome.amount !== prepared.firstCharge.amount ||
outcome.currency !== prepared.firstCharge.currency)
) {
throw new Error('Reconcile PSP Money against the retained first charge')
}
const event =
outcome.status === 'paid'
? {
kind: 'charge-result',
membershipId: prepared.membershipId,
idempotencyKey: prepared.firstCharge.idempotencyKey,
status: 'paid',
amount: outcome.amount,
currency: outcome.currency,
pgPaymentId: outcome.pgPaymentId,
}
: {
kind: 'charge-result',
membershipId: prepared.membershipId,
idempotencyKey: prepared.firstCharge.idempotencyKey,
status: 'failed',
pgPaymentId: outcome.pgPaymentId,
}
const rawBody = JSON.stringify(event)
const timestamp = Date.now().toString()
const deliveryId = randomUUID()
const signature = createHmac('sha256', connection.webhookSecret)
.update(`${timestamp}.${deliveryId}.${rawBody}`, 'utf8')
.digest('hex')
const response = await fetch(
new URL(
`/api/memberships/${encodeURIComponent(prepared.membershipId)}/charge-result`,
connection.apiUrl,
),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-membership-signature': signature,
'x-membership-timestamp': timestamp,
'x-membership-delivery-id': deliveryId,
},
body: rawBody,
},
)
const receipt = await response.json().catch(() => null)
if (response.status !== 200 || receipt?.received !== true) {
throw Object.assign(
new Error('Charge result not acknowledged; retain for reconciliation'),
{
status: response.status,
receipt,
},
)
}
return { status: 'received' as const, duplicate: receipt.duplicate === true }
}The request is POST /api/memberships/:id/charge-result, and its body membershipId must equal the route ID.
- The signature is HMAC-SHA256 hex over
${timestamp}.${deliveryId}.${rawBody}, using that workspace connection's secret.timestampis a millisecond string; send the exact UTF-8 JSON bytes you signed. All threex-membership-*headers are required. - The ±300-second window checks freshness; on its own it does not stop replays. The server deduplicates by the logical charge and result identity, not by delivery ID alone.
- HMAC authenticates the sender, not the PSP payment. Money must match the saved charge and Membership, and
pgPaymentIdmust agree with any stored provider payment identity.
| Result | Action |
|---|---|
200 { received: true, duplicate?: true } | Receipt only. Check activation separately, below. |
401 bad_signature | Check the route and body identity, the workspace connection secret, the headers, the clock, and the exact bytes. Malformed signed JSON also returns 401. |
400 after verification | Correct the body to the paid or failed shape; do not add fields. |
500 or a lost or unclear response | Keep the same verified result and reconcile or resend it. 500 also covers Money or provider identity mismatches and charge_not_found, so do not assume a temporary storage failure. |
To resend, call reportFirstCharge with the same saved preparation and outcome; a fresh timestamp, delivery ID, and signature do not create a new charge. Never recover by creating a new Membership, charge key, or PSP payment. If the same result keeps being rejected, contact workspace support and reconcile the saved evidence. Late paid evidence after setup expiry or a final state may be kept without activation. A late paid result for a voided charge is retained as unapplied evidence under the same key; refunds stay your app's responsibility outside this API.
Confirm the same Membership
Use the workspace Publishable Key and this customer's JWT (see Customer sessions). me() returns the current entitling or latest visible Membership; it does not look up the prepared ID. Mark this paid activation complete only when the ID matches the saved membershipId and the status is active.
import { createClient } from '@01.software/sdk'
export async function confirmFirstCharge(input: {
apiUrl: string
publishableKey: string
customerJwt: string
membershipId: string
}) {
const client = createClient({
apiUrl: input.apiUrl,
publishableKey: input.publishableKey,
customer: { persist: false },
})
client.customer.auth.setToken(input.customerJwt)
const membership = await client.memberships.me()
return {
activated:
membership?.id === input.membershipId && membership.status === 'active',
membership,
}
}A different ID, null, another status, or a read error leaves this activation unconfirmed. Open that exact saved Membership ID in the Admin Panel to inspect it; do not assume the payment failed or start another charge.
Unprovided paid periods after customer withdrawal
The Admin Panel lists delegated paid periods that require workspace handling. A task records retained charge/refund facts, the actual entitlement end and the unprovided interval separately from account withdrawal time. A never-activated first signup has no actual end and its entire charged period is unprovided. Amounts without sufficient evidence are marked confirmation required.
Use server-only server.memberships.listWithdrawalTasks({ status: 'open' })
and server.memberships.updateWithdrawalTask(id, { status: 'processing', assignee: null }).
Read/write scopes and tenant-admin authority for role-bearing callers are
required. Only assignee and handling status are writable. Changes emit the
workspace-scoped membership-withdrawal-tasks collection event with scalar
membership/charge references and status; no customer contact or receipt token
is included. Use an event to refresh the task, then confirm its saved status
with a new list request. Completing a task never executes a platform refund or
changes the membership/charge ledger; handle any delegated refund through your
own provider.
Task events publish after commit. SSE subscribers with the tenant-viewer role receive only scalar references; role-bearing handling API callers require tenant-admin. Use the membership methods above. The collection is server-only and appears in generic SDK types, but sk requests through the generic collection API return 403.