API documentation

Consuming the VellumCharter API

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.

GET /tenants/{tenantId}/customers/{customerId}/entitlements

Read a customer’s entitlements

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');

Response

{
  "accountId": "acme",
  "productId": "core",
  "planId": "pro",
  "planVersion": 3,
  "status": "active",
  "currentPeriodEnd": "2026-07-01T00:00:00.000Z",
  "modules": ["exports", "reports"],
  "config": { "max_users": 50 },
  "updatedAt": "2026-06-01T12:00:00.000Z"
}

404 — no entitlements (normal; means no access)

{ "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.

POST /tenants/{tenantId}/accounts/{accountId}/checkout

Start hosted checkout

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.

  • customerIdrequired The member who gets the seat.
  • productId The 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.
  • planIdrequired The tier to sell.
  • planVersion Optional 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.
  • seats Number of seats (default 1).
  • addOnModuleIds Priced add-on module ids the tier offers; each is added as its own line item.
  • successUrl / cancelUrlrequired Where the customer is returned after checkout.
const { url } = await vellum.createCheckout({
  accountId: 'acme',
  customerId: 'cust_123',
  productId: 'core',
  planId: 'pro',
  planVersion: 3,
  seats: 2,
  addOnModuleIds: ['cashflower'],
  successUrl: 'https://app.example.com/billing/ok',
  cancelUrl: 'https://app.example.com/billing/cancel',
});
// redirect the browser to `url`

Response

{
  "url": "https://checkout.example.com/pay/abc123",
  "productId": "core",
  "planVersion": 3
}

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." }
POST /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}/subscribe

Subscribe a customer directly

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.

  • productId The Product owning the tier. Optional only as a back-compat shim (see checkout); send it explicitly.
  • planIdrequired The tier to sell.
  • planVersion Optional and recommended to omit — Vellum sells the newest version priced in your key's Stripe mode, and the response echoes what was sold.
  • seats Number of seats (default 1).
  • addOnModuleIds Priced add-on module ids the tier offers; each is added as its own line item.
const { productId, planVersion } = await vellum.createSubscription({
  accountId: 'acme',
  customerId: 'cust_123',
  productId: 'core',
  planId: 'pro',
  planVersion: 3,
  seats: 2,
  addOnModuleIds: ['cashflower'],
});

201 Created

{ "productId": "core", "planVersion": 3 }

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 …" }
POST /tenants/{tenantId}/accounts/{accountId}/billing/setup-checkout

Capture a payment method

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 / cancelUrlrequired Where 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)

Response

{
  "url": "https://checkout.example.com/pay/abc123",
  "productId": "core",
  "planVersion": 3
}
GET /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}/subscription

Get a customer’s subscription

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');

Response

{
  "id": "sub_1a2b3c",
  "status": "active",
  "cancelAtPeriodEnd": false,
  "seats": 2,
  "amount": 1200,
  "currency": "usd",
  "interval": "month",
  "currentPeriodStart": "2026-06-01T00:00:00.000Z",
  "currentPeriodEnd": "2026-07-01T00:00:00.000Z",
  "defaultPaymentMethod": "pm_1a2b3c",
  "defaultPaymentMethodRef": "v1_pm_9f2a7c04KxQeR8VvKpXo1nHzlUemW3sD"
}
DELETE /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}/subscription

Cancel a subscription

Cancels immediately, or at the end of the current period with ?atPeriodEnd=true (the customer keeps access until then).

  • atPeriodEnd Query param; true to cancel at period end (default false).
await vellum.cancelSubscription('acme', 'cust_123', true); // at period end

Response

{ "canceled": "cust_123", "atPeriodEnd": true }
PATCH /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}/subscription/add-ons

Add or remove an add-on module

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).

  • add Module ids to add (string[]).
  • remove Module ids to remove (string[]).
await vellum.addAddOn('acme', 'cust_123', 'simpledocs');
await vellum.removeAddOn('acme', 'cust_123', 'cashflower');

Response

{ "id": "sub_1a2b3c", "status": "active", "cancelAtPeriodEnd": false }
POST /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}/subscription/preview

Preview a change

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.

const q = await vellum.previewChange('acme', 'cust_123', { addOnModuleIds: ['cashflower'] });

Response

{ "immediateCharge": 600, "credit": 0, "nextInvoiceTotal": 900, "currency": "usd", "lines": [] }
PUT /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}/subscription

Set the full subscription state

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.

  • productId Optional — defaults to the subscription's current Product. Pass it only to move across Products.
  • planId / planVersionrequired The target tier version.
  • seats Seat count (defaults to current).
  • addOnModuleIds The complete set of priced add-ons to have.
const sub = await vellum.updateSubscription('acme', 'cust_123', {
  planId: 'hub', planVersion: 1, addOnModuleIds: ['cashflower', 'simpledocs'],
});

Response

{ "id": "sub_1a2b3c", "status": "active", "cancelAtPeriodEnd": false }
POST /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}/subscription/reactivate

Reactivate a canceling subscription

Clears a pending period-end cancellation so the subscription continues on its existing cycle.

await vellum.reactivate('acme', 'cust_123');

Response

{ "id": "sub_1a2b3c", "status": "active", "cancelAtPeriodEnd": false }

Billing

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.

GET /tenants/{tenantId}/accounts/{accountId}/payment-methods

List payment methods

The account’s saved cards, with which one is the default. ref is each card’s only identifier — send it back to the endpoints below.

const methods = await vellum.listPaymentMethods('acme');

Response

{
  "methods": [
    { "id": "pm_1a2b3c", "ref": "v1_pm_9f2a7c04KxQeR8VvKpXo1nHzlUemW3sD",
      "brand": "visa", "last4": "4242",
      "expMonth": 12, "expYear": 2027, "name": "Ada Lovelace", "isDefault": true }
  ]
}
PATCH /tenants/{tenantId}/accounts/{accountId}/payment-methods/{methodId}

Set the default card

Makes a saved card the account’s default for future invoices.

  • methodIdrequired A payment method ref (see List payment methods). Raw billing-provider ids are not accepted and return 404.
await vellum.setDefaultPaymentMethod('acme', 'v1_pm_9f2a7c04KxQeR8VvKpXo1nHzlUemW3sD');

Response

{ "default": "v1_pm_9f2a7c04KxQeR8VvKpXo1nHzlUemW3sD" }
DELETE /tenants/{tenantId}/accounts/{accountId}/payment-methods/{methodId}

Remove a card

Detaches a saved card from the account.

  • methodIdrequired A payment method ref (see List payment methods). Raw billing-provider ids are not accepted and return 404.
await vellum.removePaymentMethod('acme', 'v1_pm_9f2a7c04KxQeR8VvKpXo1nHzlUemW3sD');

Response

{ "removed": "v1_pm_9f2a7c04KxQeR8VvKpXo1nHzlUemW3sD" }
PATCH /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}/payment-method

Set a subscription’s card

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.

  • methodIdrequired A payment method ref (see List payment methods). Raw billing-provider ids are not accepted and return 404.
await vellum.setSubscriptionPaymentMethod('acme', 'cust_123', 'v1_pm_9f2a7c04KxQeR8VvKpXo1nHzlUemW3sD');

Response

{ "subscription": "sub_1a2b3c", "default": "v1_pm_9f2a7c04KxQeR8VvKpXo1nHzlUemW3sD" }
GET /tenants/{tenantId}/accounts/{accountId}/invoices

List invoices

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.

  • customerId Query param; filter to one customer’s subscription.
const invoices = await vellum.listInvoices('acme', 'cust_123');

Response

{
  "invoices": [
    {
      "ref": "v1_in_5vGZ0iN3kq8mQpXeYb1LwTTirQqE7z0h",
      "number": "ACME-0001", "status": "paid",
      "amountDue": 2400, "amountPaid": 2400, "subtotal": 2400,
      "total": 2400, "tax": 0, "paid": true, "currency": "usd",
      "created": "2026-06-01T00:00:00.000Z",
      "hostedInvoiceUrl": "https://invoice.example.com/i/..."
    }
  ]
}
GET /tenants/{tenantId}/accounts/{accountId}/invoices/{invoiceId}/pdf

Get an invoice PDF

Responds with the PDF bytes (application/pdf). The billing provider's hosted-PDF URL is a bearer credential and is never returned.

  • invoiceIdrequired An 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).

  • accountIdrequired Your identifier for the org.
  • name / email Optional billing contact details.
  • createBillingCustomer Create a new billing customer (default false).
  • billingCustomerId Adopt an existing billing customer instead.
const { accountId, billingCustomerId } = await vellum.createAccount({
  accountId: 'acme',
  name: 'Acme, Inc.',
  email: 'billing@acme.test',
  createBillingCustomer: true,
});

201 Created

{ "accountId": "acme", "billingCustomerId": "cus_1a2b3c" }

409 — billingCustomerId conflicts with the one already stored

{ "error": "account acme is already linked to a different billing customer" }
PATCH /tenants/{tenantId}/accounts/{accountId}

Update account status

Set the account’s operational status (e.g. active, suspended).

  • statusrequired The new status string.
await vellum.setAccountStatus('acme', 'suspended');

Response

{ "accountId": "acme", "status": "suspended" }
POST /tenants/{tenantId}/accounts/{accountId}/customers

Create a customer

Adds a customer (member) under an account.

  • customerIdrequired Your identifier for the member.
  • name Optional display name, shown wherever the member is listed.
  • email Optional contact email.
await vellum.createCustomer({
  accountId: 'acme',
  customerId: 'cust_123',
  name: 'Ada Lovelace',
  email: 'ada@acme.test',
});

201 Created

{ "customerId": "cust_123" }
PATCH /tenants/{tenantId}/accounts/{accountId}/customers/{customerId}

Update customer status

Set the customer’s operational status (e.g. active, disabled).

  • statusrequired The new status string.
await vellum.setCustomerStatus('acme', 'cust_123', 'disabled');

Response

{ "customerId": "cust_123", "status": "disabled" }

Credits

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.

POST /tenants/{tenantId}/accounts/{accountId}/credit-checkout

Buy credit packs

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.

  • customerIdrequired Whose wallet receives the credits.
  • creditTypeId Single-pack form: which credit type to buy.
  • packId Single-pack form: the pack SKU.
  • quantity Single-pack form: number of packs (default 1).
  • items Multi-pack form: [{ creditTypeId, packId, quantity? }]. Takes precedence over packId.
  • successUrl / cancelUrlrequired Where the customer is returned after checkout.
// Single pack:
const { url } = await vellum.createCreditCheckout({
  accountId: 'acme',
  customerId: 'cust_123',
  creditTypeId: 'render_minutes',
  packId: 'pack_1000',
  quantity: 2,
  successUrl: 'https://app.example.com/credits/ok',
  cancelUrl: 'https://app.example.com/credits/cancel',
});

// Multi-denomination — several packs in one Checkout:
const { url: cartUrl } = await vellum.createCreditCheckout({
  accountId: 'acme',
  customerId: 'cust_123',
  items: [
    { creditTypeId: 'render_minutes', packId: 'pack_1000', quantity: 2 },
    { creditTypeId: 'seats', packId: 'pack_5' },
  ],
  successUrl: 'https://app.example.com/credits/ok',
  cancelUrl: 'https://app.example.com/credits/cancel',
});

Response

{
  "url": "https://checkout.example.com/pay/abc123",
  "productId": "core",
  "planVersion": 3
}
GET /tenants/{tenantId}/credit-types

List credit types

The catalog of credit types you offer. Use it (with the packs endpoint) to render a self-serve “buy credits” UI without hard-coding ids.

const types = await vellum.listCreditTypes();

Response

{ "creditTypes": [ { "creditTypeId": "render_minutes", "name": "Render minutes" } ] }
GET /tenants/{tenantId}/credit-types/{creditTypeId}/packs

List packs for a credit type

The purchasable packs (denominations) for one credit type, with their grant size and price. Pass a pack’s packId to credit-checkout to buy it.

const packs = await vellum.listCreditPacks('render_minutes');
// packs[0].grantQty, packs[0].amount, packs[0].currency

Response

{ "packs": [ { "creditTypeId": "render_minutes", "packId": "pack_1000", "name": "1,000 minutes", "grantQty": 1000, "amount": 1500, "currency": "usd" } ] }
GET /tenants/{tenantId}/customers/{customerId}/credits

List credit balances

Every credit type the customer holds, with its balance.

const credits = await vellum.listCredits('cust_123');

Response

{ "credits": [ { "creditTypeId": "render_minutes", "balance": 2000 } ] }
GET /tenants/{tenantId}/customers/{customerId}/credits/{creditTypeId}

Get one balance

A single balance. A customer who never bought this type reads as 0 (not a 404), so a gating check has one uniform shape.

const bal = await vellum.getCreditBalance('cust_123', 'render_minutes');
// bal.balance

Response

{ "creditTypeId": "render_minutes", "balance": 2000 }
POST /tenants/{tenantId}/customers/{customerId}/credits/{creditTypeId}/consume

Consume credits

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.

  • amountrequired Credits to deduct (integer ≥ 1).
  • idempotencyKeyrequired Stable 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
  }
}

Response

{ "creditTypeId": "render_minutes", "consumed": 30, "balance": 1970, "duplicate": false }

409 — insufficient credits (nothing deducted)

{ "error": "insufficient credits", "balance": 10 }

Feature flags

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.

GET /tenants/{tenantId}/customers/{customerId}/flags

Feature flags for a customer

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)) { /* ... */ }

Response

{
  "flags": {
    "newDashboard": true,
    "bulkExport": false
  }
}
GET /tenants/{tenantId}/flags

Tenant-global feature flags

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.

const flags = await vellum.globalFlags();

Response

{
  "flags": {
    "newDashboard": false,
    "bulkExport": false
  }
}

Push webhooks

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.canceledA subscription ends.
  • subscription.pausedA subscription is paused.
  • subscription.resumedA paused subscription resumes.
  • 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.

JavaScript / TypeScript

npm install @vellumcharter/sdk — Node 18+.

Python

pip install vellumcharter — Python 3.11+, sync & async.