Cross-cutting

Metadata

Every major resource accepts a free-form metadata field — a JSON object you attach to capture your own business state. Throttle stores it as jsonb, returns it on every read, and emits it in webhook payloads. Outside documented automation keys, Throttle treats it as opaque storage.

The promise
Metadata comes back out unchanged. A small set of documented line-item keys also drives fulfillment automation, such as fulfillmentType for digital delivery or access grants. Use the rest to bridge Throttle's data model with your own — sales rep IDs, campaign codes, contract numbers, feature flags, merchant notes.

Resources that accept metadata

ResourceSet on createUpdate via PATCHReturned on GETIn webhook payloads
CustomersYesYesYesYes
SubscriptionsYesYesYesYes
OrdersYesYesYesYes
PaymentsYesNoYesYes
CartsYesYesYesYes
Cart line itemsYesYesYesYes
FulfillmentsYesNoYesYes
Checkout sessionsYesNovia auth'd GETNo

Update support is missing on payments and checkout sessions because those are short-lived single-use records — payments capture once, sessions expire in 30 minutes. If you need to mutate metadata on them, do it at create time.

Customers

ts
// Customers — use the REST API until a focused customer SDK is published.
const create = await fetch('https://api.usethrottle.dev/api/v1/customers', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.THROTTLE_API_KEY!,
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    email: '[email protected]',
    externalId: 'user_42',
    metadata: {
      plan: 'enterprise',
      cohort: 'q2-2026',
      referredBy: 'partner_x',
    },
  }),
});
const { data: customer } = await create.json();
// Returned on every read:
const { data: fetched } = await fetch(`https://api.usethrottle.dev/api/v1/customers/${customer.id}`, {
  headers: { 'x-api-key': process.env.THROTTLE_API_KEY! },
}).then((res) => res.json());
console.log(fetched.metadata.plan); // 'enterprise'

Subscriptions

ts
// Subscriptions
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
  apiKey: process.env.THROTTLE_API_KEY!,
});
const now = new Date();
const end = new Date(now);
end.setMonth(end.getMonth() + 1);
const sub = await subscriptions.create({
  externalCustomerId: 'user_42',
  planReference: 'pro_monthly',
  interval: 'monthly',
  amount: 2999,
  currentPeriodStart: now.toISOString(),
  currentPeriodEnd: end.toISOString(),
  metadata: {
    salesRepId: 'rep_99',
    contractId: 'con_2026_xyz',
    region: 'EMEA',
  },
});
// Update at any time via PATCH:
await subscriptions.update(sub.id, {
  metadata: { ...sub.metadata, contractRenewedAt: '2026-05-03' },
});

Carts and line items

ts
// Carts AND cart line items
import { CartClient } from '@usethrottle/cart';
const carts = new CartClient({
  apiKey: process.env.THROTTLE_API_KEY!,
});
const cart = await carts.carts.create({
  applicationId,
  metadata: { source: 'mobile_app', utmCampaign: 'summer_sale' },
});
await carts.items.add(cart.id, {
  type: 'subscription',
  name: 'Pro Plan',
  unitPrice: 2999,
  quantity: 1,
  metadata: {
    sku: 'PRO-001',
    catalogVersion: '2026-04',
    fulfillmentType: 'access_grant',
    resourceType: 'plan',
    resourceId: 'pro_monthly',
    permissions: ['read'],
    requiresShipping: false,
  },
});

Fulfillment hints on line items

When an order is paid and in processing, Throttle reads line-item fulfillment hints. Digital and access-grant items are fulfilled automatically; shipment and service items stay manual in the dashboard. Net30 invoices wait for capture before delivery.

Cart items and order line items accept an explicit deliveryMethod field: shipment, digital, access_grant, service, in_person, custom, or none. This is the preferred way to say what an item needs — set it directly and skip the metadata hints below entirely.

ts
// Preferred: set delivery method explicitly, no metadata hint needed.
await carts.items.add(cart.id, {
  type: 'product',
  name: 'Gift card',
  unitPrice: 5000,
  quantity: 1,
  deliveryMethod: 'digital',
});
// Resolved once at insert time and frozen on the row — later edits to
// metadata or type do not change it after the fact.

Omit deliveryMethod and Throttle resolves it once, at insert time, from type and the metadata hints below — then freezes the result on the row. It is not re-resolved on later updates, so changing an item's metadata after creation does not change its delivery method.

Every order read returns the resolved value on each line item, so you never have to re-derive it: an order whose items are all digital, access_grant or none has nothing to ship and reaches fulfilled without a fulfillment of your making.

ts
// Digital product: auto-fulfilled after payment.
await carts.items.add(cart.id, {
  type: 'product',
  name: 'Design System Kit',
  unitPrice: 4900,
  quantity: 1,
  metadata: {
    sku: 'DS-KIT-2026',
    fulfillmentType: 'digital',
    downloadUrl: 'https://cdn.example.com/design-system-kit.zip',
    downloadLimit: 3,
    downloadExpiry: '2026-12-31T23:59:59.000Z',
    requiresShipping: false,
  },
});
// Access product: auto-granted after payment.
await carts.items.add(cart.id, {
  type: 'subscription',
  name: 'Pro Plan',
  unitPrice: 2999,
  quantity: 1,
  metadata: {
    fulfillmentType: 'access_grant',
    resourceType: 'plan',
    resourceId: 'pro_monthly',
    permissions: ['read'],
  },
});
KeyValuesEffect
fulfillmentTypedigital, access_grant, shipment, service, nonePrimary hint. digital/access_grant auto-create completed fulfillments after payment.
downloadUrl / downloadStorageKey / licenseKeystringStored on the digital delivery detail row.
downloadLimit / downloadExpirynumber / ISO dateStored on digital delivery.
resourceType / resourceId / permissionsstring / string / string[]Stored on the access grant detail row.
requiresFulfillmentfalseMarks the item as not applicable for fulfillment.

Defaults are conservative: unmarked products are shipments, subscriptions and tickets are access grants, services stay manual, and donations are not applicable. For older integrations, requiresShipping: false is treated as digital unless requiresFulfillment: false is set.

Tax keys

A handful of metadata keys feed tax calculation and, with Avalara connected, the document Throttle files. They have no typed field of their own: the SDKs type metadata as Record<string, unknown> and the API does not validate them. A misspelled key or a value of the wrong type is silently ignored, and the line is taxed as if you had not sent it. Copy the names exactly.

KeyGoes onValueRead by
taxCategoryLine item (cart, order, quote)string. Native engine: a rule category — standard, digital, service, shipping or exempt. Avalara: an AvaTax tax code, such as P0000000 or PC040100. Absent means standard.Both engines, with a different meaning in each
taxIncludedLine item (cart, order, quote)boolean. true: the price already contains tax. false: it does not. Overrides the application’s tax pricing for that line, in either direction; absent follows the application.Both engines
transportCart (the order copies it), or quoteSeller, Buyer, ThirdPartyForSeller, ThirdPartyForBuyer or None. Overrides the application’s transport responsibility.Avalara only
buyerReferenceCart or order. A typed field on quotes, not metadatastring. Filed as the AvaTax purchaseOrderNo, trimmed to 50 characters.Avalara only
purchaseOrderNoOrder (or the cart it is created from)string. Wins over buyerReference when both are set; also trimmed to 50 characters.Avalara only
ts
// Avalara connected (taxMode "app_based").
const cart = await carts.carts.create({
  applicationId,
  metadata: {
    transport: 'Seller',        // exact spelling — case-sensitive
    buyerReference: 'PO-1042',  // filed as the AvaTax purchaseOrderNo
  },
});
await carts.items.add(cart.id, {
  type: 'product',
  referenceId: 'WW-40',         // your SKU, sent as the AvaTax itemCode. Not `sku`.
  name: 'Wide widget',
  unitPrice: 1500,
  quantity: 200,
  metadata: {
    taxCategory: 'P0000000',    // an AvaTax tax code
    taxIncluded: false,         // a boolean, not the string 'false'
  },
});
ts
// Native engine (taxMode "calculated"): taxCategory names a rule category.
await carts.items.add(cart.id, {
  type: 'product',
  referenceId: 'EBOOK-7',
  name: 'Field guide (PDF)',
  unitPrice: 2900,
  metadata: { taxCategory: 'digital' },
});

taxCategory means two different things. With the native engine it is matched against the categories on your tax rules: an exempt line is never taxed, and a value no rule lists — including a different case, such as Digital — is taxed only by a rule covering all categories, otherwise not at all. With Avalara it is sent as the line's AvaTax tax code. Native category names are never sent to Avalara, regardless of case: a line carrying standard or exempt falls back to the application's default item tax code. So exempt does not exempt a line under Avalara — send the AvaTax code for the treatment you mean. How codes resolve, and why an item in your AvaTax catalogue beats them, is in the Avalara AvaTax setup guide.

taxIncluded must be a JSON boolean. The string "true" is ignored, and the line follows the application setting.

transport is case-sensitive. A value outside the five above is never sent to AvaTax, because AvaTax rejects the whole transaction on one. Both the estimate and the filed document use the application default in its place, so a misspelling silently becomes the default. Set it on the cart or the checkout session, not only on the order: the estimate is calculated before the order exists and reads the cart with the session's metadata over it, and the order copies that same metadata for the filed document. The native engine ignores it.

Use the camelCase key. Tax calculation and the filed document also accept tax_category and tax_included, but these are kept for older integrations; new code should send taxCategory and taxIncluded.

The item identifier is referenceId, not sku

AvaTax files each line under an item code, and Throttle sends the line's referenceId as that code. Carts and orders have no sku field: their line schemas are strict, so a top-level sku is rejected with a validation error. When a line has no referenceId, the estimate and the filed document both fall back to metadata.sku, but send referenceId: it is the field the order page shows and the one every integration reads. Quote lines are the exception: they take both sku and referenceId.

How the keys travel from a quote to an order

  1. Quote → cart, on acceptance. Each line's metadata is copied whole, so taxCategory and taxIncluded come with it. The cart line's referenceId is the quote line's referenceId, or its sku when it has none. The quote's buyerReference lands on cart.metadata.buyerReference — a value given at acceptance wins. Of the quote's own metadata, only transport is carried; any other key, including purchaseOrderNo, stays on the quote.
  2. Cart → order. The order's metadata starts from the cart's (the merge order is under On checkout sessions, below), and each line keeps its metadata and referenceId.
  3. Estimate and filing. Tax is estimated from the cart and filed from the order. Because the order inherits the cart's keys, the buyer is charged on the same answers the document records.

Avalara reads more per-order keys than these — shippingTaxCode and taxTrigger among them. They are covered in Overriding an application default on one order .

Orders and payments

ts
// Orders + payments
// Orders persist metadata you set on them. Auto-created from a checkout
// session inherit metadata you stamped on the cart.
await fetch(`https://api.usethrottle.dev/api/v1/orders/${orderId}`, {
  method: 'PATCH',
  headers: {
    'x-api-key': process.env.THROTTLE_API_KEY!,
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    metadata: {
      fulfillmentVendor: 'shipstation',
      merchantNotes: 'priority',
    },
  }),
});
// Payments accept metadata only at create-time today (no PATCH endpoint).
await fetch(`https://api.usethrottle.dev/api/v1/orders/${orderId}/payments`, {
  method: 'POST',
  headers: {
    'x-api-key': process.env.THROTTLE_API_KEY!,
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    amount: 2999,
    currency: 'USD',
    method: 'embedded',
    metadata: { processedBy: 'rep_99', urgentRefund: false },
  }),
});

Checkout sessions

ts
// Checkout sessions
// Set metadata at session create time. The recurring intent is itself
// stored under metadata.recurring; you can stack additional fields
// alongside it.
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
  apiKey: process.env.THROTTLE_API_KEY!,
});
await subscriptions.createCheckoutSession({
  applicationId,
  externalCartId,
  customer: { externalCustomerId: 'user_42', email: '[email protected]' },
  recurring: { plan: 'pro_monthly', interval: 'monthly', amount: 2999 },
  metadata: {                              // sibling to recurring
    landingPage: '/pricing/pro',
    referralCode: 'FRIEND10',
  },
  returnUrl: 'https://shop.example/success',
  cancelUrl: 'https://shop.example/pricing',
});

The recurring block is stored under metadata.recurring for the auto-create flow. You can stack your own metadata alongside without conflict.

On checkout sessions

Session metadata is capped at 50 keys and a serialized payload of 10KB. Requests over either limit return 422 metadata_too_large.

When a checkout session converts into an order, Throttle merges metadata from three sources with this precedence (later sources win on key collisions):

  1. Cart metadata — set via /api/v1/carts at cart create time.
  2. Session metadata — set via /api/v1/checkout/sessions. Overwrites colliding keys from the cart.
  3. Customer enrichment — Throttle stamps customerEmail on the order metadata when the buyer's identity is known. This always wins.
Reserved keys are stripped server-side
The following keys are reserved by Throttle and are stripped from the metadata bag before persistence (they are not rejected — the rest of the payload is accepted): recurring, customer_prefill, mode, amount, currency, externalCartId, subscriptionLines. Use top-level request fields for these instead of stuffing them into metadata.

On webhooks

User-attached metadata rides through to outbound webhook payloads. The following events now surface the merged order/session/subscription metadata bag on their data.metadata field:

  • order.created — carries the merged cart < session < {customerEmail} bag.
  • payment.captured — carries the parent order's metadata (same as order.created).
  • subscription.created — carries the subscription's metadata, which inherits from the originating session.

Use the metadata bag in your handler to attribute the event to your own records (campaign IDs, sales rep IDs, contract numbers) without a follow-up GET.

Conventions

  • Keep keys snake_case or camelCase consistently. Throttle preserves whatever you send. Pick one and stick with it.
  • Don't store secrets. Metadata is returned on GET and emitted in webhook payloads. Treat it as semi-public.
  • Update via merge, not replace. When you PATCH with metadata, you replace the entire object. Read the existing object, spread it, set your new keys, then PATCH.
  • Size limits are enforced. Keep metadata under 50 keys and 10KB. Use it for IDs and flags, not for dumping payloads.

Reserved key: subscription_id on orders

Throttle writes one metadata key of its own: metadata.subscription_id, pointing at the subscription that generated the order. Renewal and plan-change orders always carry it.

Throttle links an order to its subscriptions on the order's lines ( lineItems[].subscriptionId) and lists them on subscriptionIds. On a signup order, metadata.subscription_id is still written, for integrations that read it, only when the order has exactly one subscription. An order whose checkout created several has no pointer and a null subscriptionId: read subscriptionIds.

Filter on it with the dedicated query param rather than the fuzzy search, which also matches an id sitting under any other key:

bash
# Every order this subscription billed
curl -s "https://api.usethrottle.dev/v1/orders?subscriptionId=$SUB_ID" \
  -H "X-API-Key: $THROTTLE_SECRET_KEY"

# Matches the line link (lineItems[].subscriptionId), the order's subscriptionId,
# or the legacy metadata.subscription_id pointer. Prefer it over q=$SUB_ID, which
# substring-matches the whole metadata blob and can return unrelated orders.

If you charge a subscription's first period yourself and create the subscription afterwards, pass orderId on POST /v1/subscriptions and Throttle stamps the pointer on that order for you. Without it, that order stays unlinked — see Creating Subscriptions . Do not write subscription_id by hand, and do not overwrite it: the dashboard and the subscriptionId filter both read it.

Dashboard exposure

Today the merchant dashboard surfaces metadata as a raw JSON viewer on order detail pages. For customers, subscriptions, and carts, use the API and SDK responses as the source of truth for now.

Next

  • API Reference — full shape of every endpoint that accepts metadata.