VellumCharter is the layer between “they paid” and “they can use it.” Your app calls one fast endpoint to check what a customer is allowed to do; the rest of the
API runs checkout, subscriptions, billing, provisioning, prepaid credits, and push webhooks.
Every endpoint below has a JavaScript, Python, and raw-HTTP example — pick a language once and
it applies to the whole page.
Base URL https://api.vellumcharter.com
These are server-side APIs. Your API key carries a secret — keep it on your
backend, never ship it to a browser or mobile client.
Examples in
Authentication
Pass your key in the x-api-key header on every request. Keys look like vlm_<tenantId>.<secret> and are created in your dashboard. A key is
scoped to one tenant — requests for any other tenant’s path get a 403.
Each key is also scoped to a mode (test or live): a test key reads and
writes test data, a live key production. Use a test key in your staging/dev environment and a
live key in production — the API surface is identical, the key picks the data.
Python also ships an AsyncVellumCharterClient with the same methods.
import { VellumCharterClient } from '@vellumcharter/sdk';
const vellum = new VellumCharterClient({
apiKey: process.env.VELLUM_API_KEY!, // vlm_<tenantId>.<secret>
tenantId: 'acme',
// baseUrl defaults to https://api.vellumcharter.com
// cacheTtlMs defaults to 30000 (entitlement reads); 0 disables caching
});
Conventions
Requests and responses are JSON. Path parameters (tenantId, accountId, customerId, …) are your own identifiers. Errors come back
as { "error": "..." } with a matching status code.
StatusMeaning
400Invalid request — bad or missing parameters, malformed JSON.
403Bad API key, or the key’s tenant doesn’t match the path.
404Not found — including an object that exists but isn’t yours (see below). For entitlements it just means “no access”.
409Conflict, e.g. insufficient credits on a consume, or a checkout for a customer who already has a subscription. Nothing changed.
500Server error.
Object ownership. Every id you pass in a billing path — a payment
method, an invoice — must belong to the account named in that path. An id belonging
to another account returns 404, identical to one that doesn’t exist:
we never confirm that someone else’s object is real.
Deprecated ids.PaymentMethod.id, Invoice.id,
and a subscription’s defaultPaymentMethod are deprecated in favor of an
opaque ref (PaymentMethod.ref, Invoice.ref, defaultPaymentMethodRef) — send ref back wherever a payment
method or invoice id is expected. A few other fields — a subscription’s own id, an entitlement’s billingSubscriptionId, and the billingCustomerId / subscriptionId echoed back from account
and subscription creation — are deprecated with no replacement, since the account or
customer id you already hold addresses the same object. All of these, plus Invoice.invoicePdf (superseded by the PDF redirect below), are removed in 1.0.0; raw billing-provider ids are still accepted everywhere in the
meantime.
Subscription status. The status field on entitlements
and subscription responses is a closed, Vellum-owned vocabulary — not Stripe’s raw
status strings — with a boolean hasAccess alongside it. Every value, and
what each one asks you to do, is enumerated in the published spec. Treat a value outside that set
as an error rather than ignoring it: silently no-op'ing an unknown status is how a
contract change becomes an outage nobody sees.
Gate on modules, not on hasAccess.modules is the set the customer may use right now — it is
emptied when the subscription does not grant access, so a module check is already
status-aware. hasAccess is for reporting (a billing banner, an “in good
standing” indicator); gating on it would grant every module to any active
customer. Never match status strings yourself.
A few “not an error” cases worth knowing: a customer with no entitlements returns 404 (the SDKs hand you an empty, no-access set — no null check needed); a
never-purchased credit balance reads as 0, not 404; and a repeated
credit consume with the same idempotencyKey returns 200 with duplicate: true rather than deducting twice.
Entitlements
The hot path. One fast, cached lookup answers “what can this customer do right now?” — already resolved from what they pay for. This is the call your app makes on (almost) every request.
Returns the pre-flattened entitlement blob: which modules are unlocked and the plan config. A customer with no record returns 404 — treat that as “no access”. The SDKs turn it into an empty set so you never null-check.
// One call, instant yes/no — cached ~30s by default.
if (await vellum.can('cust_123', 'exports')) {
// ...let them export
}
// Or read the whole resolved set:
const ent = await vellum.getEntitlements('cust_123');
ent.hasAccess(); // reporting only — not a gate
ent.has('reports'); // module check — gate on this
ent.config('max_users'); // a plan config value
// After a webhook tells you it changed, drop the cache:
vellum.invalidate('cust_123');
{ "tenantId": "acme", "customerId": "cust_123", "error": "no entitlements" }
Checkout & subscriptions
Turn a customer into a paying one and manage their subscription. Hosted checkout returns a URL you redirect to; the server-side variants act directly when the account already has a saved card.
Creates a hosted subscription-checkout session. Redirect the browser to the returned URL; the customer enters payment there. Their entitlements are resolved automatically once the payment completes. A customer gets one subscription per mode: if they already have one that hasn't ended, this returns 409 and creates nothing, because a second one would bill right alongside the first. Change the one they have with Set the full subscription state (or its PATCH slices) instead.
customerIdrequiredThe member who gets the seat.
productIdThe Product owning the tier. Optional only as a back-compat shim: omitted, Vellum resolves it from the catalog and returns 400 if the planId is offered by more than one Product. Send it — the shim is deprecated.
planIdrequiredThe tier to sell.
planVersionOptional and recommended to omit — Vellum sells the newest version priced in your key's Stripe mode. Pinning a version in your deploy config is how a tier edit silently strands you on the superseded, still-charging tier. Pass it only to deliberately sell an older version.
seatsNumber of seats (default 1).
addOnModuleIdsPriced add-on module ids the tier offers; each is added as its own line item.
successUrl / cancelUrlrequiredWhere the customer is returned after checkout.
409 Conflict: the customer already has a subscription
{ "error": "Customer cust_123 already has a subscription that hasn't ended (status: active), and a second one would bill alongside it. Change the existing one instead: PUT /tenants/your-tenant/accounts/acme/customers/cust_123/subscription, or PATCH /tenants/your-tenant/accounts/acme/customers/cust_123/subscription/plan, /tenants/your-tenant/accounts/acme/customers/cust_123/subscription/seats or /tenants/your-tenant/accounts/acme/customers/cust_123/subscription/add-ons." }
Creates the subscription server-side, with no hosted page. The account must already have a saved payment method (see Billing or setup checkout). Honors the plan’s trial period if configured. Same rule as checkout: one subscription per customer, so a customer who already has one that hasn't ended gets a 409.
productIdThe Product owning the tier. Optional only as a back-compat shim (see checkout); send it explicitly.
planIdrequiredThe tier to sell.
planVersionOptional and recommended to omit — Vellum sells the newest version priced in your key's Stripe mode, and the response echoes what was sold.
seatsNumber of seats (default 1).
addOnModuleIdsPriced add-on module ids the tier offers; each is added as its own line item.
409 Conflict: the customer already has a subscription
{ "error": "Customer cust_123 already has a subscription that hasn't ended (status: past_due), and a second one would bill alongside it. Change the existing one instead: PUT …" }
Creates a setup-mode hosted session to save or update a card without charging it. Redirect to the returned URL — useful before a server-side subscribe, or to fix an expired card.
successUrl / cancelUrlrequiredWhere the customer is returned after checkout.
const { url } = await vellum.createSetupCheckout({
accountId: 'acme',
successUrl: 'https://app.example.com/billing/ok',
cancelUrl: 'https://app.example.com/billing/cancel',
});
// redirect to `url` to capture a card (no charge)
The current subscription details — status, seats, price, and period — for building a billing screen. Returns 404 if the customer has no subscription. defaultPaymentMethodRef is byte-identical to the same card’s ref from List payment methods; the subscription itself is addressed by accountId/customerId.
const sub = await vellum.getSubscription('acme', 'cust_123');
Adds or removes priced add-on modules the tier offers, on the live subscription. Proration applies immediately (a small partial-period charge or credit on monthly plans).
Returns the immediate proration charge/credit and next invoice total for a proposed change, before you apply it. Send any subset of the desired state for a what-if; omitted fields keep their current value.
Declarative reconcile: sends the complete intended shape and Vellum makes one Stripe update to match it. Omitting addOnModuleIds means no add-ons; omitting seats keeps the current count. There are also building-block slices: PATCH .../seats and PATCH .../plan.
productIdOptional — defaults to the subscription's current Product. Pass it only to move across Products.
Read-and-manage helpers over the account’s cards and invoices — enough to build a self-serve billing page. The underlying payment platform still owns invoicing, proration, tax, and dunning; these are a thin, camelCase view.
Overrides the default payment method for one customer’s subscription (when it should differ from the account default). Returns the updated subscription view, matching every other subscription mutation.
methodIdrequiredA payment method ref (see List payment methods). Raw billing-provider ids are not accepted and return 404.
Invoices for the account, or scoped to one customer’s subscription with ?customerId=. Amounts are in the smallest currency unit (e.g. cents). ref is each invoice’s only identifier — send it to Get an invoice PDF. hostedInvoiceUrl stays: it is a Stripe-hosted payment page intended to be shared, not an identifier.
customerIdQuery param; filter to one customer’s subscription.
Responds with the PDF bytes (application/pdf). The billing provider's hosted-PDF URL is a bearer credential and is never returned.
invoiceIdrequiredAn invoice ref (see List invoices). Raw billing-provider ids are not accepted and return 404.
const pdf = await vellum.invoicePdf('acme', 'v1_in_5vGZ0iN3kq8mQpXeYb1LwTTirQqE7z0h'); // Uint8Array
302 Found
Location: https://.../invoice.pdf
Provisioning
Register the accounts (orgs) and customers (members) your billing hangs off, and update their lifecycle status. Create the account first, then its customers, then subscribe.
POST/tenants/{tenantId}/accounts
Create an account
Creates a billing account. Optionally create a new billing customer for it (createBillingCustomer) or adopt an existing one (billingCustomerId).
accountIdrequiredYour identifier for the org.
name / emailOptional billing contact details.
createBillingCustomerCreate a new billing customer (default false).
billingCustomerIdAdopt an existing billing customer instead.
Prepaid, pay-as-you-go balances that sit alongside subscriptions. A customer buys a pack with a one-time payment, and you consume credits as a feature is used. Consumption is atomic, all-or-nothing, and idempotent.
Creates a one-time (not recurring) hosted Checkout session to buy one or more prepaid packs. Redirect to the URL; the credits land in the customer’s wallet once payment completes. Buy a single pack with creditTypeId + packId (+ quantity), or pass items to buy several at once — denominations and credit types may be mixed. Buying quantity N of a pack grants its amount × N.
customerIdrequiredWhose wallet receives the credits.
creditTypeIdSingle-pack form: which credit type to buy.
packIdSingle-pack form: the pack SKU.
quantitySingle-pack form: number of packs (default 1).
Atomically deducts credits as a feature is used. Pass a stable idempotencyKey (e.g. the job id) so a retry is a safe no-op — it returns the already-applied balance with duplicate: true. If the balance is insufficient, nothing is deducted and you get a 409.
amountrequiredCredits to deduct (integer ≥ 1).
idempotencyKeyrequiredStable key per logical operation.
try {
const r = await vellum.consumeCredits('cust_123', {
creditTypeId: 'render_minutes',
amount: 30,
idempotencyKey: 'render:job_42',
});
// r.balance, r.duplicate
} catch (e) {
if (e instanceof VellumCharterApiError && e.status === 409) {
// insufficient balance — nothing was deducted
}
}
Feature flags are a second, independent axis. A flag says whether a feature is switched on; entitlements say what was paid for. Vellum never mixes them — evaluating a flag does not consult the customer’s product, tier, modules or subscription. To gate on “paid and enabled”, combine the two in your own code. Flags are mode-scoped, so a test key and a live key see independent values.
The tenant’s global flag values with this customer’s account overrides and then its own overrides applied — most specific wins, so a customer-level setting beats an account-level one, which beats the global. The customerId is a targeting key only. Works regardless of whether the customer has a subscription. Returns an empty object when the tenant defines no flags. Always pass a default when reading a single flag — the SDKs return it both for an undefined flag and for a failed request, so a Vellum outage cannot silently switch your features off.
const flags = await vellum.flags('cust_123');
// Prefer the single-flag accessor: the default is required, and is used
// both for an unknown flag and when the request fails.
if (await vellum.flag('newDashboard', 'cust_123', false)) { /* ... */ }
The global values with no customer applied — for a code path with no logged-in user in hand, such as a scheduled job or a queue consumer. Prefer the customer-scoped route wherever you have a customer, so account and customer overrides apply. Returns an empty object when the tenant defines no flags.
Pull stays the default: your app reads the entitlements endpoint and caches the
result. Push is for tenants who need to react immediately instead of waiting on
that cache. VellumCharter posts an event to your tenant’s pushUrl whenever a subscription’s lifecycle moves, or an invoice succeeds, fails, or needs
action. You don’t set that URL yourself: it lives on your tenant record along with
the signing secret, so ask VellumCharter to configure it.
Signature. Every push carries a vellumcharter-signature header in the form t=<unix>,v1=<hex>. v1 is
HMAC-SHA256, keyed with your push secret, over `${t}.${rawBody}`: the
timestamp and the exact request body, joined by a period. An unsigned or mismatched
payload isn’t a VellumCharter event. Verify it before you act on it.
Verify before trusting it
Recompute the HMAC yourself and compare it to v1 before reading
anything out of the payload. Pass the exact bytes VellumCharter sent as the body,
not a value your framework has already parsed into an object: re-serializing JSON
doesn’t reliably reproduce the original bytes, so a genuine body can fail the
check anyway. Both SDKs verify and parse in one call.
Shown in both languages: this runs in your own server code, not as an API call, so there’s no cURL equivalent.
JavaScript
import { verifyPushSignature } from '@vellumcharter/sdk';
const event = verifyPushSignature(rawBody, req.headers['vellumcharter-signature'], secret);
if (await alreadyProcessed(event.eventId)) return res.status(200).end();
Python
from vellumcharter import verify_push_signature
event = verify_push_signature(raw_body, request.headers["vellumcharter-signature"], secret)
Event types. A closed set. type is always one of these:
subscription.createdA new subscription starts.
subscription.updatedAn existing subscription changes: plan, seats, add-ons, or status.
subscription.trial_will_endA trial is ending soon.
payment.succeededAn invoice is paid. See the caveat below before treating this as revenue.
payment.failedAn invoice payment attempt fails.
payment.action_requiredAn invoice needs the customer to complete authentication before it can be paid.
De-duplicate on eventId. It’s the id of VellumCharter’s
own log row for that delivery, stable across every retry of it. VellumCharter attempts
a push up to three times, with no delay between attempts, and does not retry beyond
that. A delivery your handler already processed can still arrive again inside that
burst, for instance if it succeeded but the acknowledgment was lost, so a handler that
emails on every delivery instead of checking eventId first can send that
email three times for one event. If your endpoint is down for all three attempts,
VellumCharter does not retry later and the event is dropped. The entitlements endpoint
reflects the same underlying state, so re-read it there if you need to recover from a
gap.
payment.succeeded isn’t always revenue. A trial start
or a trial extension produces a genuinely paid invoice for $0, so the event fires
with invoice.amountPaid at 0. Check invoice.amountPaid > 0 before treating a payment.succeeded push as revenue, a receipt, or a reason to fulfill
an order.
SDKs & next steps
The thin, dependency-free SDKs wrap every endpoint above (server-side only). Or call the API
directly — it’s plain JSON over HTTPS.