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.
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
| Resource | Set on create | Update via PATCH | Returned on GET | In webhook payloads |
|---|---|---|---|---|
| Customers | Yes | Yes | Yes | Yes |
| Subscriptions | Yes | Yes | Yes | Yes |
| Orders | Yes | Yes | Yes | Yes |
| Payments | Yes | No | Yes | Yes |
| Carts | Yes | Yes | Yes | Yes |
| Cart line items | Yes | Yes | Yes | Yes |
| Fulfillments | Yes | No | Yes | Yes |
| Checkout sessions | Yes | No | via auth'd GET | No |
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
// 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
// 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
// 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.
// 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.
// 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'],
},
}); | Key | Values | Effect |
|---|---|---|
| fulfillmentType | digital, access_grant, shipment, service, none | Primary hint. digital/access_grant auto-create completed fulfillments after payment. |
| downloadUrl / downloadStorageKey / licenseKey | string | Stored on the digital delivery detail row. |
| downloadLimit / downloadExpiry | number / ISO date | Stored on digital delivery. |
| resourceType / resourceId / permissions | string / string / string[] | Stored on the access grant detail row. |
| requiresFulfillment | false | Marks 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.
| Key | Goes on | Value | Read by |
|---|---|---|---|
taxCategory | Line 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 |
taxIncluded | Line 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 |
transport | Cart (the order copies it), or quote | Seller, Buyer, ThirdPartyForSeller, ThirdPartyForBuyer or None. Overrides the application’s transport responsibility. | Avalara only |
buyerReference | Cart or order. A typed field on quotes, not metadata | string. Filed as the AvaTax purchaseOrderNo, trimmed to 50 characters. | Avalara only |
purchaseOrderNo | Order (or the cart it is created from) | string. Wins over buyerReference when both are set; also trimmed to 50 characters. | Avalara only |
// 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'
},
}); // 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
- Quote → cart, on acceptance. Each line's metadata is copied
whole, so
taxCategoryandtaxIncludedcome with it. The cart line'sreferenceIdis the quote line'sreferenceId, or itsskuwhen it has none. The quote'sbuyerReferencelands oncart.metadata.buyerReference— a value given at acceptance wins. Of the quote's own metadata, onlytransportis carried; any other key, includingpurchaseOrderNo, stays on the quote. - 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. - 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
// 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
// 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):
- Cart metadata — set via
/api/v1/cartsat cart create time. - Session metadata — set via
/api/v1/checkout/sessions. Overwrites colliding keys from the cart. - Customer enrichment — Throttle stamps
customerEmailon the order metadata when the buyer's identity is known. This always wins.
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 asorder.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
PATCHwithmetadata, 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:
# 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.