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: 'jane@example.com',
    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.

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.

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: 'gr4vy',
    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: 'jane@example.com' },
  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. 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.

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.