SKILL.md — it includes a complete, copy-and-adapt reference client in TypeScript.
How to use a skill
Option A — install with the skills CLI (recommended):npx skills add https://docs.numeralhq.com
- Open the raw
SKILL.mdlink below and save it. - Drop it into your project’s
.claude/skills/<skill-name>/directory (or attach it to your Claude conversation). - Ask your agent to build the integration — it will read the skill and follow the patterns automatically.
The skills reference a
numeral-api-docs MCP server as the live source of truth for request/response shapes. That tool is optional — if your agent doesn’t have it, the patterns still apply and the agent can verify shapes against this API reference instead.Integrating the Numeral tax API
The general-purpose integration skill. Use it for any custom checkout, invoicing, B2B quoting tool, in-house subscription billing, or marketplace that callsapi.numeralhq.com directly. Covers the full calculate / commit / refund / customer lifecycle, idempotent commits, reference_* ID selection, minor-unit amounts, and the most common integration mistakes.
Get the skill
SKILL.md — includes the full numeral-client.example.ts reference client inline
View full skill (SKILL.md)
View full skill (SKILL.md)
---
name: integrating-numeral-tax-api
description: Use when integrating Numeral's tax API (api.numeralhq.com) into a checkout, invoice, subscription, marketplace, or order-management flow. Covers the calculate / commit / refund / customer lifecycle and the integration pitfalls that cause duplicate transactions, wrong amounts, or under-collection. Read before writing any client code against the Numeral tax API.
---
# Integrating Numeral's Tax API
## Overview
Numeral computes sales tax / VAT / GST for orders. The integration lifecycle:
1. **Calculate** — preview tax for a cart (`POST /tax/calculations`)
2. **Commit** — record the calculation as a permanent transaction after payment (`POST /tax/transactions`)
3. **Refund** — reverse part or all of a committed transaction (`POST /tax/refunds`)
4. **Customer (optional)** — pre-store exemption status (`POST /tax/customers`); tax_ids travel on each calculation, not on the customer
Base URL: `https://api.numeralhq.com`. Auth: `Authorization: Bearer <key>`. Version header: `X-API-Version: 2026-03-01` — pin it and bump deliberately. Before shipping, confirm via the MCP (next section) that `2026-03-01` is still the latest stable version, and that the request/response shapes you're coding against are the ones documented at that version.
## Source of truth: the numeral-api-docs MCP
This skill teaches the patterns and pitfalls. For the live, field-by-field request/response shape of any endpoint, **query the `numeral-api-docs` MCP server**. Never invent field names or guess shapes from training data — the MCP stays in sync with API releases.
```
mcp__numeral-api-docs__authenticate
mcp__numeral-api-docs__complete_authentication
```
Workflow: check the MCP **before** writing the call, and again whenever an unexpected error appears. When you query the MCP, explicitly request schemas at the version you've pinned in `X-API-Version` (currently `2026-03-01`) — don't accept whatever the MCP defaults to, since defaults can lag. If the MCP reports a newer stable version than what's pinned here, the skill is out of date — flag it before relying on the response shapes in `numeral-client.example.ts`.
## Before writing any code — ask which connector is already in use
Numeral ships first-party integrations for Stripe, Shopify, Amazon, Walmart, and others. If one is in use, the connector is **already calling calculate/commit on the integrator's behalf**. Adding direct API calls on top will cause duplicate transactions.
Ask the integrator: *"Which Numeral connector does your company already use, and what flow am I covering that isn't already covered?"*
Good fits for direct API calls: custom checkout, invoicing not on Stripe, B2B quoting tools, in-house subscription billing, marketplaces. Bad fits: anything Stripe Checkout / Stripe Invoicing / Shopify already handles end-to-end.
## A complete reference client
The reference client below implements calculate, idempotent commit, lookup, full + partial refund, and customer creation in ~300 lines of TypeScript. **Copy and adapt it** — don't rewrite from scratch. If the integrator's stack is Python/Ruby/Go, port the patterns; the shapes are identical. (In the downloadable bundle this also ships as `numeral-client.example.ts` next to this file — same content.)
<!-- BEGIN numeral-client.example.ts (auto-embedded; keep in sync with the .ts file) -->
```typescript
// Reference Numeral tax-API client. Copy into the integrator's codebase and adapt.
// Demonstrates the patterns documented in SKILL.md:
// - Bearer auth + X-API-Version header
// - Idempotent commit on reference_order_id with duplicate-error recovery
// - Refund amounts as negative minor units
// - Customer pre-creation for exemption status
//
// Amounts are minor units (cents for USD/EUR/GBP/CAD, whole units for JPY/KRW)
// and are PER-UNIT: the taxable base for a line is amount x quantity.
// Three $25.00 items => amount: 2500, quantity: 3 (NOT amount: 7500).
//
// Shapes below match API version 2026-03-01. For the live request/response
// shape of each endpoint, query the numeral-api-docs MCP server and confirm
// before shipping that 2026-03-01 is still the latest stable version.
const NUMERAL_BASE = "https://api.numeralhq.com/tax"
const API_VERSION = "2026-03-01"
// Every line item needs reference_product_id or product_category (or both).
export interface CalculationLineItemRequest {
reference_line_item_id?: string
reference_product_id?: string
product_category?: string
// Used when reference_product_id doesn't exist in Numeral yet.
fallback_product_category?: string
// Per-unit price in minor units. Taxable base = amount x quantity.
amount: number
quantity: number
}
// origin_address uses exactly this shape. address_province is required (a
// 2-letter ISO 3166-2 subdivision code; USPS state code for US).
export interface NumeralAddress {
address_line_1: string
address_line_2?: string
address_city: string
address_province: string
address_postal_code: string
address_country: string
}
// customer.address additionally carries address_type. Never put address_type
// on origin_address — that returns a 400 UNRECOGNIZED_FIELD.
export interface CustomerAddressRequest extends NumeralAddress {
address_type: "shipping" | "billing"
}
// Stripe-style country-specific type codes: "us_ein", "eu_vat", "gb_vat",
// "au_abn", "ca_gst_hst", ... Full list: /essentials/tax-ids in the docs.
export interface TaxIdRequest {
type: string
value: string
}
export interface CalculationCustomerRequest {
// Numeral customer id. If provided it must already exist (POST
// /tax/customers), unless default_customer_type is set on the request.
id?: string
// Defaults to CONSUMER when omitted.
type?: "BUSINESS" | "CONSUMER"
// Required for BUSINESS customers outside the US. Sent per calculation —
// customers do not store tax_ids.
tax_ids?: TaxIdRequest[]
// Provide address, ip, or both (address wins, IP is the fallback).
address?: CustomerAddressRequest
ip?: {
value: string
resolution?: "strict" | "zero" | "approximate" | "best_effort"
}
}
export interface CalculationRequest {
customer: CalculationCustomerRequest
// Ship-from address. Required unless a default origin address is configured
// on the account for the destination jurisdiction — omitting it with no
// default configured returns a 400 MISSING_ORIGIN_ADDRESS.
origin_address?: NumeralAddress
order_details: {
customer_currency_code: string
tax_included_in_amount: boolean
// Required. Must be "auto" for US addresses.
automatic_tax: "auto" | "disabled"
line_items: CalculationLineItemRequest[]
}
// Unix timestamp in SECONDS of when the sale occurred (defaults to now).
transacted_at?: number
// Fallback type when customer.id doesn't exist in Numeral yet.
default_customer_type?: "BUSINESS" | "CONSUMER"
}
export interface CalculationResponse {
id: string
object: "tax.calculation"
// Unix seconds. Calculations expire 24 hours after creation.
expires_at: number
total_amount_excluding_tax: number
total_tax_amount: number
total_amount_including_tax: number
line_items: Array<{
line_item_id: string
product: { reference_line_item_id?: string; reference_product_id?: string }
amount_excluding_tax: number
tax_amount: number
amount_including_tax: number
quantity: number
}>
}
export interface TransactionResponse {
id: string
object: "tax.transaction"
calculation_id: string
reference_order_id: string
reference_payment_id?: string
transaction_processed_at: number
testmode: boolean
line_items: unknown[]
}
export interface CommitResult {
transactionId: string
wasDuplicate: boolean
}
// 2026-01-01+ error format: a flat object, no nested `error` wrapper.
interface NumeralErrorBody {
code?: number
type?: string
message?: string
}
// PII-safe error: never embeds the raw response body, which can contain
// customer addresses, tax_ids, and other sensitive fields. Reads only the
// Numeral error type, the HTTP status, and the x-request-id (for support).
async function throwSanitized(op: string, res: Response): Promise<never> {
const body = (await res.json().catch(() => ({}))) as NumeralErrorBody
throw new Error(
`${op} failed status=${res.status} type=${body.type ?? "unknown"} request_id=${res.headers.get("x-request-id") ?? "unknown"}`,
)
}
export class NumeralClient {
private readonly headers: Record<string, string>
constructor(args: { apiKey: string }) {
this.headers = {
Authorization: `Bearer ${args.apiKey}`,
"Content-Type": "application/json",
"X-API-Version": API_VERSION,
}
}
async createCalculation(
body: CalculationRequest,
): Promise<CalculationResponse> {
const res = await fetch(`${NUMERAL_BASE}/calculations`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
})
if (!res.ok) await throwSanitized("createCalculation", res)
return (await res.json()) as CalculationResponse
}
// Commits a calculation as a permanent transaction. Safe to retry: if the
// same reference_order_id was already committed (e.g. webhook replay,
// worker retry), looks up and returns the existing transaction instead of
// creating a duplicate. This is the critical safety pattern — never replace
// it with a plain POST.
async commitTransactionIdempotent(args: {
calculationId: string
referenceOrderId: string
referencePaymentId?: string
// Unix timestamp in SECONDS of the sale (defaults to now).
transactionProcessedAt?: number
}): Promise<CommitResult> {
const createRes = await fetch(`${NUMERAL_BASE}/transactions`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({
calculation_id: args.calculationId,
reference_order_id: args.referenceOrderId,
reference_payment_id: args.referencePaymentId,
transaction_processed_at: args.transactionProcessedAt,
}),
})
if (createRes.ok) {
const tx = (await createRes.json()) as TransactionResponse
return { transactionId: tx.id, wasDuplicate: false }
}
const errBody = (await createRes.json().catch(
() => ({}),
)) as NumeralErrorBody
const isDuplicate =
errBody.type === "REFERENCE_ORDER_ID_ALREADY_EXISTS" ||
errBody.type === "TRANSACTION_ALREADY_EXISTS"
if (!isDuplicate) {
// errBody is already consumed above for duplicate detection; format
// inline from the extracted type rather than re-reading the response.
throw new Error(
`commitTransaction failed status=${createRes.status} type=${errBody.type ?? "unknown"} request_id=${createRes.headers.get("x-request-id") ?? "unknown"}`,
)
}
const lookupRes = await fetch(
`${NUMERAL_BASE}/transactions?reference_order_id=${encodeURIComponent(args.referenceOrderId)}`,
{ headers: this.headers },
)
if (!lookupRes.ok) await throwSanitized("lookupByReferenceOrderId", lookupRes)
const lookup = (await lookupRes.json()) as {
transactions: TransactionResponse[]
}
const existing = lookup.transactions[0]
if (!existing) {
throw new Error(
`expected transaction with reference_order_id=${args.referenceOrderId} but lookup returned none`,
)
}
return { transactionId: existing.id, wasDuplicate: true }
}
async getTransaction(id: string): Promise<TransactionResponse> {
const res = await fetch(`${NUMERAL_BASE}/transactions/${id}`, {
headers: this.headers,
})
if (!res.ok) await throwSanitized("getTransaction", res)
return (await res.json()) as TransactionResponse
}
async refundFull(transactionId: string): Promise<unknown> {
const res = await fetch(`${NUMERAL_BASE}/refunds`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({ transaction_id: transactionId, type: "full" }),
})
if (!res.ok) await throwSanitized("refundFull", res)
return await res.json()
}
// Partial-refund line items are matched to the original transaction's
// lines by, in priority order: line_item_id (Numeral's li_...), then
// reference_line_item_id, then reference_product_id. Send at least one.
//
// NOTE: sales_amount_refunded must be strictly NEGATIVE and
// tax_amount_refunded must be <= 0. Positive values do not behave the way
// you'd expect.
async refundPartial(args: {
transactionId: string
lineItems: Array<{
line_item_id?: string
reference_line_item_id?: string
reference_product_id?: string
sales_amount_refunded: number
tax_amount_refunded: number
quantity: number
}>
}): Promise<unknown> {
// Fail fast on the most common mis-uses rather than silently producing
// wrong reconciliation in Numeral.
for (const item of args.lineItems) {
const lineRef =
item.line_item_id ??
item.reference_line_item_id ??
item.reference_product_id
if (!lineRef) {
throw new Error(
"refundPartial: each line item needs line_item_id, reference_line_item_id, or reference_product_id",
)
}
if (item.sales_amount_refunded >= 0 || item.tax_amount_refunded > 0) {
throw new Error(
`refundPartial: sales_amount_refunded must be negative and tax_amount_refunded must be <= 0 (got sales=${item.sales_amount_refunded}, tax=${item.tax_amount_refunded} for line ${lineRef})`,
)
}
}
const res = await fetch(`${NUMERAL_BASE}/refunds`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({
transaction_id: args.transactionId,
type: "partial",
line_items: args.lineItems,
}),
})
if (!res.ok) await throwSanitized("refundPartial", res)
return await res.json()
}
// Pre-create a customer to store exemption status. POST /tax/customers is
// create-only — repeating it for the same customer returns a 400
// DUPLICATE_CUSTOMER, so call it once per customer. Customers do NOT store
// tax_ids or addresses; send those on each calculation instead.
async createCustomer(body: {
email: string
name?: string
is_tax_exempt?: boolean
reference_customer_id?: string
}): Promise<{
id: string
object: "tax.customer"
reference_customer_id?: string
name?: string
email: string
is_tax_exempt?: boolean
}> {
const res = await fetch(`${NUMERAL_BASE}/customers`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
})
if (!res.ok) await throwSanitized("createCustomer", res)
return (await res.json()) as {
id: string
object: "tax.customer"
reference_customer_id?: string
name?: string
email: string
is_tax_exempt?: boolean
}
}
}
```
<!-- END numeral-client.example.ts -->
## Patterns to follow
### 1. Idempotent commit (the single highest-stakes pattern)
Commits are triggered from webhooks or background workers — both retry. Re-committing the same order creates duplicate transactions, which means over-reported tax in filings.
Always commit on a stable `reference_order_id` (your order id, not Numeral's). If Numeral returns a duplicate error, recover by looking up the existing transaction:
```typescript
const res = await fetch(`${BASE}/tax/transactions`, {
method: "POST", headers,
body: JSON.stringify({ calculation_id, reference_order_id }),
})
if (res.ok) return await res.json()
// 2026-01-01+ errors are FLAT: { code, type, message } — no nested `error`
// object, and `type` is SCREAMING_SNAKE_CASE.
const err = await res.json()
if (err.type === "REFERENCE_ORDER_ID_ALREADY_EXISTS" || err.type === "TRANSACTION_ALREADY_EXISTS") {
const lookup = await fetch(
`${BASE}/tax/transactions?reference_order_id=${encodeURIComponent(reference_order_id)}`,
{ headers },
).then((r) => r.json())
return lookup.transactions[0]
}
// Sanitised — never embed the parsed body itself, which can contain PII (see Common Mistakes).
throw new Error(`commit failed status=${res.status} type=${err.type ?? "unknown"}`)
```
**Anti-pattern — do not transfer Stripe muscle memory:** Numeral does **not** support an `Idempotency-Key` header. Sending one is silently ignored and the double-commit still happens. The duplicate-error recovery above is the only correct idempotency pattern for this API. If your codebase has a generic HTTP client that sets `Idempotency-Key` by default, disable it for Numeral calls.
This pattern is so important it has its own scenario in the verification checklist below.
### 2. `reference_*` IDs are the integrator's IDs
| Field | What goes here |
|---|---|
| `reference_order_id` | The integrator's order id — the natural key for commit idempotency |
| `reference_line_item_id` | The integrator's line-item id — stable across calculate → commit → refund |
| `reference_product_id` | SKU / product id |
| `reference_payment_id` | Payment / charge id |
Never put Numeral's `cal_…` or `tr_…` ids in a `reference_*` field. Never use a fresh nonce — the **same** line item must carry the **same** `reference_line_item_id` from calculation through commit through every later refund, or refunds and reconciliation will fail.
**Finding the right id in the integrator's codebase**
Before writing any Numeral request body, walk the codebase for each entity (order, line item, customer, product, payment) and pick the **outward-facing** stable id. These ids appear in Numeral's UI, CSV exports, and audit reports — six months from now ops has to recognize them. Picking wrong means nobody can reconcile a Numeral record back to a business record.
Criteria for a good `reference_*` id:
- **Outward-facing** — appears on customer receipts, finance exports, support tools, URLs.
- **Immutable** — never rotates or rebinds across migrations, restores, or vendor changes.
- **Unique** within the entity type.
- **Greppable** — given the id, a human can find the row in 5 seconds.
**Don't use:** raw DB primary keys (cuid / UUID / serial) that exist only in the DB, display names, fresh nonces, or third-party ids (Stripe `cus_…`, Shopify `gid://…`) **unless** that third party is the source of truth for that entity (see §9 for Stripe customers).
**Where to look** — do this as a codebase walkthrough before writing any request body:
1. **Schema / types** (`schema.prisma`, SQL migrations, TypeScript `interface`/`type` defs) — fields marked `@unique` that aren't surrogate `@id`s.
2. **URL routes** — `/orders/:orderNumber`, `/products/:sku` — the path param is usually the natural key.
3. **Webhook payloads the integrator already emits** — what id is in the body other services consume?
4. **CSV export code or receipt / invoice templates** — column headers like "Order ID", "SKU", "Customer ID".
5. **Error-log / Sentry / Datadog context** — what id does on-call grep during incidents?
6. **Ask the dev directly:** *"For an order / customer / product, what id would you quote to a customer over the phone?"* That answer is the `reference_*`.
If the codebase only has surrogate keys with no outward-facing id, the integrator should add one **before** integrating, not after. Raise this as a blocking question — don't paper over it with a cuid.
**Worked example.** A typical Prisma schema:
```prisma
model Order {
id String @id @default(cuid()) // surrogate — DON'T use
orderNumber String @unique // "HC-2026-00042" — USE THIS
stripeSessionId String? @unique
}
model Customer {
id String @id @default(cuid()) // surrogate — DON'T use
stripeId String? @unique // "cus_…" — use if Stripe-Numeral sync is wired (§9)
publicId String @unique // "hcust_…" — USE THIS otherwise
}
model Product {
id String @id @default(cuid()) // surrogate — DON'T use
sku String @unique // "WIDGET-PRO" — USE THIS
}
```
Resulting picks:
| Numeral field | Source in this codebase |
|---|---|
| `reference_order_id` | `Order.orderNumber` |
| `reference_line_item_id` | the line's stable id, or composite `${orderNumber}-${lineIndex}` if no stable id exists |
| `reference_product_id` | `Product.sku` |
| `reference_payment_id` | Stripe `charge_id` (or the payment row's outward-facing id) |
| `customer.id` (Numeral) | `Customer.stripeId` if Stripe-Numeral sync is on; otherwise `Customer.publicId` |
Pick once per entity, document the mapping in a short comment near the Numeral client (`// Numeral reference_* mapping: order.orderNumber → reference_order_id, …`), and use the **same** picks everywhere: calculate, commit, refund, customer creation. If you find yourself reaching for a different id in the refund path than the calc path, stop — refunds will not match.
### 3. Amounts are minor units (cents / subunits) — and per-unit
`$500.00 USD` → `amount: 50000`. `€12.50` → `1250`. `¥500` → `500` (zero-decimal currency). Same convention as Stripe. Sending `500.00` for $500 will produce tax 100× too low.
Since API version 2026-01-01, `amount` is the **per-unit** price: the taxable base for a line is `amount × quantity`. Three $25.00 items → `amount: 2500, quantity: 3` — **not** `amount: 7500`. Sending the line total with `quantity > 1` overstates tax by the quantity factor.
### 4. Refund amounts are negative
```typescript
{ sales_amount_refunded: -2000, tax_amount_refunded: -175, quantity: 1 }
```
Positive numbers on a refund will not behave as you expect. Always negative for reductions. For a full refund, send `type: "full"` and omit `line_items`.
### 5. Calculations expire — calculate at order-review, not cart-add
The calculation response includes `expires_at`. Calculate when the user reaches the review/checkout screen, re-calculate if cart or shipping address changes, and commit promptly after payment authorization. Don't:
- Calculate at "add to cart" and hold the result through a long browse
- Reuse one calculation across multiple checkout attempts
- Commit an expired calculation (the API returns an error)
**A transaction always requires a non-expired `calculation_id`.** If the calculation expires before commit, re-calculate (with whatever the current cart / address now is) and commit the **new** `calculation_id` while reusing the original `reference_order_id` so idempotency still holds. Do **not** "fall back" to `POST /tax/transactions` without a `calculation_id` — the API will reject it, and the failure mode of a webhook that keeps retrying with no calc id is worse than a clean error you can route to ops.
### 6. `tax_included_in_amount` — pick one convention per store
- `false` (US-style): `amount` is the **pre-tax** price. Numeral computes tax on top.
- `true` (EU-style VAT-inclusive pricing): `amount` is the **tax-inclusive** price. Numeral extracts the tax portion.
Mixing the two across calls in the same store produces totals that don't reconcile against the integrator's own books.
### 7. `automatic_tax: "disabled"` is not a workaround
- `"auto"` — Numeral applies nexus, sourcing, and product taxability automatically. Use this for almost every integration.
- `"disabled"` — forces zero tax. Use **only** for explicitly modeled exempt scenarios.
When tax is unexpectedly high or low, do **not** flip to `"disabled"` to make it go away — that means under-collecting and creates real audit liability for the seller. Investigate via the MCP and Numeral support instead.
### 8. B2B uses `customer.type: "BUSINESS"` plus `tax_ids` on each calculation
For intra-EU B2B with a valid VAT id, the calculation may return `total_tax_amount: 0`. That is correct (reverse-charge applies). Send `customer.type: "BUSINESS"` and `customer.tax_ids` on **every** calculation — customers do not store tax_ids, so there is nothing to pre-register. `tax_ids` is required for BUSINESS customers outside the US, and must not be sent for CONSUMER customers.
Tax ID `type` values are Stripe-style country-specific codes (`eu_vat`, `gb_vat`, `us_ein`, `au_abn`, …) at API version 2026-01-01+ — the older `VAT` / `GST` / `EIN` values are rejected. `POST /tax/customers` itself only stores `email` (required), `name`, `is_tax_exempt`, and `reference_customer_id` — use it when you need a stored exemption flag, and note it is create-only (repeating it returns a 400 `DUPLICATE_CUSTOMER`).
### 9. Stripe customers sync into Numeral automatically (in production)
If the integrator uses Stripe in **live** mode, a Stripe `cus_…` customer is mirrored into Numeral automatically, and calculations can reference `customer.id: cus_…` directly. In **test** mode this sync is not active — the integration code may need to `POST /tax/customers` first to mirror what production does. Document this difference clearly in the integrator's setup notes.
## Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Commit not idempotent | Duplicate transactions, over-reported tax | Handle `REFERENCE_ORDER_ID_ALREADY_EXISTS` and look up |
| Parsing errors as `err.error.error_code` (pre-2026 format) | Duplicate recovery never fires; every retry throws | 2026-01-01+ errors are flat `{ code, type, message }` with uppercase `type` |
| Adding `Idempotency-Key` header (Stripe muscle memory) | Header is ignored; double-commit still happens | Use `reference_order_id` + duplicate-error recovery, not a header |
| Dollars instead of cents | Tax 100× wrong | Multiply by 100 (or 10^decimals for non-2-decimal currencies) |
| Line totals instead of per-unit amounts | Tax overstated by the quantity factor on multi-quantity lines | `amount` is per-unit; taxable base = `amount × quantity` (2026-01-01+) |
| Omitting `origin_address` | 400 `MISSING_ORIGIN_ADDRESS` unless a default origin is configured on the account | Send the ship-from address on every calculation |
| Refund amounts positive | Wrong reconciliation | Negative numbers for reductions |
| Same `reference_line_item_id` reused across different items | Refunds and reporting break | Unique per line within an order |
| Using a DB surrogate key (cuid / UUID / serial) as a `reference_*` id | Numeral records can't be reconciled to business records later; ops can't find anything | Use the outward-facing id (`orderNumber`, `sku`, `publicId`) — the one the integrator would quote to a customer |
| Different id sources for calculate vs commit vs refund | Refunds fail to match; line-level reconciliation breaks | Pick once per entity, document the mapping, reuse everywhere |
| Calculate at "add to cart" | Stale tax shown; expired calc on commit | Calculate at order review; re-calculate on change |
| "Fallback" to commit without a `calculation_id` when the calc expires | Request rejected, webhook stuck retrying; worse, accepted with no validated tax | Re-calculate, commit the new calc id with the original `reference_order_id` |
| `automatic_tax: "disabled"` to hide unexpected tax | Under-collection, audit liability | Use the MCP / Numeral support to investigate |
| Expecting `POST /tax/customers` to store `tax_ids` or an address | 400 `UNRECOGNIZED_FIELD` — the endpoint only takes email/name/is_tax_exempt/reference_customer_id | Send `customer.tax_ids` and `customer.address` on each calculation |
| Old-style tax ID types (`VAT` / `GST` / `EIN`) on 2026+ versions | 400 validation error | Use Stripe-style codes: `eu_vat`, `gb_vat`, `us_ein`, `au_abn`, … |
| Hardcoding `X-API-Version` and never revisiting | Silent drift from current behavior | Re-read changelog and update yearly |
| Logging full response bodies in production | PII leak (customer addresses, tax_ids) | Log Numeral ids only, never request/response bodies |
| Treating the calculation's `total_tax_amount` as the source of truth after commit | Refunds and reports won't reconcile | Re-read from the committed transaction |
| Building direct API integration on top of an active Stripe/Shopify connector | Duplicate transactions | Check which connector is already wired up |
## Verification scenarios — run before going live
The reference client's runner (`run.ts` style) covers these. At minimum:
1. **Happy path** — calculate → commit → `GET /tax/transactions/{id}` and confirm tax + line items match.
2. **Webhook / worker replay** — fire the same commit twice. Second response must recover the existing transaction (no duplicates).
3. **Expired or invalid `calculation_id`** — commit a stale id and confirm the error path is handled, not crashed.
4. **Partial refund** — refund one line; confirm the transaction's net tax updates and the un-refunded lines are untouched.
5. **B2B reverse-charge** (if EU/CA in scope) — calculate with `type: "BUSINESS"` and a valid VAT id; expect `total_tax_amount: 0` for intra-EU.
6. **Test vs live customer sync** (Stripe integrations) — confirm that the calc works with a real `cus_…` in live mode without an explicit upsert.
When any scenario produces an unexpected response, query the MCP for that endpoint's current spec and changelog before changing client code.
## Auth setup
```typescript
const HEADERS = {
Authorization: `Bearer ${process.env.NUMERAL_API_KEY}`,
"Content-Type": "application/json",
"X-API-Version": "2026-03-01", // confirm via numeral-api-docs MCP before shipping
}
```
Store the key in the integrator's secret manager — never commit. Use separate test and live keys; test-mode transactions are not filed.
Integrating Numeral with Stripe Elements
Use this skill when Stripe Elements is in the stack (any ofPaymentElement, AddressElement, Subscriptions, or Invoicing). It builds on the same lifecycle but adds the Stripe-specific timing: where the Numeral commit fires for each Stripe flow, the calculation_id-via-metadata handoff between server and webhook, address-change recalculation, and webhook event mapping.
Get the skill
SKILL.md — the full skill; view the reference client in the accordion below
View full skill (SKILL.md)
View full skill (SKILL.md)
---
name: integrating-numeral-with-stripe-elements
description: Use when integrating Numeral's tax API (api.numeralhq.com) into a Stripe Elements implementation — PaymentIntents (one-time and auth→capture), Stripe Invoicing, Subscriptions via the Subscription API, or SetupIntents. Standalone skill — covers calculate/commit timing for each Stripe flow, the calculation_id-via-metadata handoff between server and webhook, address-change recalculation, and webhook event mapping. Read this when Stripe Elements (any of PaymentElement / AddressElement / Subscription / Invoice) is in the stack.
---
# Integrating Numeral with Stripe Elements
## Overview
Stripe Elements supports several distinct payment flows. Each has its own commit point for Numeral, and putting the commit in the wrong place either misses charges (committing too early) or commits for transactions that never happened (committing on the wrong event).
| Stripe flow | Numeral commit fires on | `reference_order_id` |
|---|---|---|
| One-time PaymentIntent | `payment_intent.succeeded` | your stable order id |
| Auth → capture (manual capture) | `payment_intent.succeeded` (fires at **capture**, not authorization) | your stable order id |
| Stripe Invoicing (one-off invoices) | `invoice.paid` | `invoice.id` (the `in_…`) |
| Subscription renewals | `invoice.paid` (one commit per invoice) | `invoice.id` per renewal |
| SetupIntent (save card) | no commit — no transaction | n/a |
Base URL: `https://api.numeralhq.com`. Auth: `Authorization: Bearer <key>`. Version header: `X-API-Version: 2026-03-01` — confirm via the MCP (next section) that this is still the latest stable version before shipping.
## Before any code — does Numeral's Stripe connector already cover this?
**ASK FIRST:** Does the integrator have Numeral's Stripe connector enabled in their Numeral account?
The connector observes Stripe events (`payment_intent.succeeded`, `invoice.paid`, …) and commits transactions to Numeral on the integrator's behalf. If it's enabled AND you also write direct API calls, you **double-commit** every paid order → over-reported tax in filings.
Connector decisions:
- **Subscriptions and Stripe Invoicing:** prefer the connector. It handles proration, dunning, trial periods, and mid-cycle plan changes more correctly than most hand-rolled integrations. Build direct only if the integrator's billing model doesn't quite fit Stripe Subscriptions.
- **PaymentIntents on Elements:** if the connector is on, it commits automatically when payment succeeds. Your direct code should still **calculate** tax (so the UI can show it pre-payment) but **must not commit** — the connector will.
- **SetupIntents:** the connector mirrors Stripe customers to Numeral automatically. You don't need to create customers manually.
If the connector is off, build direct as described below.
## Source of truth: the numeral-api-docs MCP
For the live, field-by-field shape of every endpoint, query the `numeral-api-docs` MCP server. Never invent field names — the MCP stays in sync with API releases.
```
mcp__numeral-api-docs__authenticate
mcp__numeral-api-docs__complete_authentication
```
When you query the MCP, explicitly request schemas at the version pinned here (`2026-03-01`) — don't accept whatever the MCP defaults to. If the MCP reports a newer stable version, flag it; the examples in this skill are bound to `2026-03-01`.
## Foundational patterns (apply to every flow below)
### Auth headers
```typescript
const HEADERS = {
Authorization: `Bearer ${process.env.NUMERAL_API_KEY}`,
"Content-Type": "application/json",
"X-API-Version": "2026-03-01",
}
```
Store the key in your secret manager. Use separate test and live keys.
### Amounts in minor units — and Numeral line amounts are per-unit
Stripe and Numeral both use minor units (cents for USD/EUR/GBP/CAD, whole units for JPY/KRW) — no decimal conversion needed in either direction.
But do **not** pass `PaymentIntent.amount` through as a Numeral line amount: since API version 2026-01-01, Numeral's line-item `amount` is the **per-unit** price (taxable base = `amount × quantity`), while `PaymentIntent.amount` is the order **total**. Feed Numeral your cart's per-unit prices, then set `PaymentIntent.amount = calc.total_amount_including_tax` from the calculation response.
### Idempotent commit (highest-stakes pattern)
Webhooks retry. Always commit on a stable `reference_order_id` and recover from the duplicate error:
```typescript
const res = await fetch(`${BASE}/tax/transactions`, {
method: "POST", headers,
body: JSON.stringify({ calculation_id, reference_order_id }),
})
if (res.ok) return await res.json()
// 2026-01-01+ errors are FLAT: { code, type, message } — no nested `error`
// object, and `type` is SCREAMING_SNAKE_CASE.
const err = await res.json()
if (err.type === "REFERENCE_ORDER_ID_ALREADY_EXISTS" || err.type === "TRANSACTION_ALREADY_EXISTS") {
const lookup = await fetch(
`${BASE}/tax/transactions?reference_order_id=${encodeURIComponent(reference_order_id)}`,
{ headers },
).then((r) => r.json())
return lookup.transactions[0]
}
// Sanitised — never embed the parsed body (PII risk).
throw new Error(`commit failed status=${res.status} type=${err.type ?? "unknown"}`)
```
**Anti-pattern — do not transfer Stripe muscle memory:** Numeral does NOT support an `Idempotency-Key` header. Sending one is silently ignored and the double-commit still happens. If your codebase has a generic HTTP client that sets `Idempotency-Key` by default, disable it for Numeral calls.
### `reference_*` IDs are the integrator's IDs
| Field | What goes here |
|---|---|
| `reference_order_id` | Stable order id. **For PaymentIntent flows:** your own order id. **For any Invoicing flow (one-off or subscription renewal):** `invoice.id` (the `in_…`) directly |
| `reference_line_item_id` | Your line-item id (stable across calc → commit → refund) |
| `reference_product_id` | Your SKU / product id |
| `reference_payment_id` | Stripe `pi_…` (PaymentIntent flows) or `ch_…` |
| `customer.id` (Numeral) | Stripe `cus_…` if the Stripe-Numeral connector is mirroring customers (live mode); otherwise your own outward-facing customer id |
The same line item must carry the same `reference_line_item_id` from calculation through commit through every later refund. Never use a fresh nonce; never put Numeral's `cal_…`/`tr_…` ids in a `reference_*` field; never put a raw DB surrogate key (cuid/UUID/serial) — pick the outward-facing id you'd quote to a customer.
### Calculations expire — commit promptly on a fresh calc
The calculation response includes `expires_at` (epoch seconds). If a calc expires before commit, **re-calculate** (same `reference_order_id`) and commit the new `calculation_id`. **Never POST a transaction without a `calculation_id`** — the API rejects it, and the failure mode of a webhook that keeps retrying with no calc id is worse than a clean error you can route to ops.
### Refund amounts are negative
```typescript
{ sales_amount_refunded: -2000, tax_amount_refunded: -175, quantity: 1 }
```
For a full refund, send `type: "full"` and omit `line_items`. Read the original tax-per-line from the **committed transaction** (`tx.line_items[i].tax_amount`), not from a fresh calculation — rates may have drifted between commit and refund.
**Partial-quantity refunds must scale the amount.** A committed line's `amount_excluding_tax` and `tax_amount` are line-level totals across the whole original quantity. If the original line is `quantity: 3, amount_excluding_tax: 150000` and the customer returns 1 of 3, the refund must be `sales_amount_refunded: -50000` (scaled by `1/3`), not `-150000`. Sending the full line total for a partial-quantity refund over-refunds and creates a filing error. See `refundLine` in the reference implementation for the safe pattern with a bounds check.
### `tax_included_in_amount` and `automatic_tax`
- `tax_included_in_amount: false` — `amount` is pre-tax. Numeral adds tax on top. Use this for US-style pricing.
- `tax_included_in_amount: true` — `amount` is tax-inclusive. Use this for EU VAT-inclusive pricing.
- `automatic_tax: "auto"` — Numeral applies nexus and product taxability. Default choice.
- `automatic_tax: "disabled"` — forces zero tax. Use only for explicitly modeled exempt cases. **Never** as a workaround for unexpected tax — that creates real audit liability.
## Stripe Elements flows — detailed
### Flow 1: One-time PaymentIntent
Lifecycle:
1. Customer fills `<PaymentElement>` and `<AddressElement>` on the client.
2. Client POSTs cart + selected address to your server.
3. Server validates the address, then calls Numeral `POST /tax/calculations` → returns `total_tax_amount`, `calculation_id`, and `expires_at`.
4. Server creates a PaymentIntent: `amount = subtotal + total_tax_amount`, `metadata = { numeral_calculation_id, your_order_id }`.
5. Server returns `client_secret` to the client.
6. Client calls `stripe.confirmPayment({ clientSecret, … })`.
7. Stripe webhook `payment_intent.succeeded` → server reads `numeral_calculation_id` and `your_order_id` from `metadata` and commits to Numeral with `reference_order_id = your_order_id`, `reference_payment_id = pi.id`.
**Stash `calculation_id` in PaymentIntent metadata, not just your DB.** The webhook is your single source of truth for "did this pay?" — it must carry everything needed to commit Numeral, even if your DB write failed mid-checkout. Always include `numeral_calculation_id` and your order id in the metadata.
#### Address changes mid-checkout
`<AddressElement>` can change after the PaymentIntent is created (customer toggles billing vs shipping, picks a different saved address, etc.). When that happens:
1. Server re-calculates with the new address → new `calculation_id`, possibly new `total_tax_amount`.
2. Server updates the PaymentIntent: `stripe.paymentIntents.update(pi.id, { amount: new_total, metadata: { numeral_calculation_id: new_calc_id, ... } })`.
3. Client must re-fetch `client_secret` if the amount changed materially (Stripe refuses confirmation against a stale amount in some flows).
Never reuse a stale `calculation_id` after the address changes. The new calc supersedes the old one; the commit references the new one.
#### SCA / 3DS
3DS pushes the PaymentIntent into `requires_action` while the customer authenticates. This is invisible to Numeral — `payment_intent.succeeded` fires only after 3DS completes successfully. **Commit on `succeeded`, not on `confirm`.** Authenticating an `authentication_required` PI without ever succeeding means no money moved → no commit.
### Flow 2: Auth → capture (manual capture)
When `capture_method: "manual"` is set on the PaymentIntent:
- Confirmation moves the PI into `requires_capture` — money is **authorized** but **not** moved.
- Later, `stripe.paymentIntents.capture(pi.id)` actually moves the money. `payment_intent.succeeded` fires at this point, not at authorization.
- **Commit Numeral at capture, not at authorization.** An authorized PI may never capture (user changes mind, fraud check fails, merchant cancels).
- For **partial capture** (capturing less than authorized), recalculate tax against the captured amount and commit that. The Numeral transaction should match the money that actually moved.
- For an authorized PI that gets cancelled (`payment_intent.canceled`): don't commit. No transaction occurred.
### Flow 3: Stripe Invoicing (one-off invoices)
Use this when you create invoices server-side (B2B billing, custom payment terms, NET 30, hosted-invoice-URL flows).
Lifecycle:
1. Create the invoice (`stripe.invoices.create(...)`) and add line items.
2. **Before finalizing**, call Numeral `POST /tax/calculations` with the invoice's line items.
3. Add the tax as a single custom line item on the invoice: `stripe.invoiceItems.create({ invoice: invoice.id, amount: total_tax_amount, currency, description: "Sales tax" })`.
4. Store `numeral_calculation_id` in `invoice.metadata` (you'll need it in the webhook).
5. Finalize the invoice (`stripe.invoices.finalizeInvoice(invoice.id)`).
6. Customer pays the invoice. Webhook `invoice.paid` fires.
7. Commit to Numeral with `reference_order_id = invoice.id` (the `in_…`), `calculation_id` read from `invoice.metadata.numeral_calculation_id`, `reference_payment_id = invoice.payment_intent` if present.
**`invoice.id` is the natural key for `reference_order_id` in Invoicing flows.** It's stable, outward-facing (appears in the Stripe dashboard, in the customer's hosted-invoice URL, in the integrator's accounting export), and Numeral's audit trail will line up cleanly with Stripe's.
**Invoicing is the common case for an expired calculation.** Unlike a PaymentIntent (paid within minutes), an invoice on NET-30 / NET-60 / hosted-invoice terms sits unpaid for days or weeks. The calculation from step 2 will almost certainly be expired by the time `invoice.paid` fires. The webhook handler **must** check `invoice.metadata.numeral_calculation_expires_at` and re-calculate against the invoice's current state if expired, reusing `invoice.id` as `reference_order_id` to preserve idempotency. The `handleInvoicePaid` example in the reference implementation demonstrates this pattern. Skipping the expiry check guarantees a Numeral rejection and a webhook retry loop the first time an invoice ages past the calc TTL.
Don't use Stripe's own `tax_rates` alongside Numeral. They double-count — pick one source of truth, and that's Numeral.
#### Voided / uncollectible invoices
- **Voided before payment:** no commit. Numeral never saw it.
- **Paid then later marked uncollectible** (rare, e.g. chargeback): refund the Numeral transaction. The trigger is `invoice.marked_uncollectible` or whatever your dispute-handling flow surfaces.
### Flow 4: Subscriptions via Subscription API
**Strong recommendation: use Numeral's Stripe connector for subscriptions.** Proration, mid-cycle plan changes, trial periods, dunning, and billing cycle anchors are all handled correctly by the connector and tricky to get right by hand. Build direct only if the integrator's billing model genuinely doesn't fit Stripe Subscriptions.
If you're building direct anyway:
Each subscription invoice (initial subscription, every renewal, every prorated mid-cycle adjustment) flows through the **Flow 3 pattern**:
1. `invoice.upcoming` or `invoice.created` (depending on `pending_invoice_items_behavior` and how the subscription is configured) → calculate Numeral tax for that invoice's line items.
2. Add a tax invoice item to the invoice before it finalizes.
3. Stripe auto-finalizes when ready; `invoice.finalized` fires.
4. Customer pays → `invoice.paid` → commit Numeral with `reference_order_id = invoice.id`.
**One commit per invoice.** Never try to commit "the subscription" — it's a billing relationship, not a transaction. Each renewal, each proration adjustment, is its own invoice and its own commit.
**Proration:** when a subscription changes mid-cycle (plan change, quantity bump), Stripe emits an invoice with both the prorated debit/credit lines and the next-period charge. Recalculate Numeral tax against the full new line set on that invoice — don't try to carry forward the previous invoice's calc.
### Flow 5: SetupIntent (save card without charging)
SetupIntents collect a payment method for future use without moving money. There's no transaction, so **no tax calculation and no commit**.
What a successful SetupIntent does signal: you've collected the customer's payment method. Use that moment to create the customer in Numeral so future PaymentIntents / Invoices reference a known customer:
```typescript
// On setup_intent.succeeded webhook:
await fetch(`${BASE}/tax/customers`, {
method: "POST", headers,
body: JSON.stringify({
reference_customer_id: stripeCustomerId, // cus_… — your id for this customer
email: customerEmail, // REQUIRED
name: customerName, // optional
is_tax_exempt: false, // optional; the reason to pre-create
}),
})
// Create-only: a repeat call returns 400 DUPLICATE_CUSTOMER — treat that as
// success so webhook replays stay idempotent.
```
`POST /tax/customers` does **not** store tax_ids or addresses — send `customer.tax_ids` (Stripe-style type codes like `eu_vat`, `us_ein`) and `customer.address` on **each calculation** instead. Pre-creating the customer matters mainly for `is_tax_exempt`; if you skip it, calculations still work as long as they pass `default_customer_type` (see the reference implementation) or avoid referencing an unknown `customer.id`.
## `reference_*` mapping summary
| Numeral field | PaymentIntent flow | Invoicing flow | Subscription invoice | SetupIntent |
|---|---|---|---|---|
| `reference_order_id` | your order id | `invoice.id` | `invoice.id` (per renewal) | — (no commit) |
| `reference_payment_id` | `pi_…` | `pi_…` from `invoice.payment_intent` | `pi_…` from renewal's PI | — |
| `reference_line_item_id` | your line id | your line id (or composite `${invoice.id}-${lineIndex}` if Stripe's `il_…` isn't stable enough) | same | — |
| `reference_product_id` | your sku | your sku | your sku | — |
| `customer.id` (Numeral) | `cus_…` (if Stripe-Numeral connector mirrors customers in live mode) or your outward-facing customer id | same | same | same |
Pick once per entity. Use the **same** picks across calculate, commit, refund. Document the mapping in a comment near your Numeral client.
## Common Stripe Elements mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Storing `calculation_id` only in your DB, not in PaymentIntent metadata | Webhook commit fails if DB write was lost or out of sync | Always put `numeral_calculation_id` in PI / invoice metadata |
| Calculating tax before collecting the address | Wrong jurisdiction → wrong tax | Calculate only after `<AddressElement>` has a complete address |
| Forgetting to recalc + update the PI when address changes mid-checkout | Customer pays the wrong tax; commit and auth disagree | Listen for AddressElement change events; PATCH the PI on change |
| Committing on `invoice.paid` without checking calc expiry | Numeral rejects the commit (calc expired); webhook retries forever | Check `invoice.metadata.numeral_calculation_expires_at` and re-calculate if past — NET-30+ invoices virtually always need this |
| Committing on `payment_intent.created` instead of `payment_intent.succeeded` | Commits for payments that never complete | Webhook on `succeeded` only |
| Committing on confirmation instead of capture (manual-capture flows) | Commits for auths that get cancelled | Rely on `payment_intent.succeeded` — it fires at capture for manual flows |
| Using Stripe `tax_rates` AND Numeral together | Tax double-counted in one system's reports | Numeral is the source of truth — don't set Stripe `tax_rates` |
| Trying to "commit the subscription" once | Renewal invoices never committed | One commit per invoice, keyed by `invoice.id` |
| Calculating tax on a SetupIntent | Wasted API call; possibly nonsensical response | No calc — only customer creation |
| Parsing errors as `err.error.error_code` (pre-2026 format) | Duplicate recovery never fires; every webhook retry throws | 2026-01-01+ errors are flat `{ code, type, message }` with uppercase `type` |
| Passing `PaymentIntent.amount` through as a Numeral line amount | Tax computed on the wrong base (total treated as per-unit) | Numeral line `amount` is per-unit; PI amount is the order total |
| Omitting `origin_address` on calculations | 400 `MISSING_ORIGIN_ADDRESS` unless a default origin is configured on the account | Send the ship-from address, or configure a default origin in Numeral |
| Building direct integration when the Stripe connector is enabled | Double-commit on every paid order | Check connector status first; for subscriptions strongly prefer the connector |
| Using `pi_…` as `reference_order_id` for a PI flow that has its own order entity | Audit trail mixes Stripe ids with business ids | `pi_…` belongs in `reference_payment_id`; the order id belongs in `reference_order_id` |
| Using `pi_…` as `reference_order_id` for a guest checkout "because there's no order" | Refunds can't be looked up by your own order id later | Generate an outward-facing order id at checkout, even for guests |
| Adding `Idempotency-Key` header (Stripe muscle memory) | Header is ignored; duplicates still possible | Use `reference_order_id` + duplicate-error recovery |
| Refund amounts positive | Wrong reconciliation in Numeral | Negative numbers for `sales_amount_refunded` / `tax_amount_refunded` |
| Hardcoding `X-API-Version` and never revisiting | Silent drift from current API behavior | Confirm via MCP yearly; bump deliberately |
## Verification scenarios
Test every flow you actually use before going live:
1. **PaymentIntent happy path** — calculate → confirm → succeeds → commit. Confirm transaction via `GET /tax/transactions/{id}`.
2. **Webhook retry** — replay `payment_intent.succeeded` (or `invoice.paid`) via Stripe CLI `stripe events resend evt_…`. Second commit must recover via the duplicate-error path with no duplicates created.
3. **Address change mid-checkout** — change `<AddressElement>`; PI amount must update; new `calculation_id` in metadata; commit must reference the new calc.
4. **Manual capture** — auth a PI, capture later; confirm commit fires at capture, not at auth. Then auth a PI and cancel without capture; confirm NO commit.
5. **3DS** — trigger with Stripe test card `4000 0027 6000 3184`; confirm commit happens only after the customer completes auth.
6. **Stripe Invoicing** — create + finalize + pay an invoice; confirm commit with `reference_order_id = invoice.id`.
7. **Subscription renewal** — fast-forward a subscription via Stripe test clocks; confirm each renewal's invoice commits separately.
8. **SetupIntent** — succeed a SetupIntent; confirm Numeral customer creation ran (and a replay hits the `DUPLICATE_CUSTOMER` success path); confirm NO calculation was made.
9. **Connector double-commit guard** — if the Stripe-Numeral connector is enabled in the test account, confirm that the direct code path either skips commit or recovers via the duplicate-error path (the connector commits the same `reference_order_id` from its side).
When something doesn't match expectation, query the MCP for that endpoint's spec at version `2026-03-01` and compare against what you're sending.
## Reference implementation
The reference implementation below covers the PaymentIntent + AddressElement flow and the Stripe Invoicing flow end-to-end on the server side. Copy and adapt. Subscriptions reuse the Invoicing pattern in a loop (one webhook handler covers both one-off invoices and subscription renewals — the invoice payload tells you which it is via the `subscription` field). (In the downloadable bundle this also ships as `stripe-elements-numeral.example.ts` next to this file — same content.)
<!-- BEGIN stripe-elements-numeral.example.ts (auto-embedded; keep in sync with the .ts file) -->
```typescript
// Reference server-side integration for Numeral + Stripe Elements. Copy and
// adapt — this file is a starting point, not a drop-in library.
//
// Covers five flows from SKILL.md:
// 1. One-time PaymentIntent + AddressElement (the most common Elements path)
// 2. Auth → capture (same code path; capture_method: "manual" on the PI)
// 3. Stripe Invoicing (one-off invoices)
// 4. Subscription renewals (reuse Flow 3's invoice.paid handler)
// 5. SetupIntent customer creation (no tax — no transaction)
//
// All amounts in minor units (cents for USD/EUR/GBP/CAD). Numeral line-item
// amounts are PER-UNIT (taxable base = amount x quantity) — Stripe's
// PaymentIntent.amount is an order TOTAL, so never pass it through as a line
// amount. Refund amounts are negative. The version pinned here is 2026-03-01 —
// confirm via the numeral-api-docs MCP that it's still current before shipping.
import Stripe from "stripe"
const NUMERAL_BASE = "https://api.numeralhq.com"
const NUMERAL_API_VERSION = "2026-03-01"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
apiVersion: "2024-04-10", // pin Stripe API version too (match installed SDK)
})
// ============================================================================
// HTTP helpers
// ============================================================================
function numeralHeaders(): Record<string, string> {
const key = process.env.NUMERAL_API_KEY
if (!key) throw new Error("NUMERAL_API_KEY not set")
return {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"X-API-Version": NUMERAL_API_VERSION,
// NOTE: deliberately no Idempotency-Key. Numeral ignores it; idempotency
// is via reference_order_id + duplicate-error recovery (see commitTransaction).
}
}
// 2026-01-01+ error format: a flat object, no nested `error` wrapper, with
// `type` in SCREAMING_SNAKE_CASE.
interface NumeralErrorBody {
code?: number
type?: string
message?: string
}
// PII-safe error: never embeds the raw response body, which can contain
// customer addresses, tax_ids, and other sensitive fields.
async function throwSanitized(op: string, res: Response): Promise<never> {
const body = (await res.json().catch(() => ({}))) as NumeralErrorBody
throw new Error(
`${op} failed status=${res.status} type=${body.type ?? "unknown"} request_id=${res.headers.get("x-request-id") ?? "unknown"}`,
)
}
// ============================================================================
// Shared types and primitives
// ============================================================================
export interface CartLineInput {
referenceLineItemId: string // YOUR stable line id (across calc/commit/refund)
referenceProductId: string // YOUR SKU
unitAmountMinor: number // per-unit price in minor units
quantity: number
}
export interface ShippingAddressInput {
line1: string
line2?: string
city: string
state: string // 2-letter for US/CA, e.g. "CA"
postalCode: string
country: string // 2-letter, e.g. "US"
}
interface NumeralCalculationResponse {
id: string
object: "tax.calculation"
expires_at: number
total_amount_excluding_tax: number
total_tax_amount: number
total_amount_including_tax: number
line_items: Array<{
product: { reference_line_item_id?: string; reference_product_id?: string }
amount_excluding_tax: number
tax_amount: number
amount_including_tax: number
}>
}
interface NumeralTransactionResponse {
id: string
object: "tax.transaction"
calculation_id: string
reference_order_id: string
line_items: Array<{
product?: { reference_line_item_id?: string; reference_product_id?: string }
amount_excluding_tax?: number
tax_amount?: number
quantity?: number
}>
}
async function createNumeralCalculation(args: {
customerId: string
customerType: "BUSINESS" | "CONSUMER"
currency: string
address: ShippingAddressInput
// Ship-from address. Required unless a default origin address is configured
// on the Numeral account for the destination jurisdiction — omitting it with
// no default configured returns a 400 MISSING_ORIGIN_ADDRESS.
originAddress?: ShippingAddressInput
lines: CartLineInput[]
}): Promise<NumeralCalculationResponse> {
const res = await fetch(`${NUMERAL_BASE}/tax/calculations`, {
method: "POST",
headers: numeralHeaders(),
body: JSON.stringify({
customer: {
id: args.customerId,
type: args.customerType,
address: {
address_line_1: args.address.line1,
address_line_2: args.address.line2,
address_city: args.address.city,
address_province: args.address.state,
address_postal_code: args.address.postalCode,
address_country: args.address.country,
address_type: "shipping" as const,
},
},
// Fallback when customerId (often a Stripe cus_…) hasn't been created in
// Numeral yet — e.g. test mode, where the connector doesn't mirror
// customers. Without this, an unknown customer.id returns a 400.
default_customer_type: args.customerType,
// NOTE: no address_type on origin_address — that returns a 400
// UNRECOGNIZED_FIELD.
origin_address: args.originAddress
? {
address_line_1: args.originAddress.line1,
address_line_2: args.originAddress.line2,
address_city: args.originAddress.city,
address_province: args.originAddress.state,
address_postal_code: args.originAddress.postalCode,
address_country: args.originAddress.country,
}
: undefined,
order_details: {
customer_currency_code: args.currency.toUpperCase(),
tax_included_in_amount: false, // US-style pre-tax pricing
automatic_tax: "auto" as const,
line_items: args.lines.map((l) => ({
reference_line_item_id: l.referenceLineItemId,
reference_product_id: l.referenceProductId,
amount: l.unitAmountMinor,
quantity: l.quantity,
})),
},
}),
})
if (!res.ok) await throwSanitized("createCalculation", res)
return (await res.json()) as NumeralCalculationResponse
}
// Idempotent commit. Safe to retry across webhook replays.
async function commitNumeralTransaction(args: {
calculationId: string
referenceOrderId: string
referencePaymentId?: string
}): Promise<{ transactionId: string; wasDuplicate: boolean }> {
const headers = numeralHeaders()
const createRes = await fetch(`${NUMERAL_BASE}/tax/transactions`, {
method: "POST",
headers,
body: JSON.stringify({
calculation_id: args.calculationId,
reference_order_id: args.referenceOrderId,
reference_payment_id: args.referencePaymentId,
}),
})
if (createRes.ok) {
const tx = (await createRes.json()) as NumeralTransactionResponse
return { transactionId: tx.id, wasDuplicate: false }
}
const errBody = (await createRes.json().catch(() => ({}))) as NumeralErrorBody
const isDuplicate =
errBody.type === "REFERENCE_ORDER_ID_ALREADY_EXISTS" ||
errBody.type === "TRANSACTION_ALREADY_EXISTS"
if (!isDuplicate) {
throw new Error(
`commitTransaction failed status=${createRes.status} type=${errBody.type ?? "unknown"} request_id=${createRes.headers.get("x-request-id") ?? "unknown"}`,
)
}
// Duplicate — webhook retry. Look up the existing transaction.
const lookupRes = await fetch(
`${NUMERAL_BASE}/tax/transactions?reference_order_id=${encodeURIComponent(args.referenceOrderId)}`,
{ headers },
)
if (!lookupRes.ok) await throwSanitized("lookupByReferenceOrderId", lookupRes)
const lookup = (await lookupRes.json()) as {
transactions: NumeralTransactionResponse[]
}
const existing = lookup.transactions[0]
if (!existing) {
throw new Error(
`duplicate reported but no transaction found for reference_order_id=${args.referenceOrderId}`,
)
}
return { transactionId: existing.id, wasDuplicate: true }
}
// ============================================================================
// FLOW 1: PaymentIntent + AddressElement (one-time + auth/capture)
// ============================================================================
// Server endpoint called by the client after AddressElement has a complete
// address. Returns the tax to show in the cart and the PI client_secret.
//
// IMPORTANT: orderId is YOUR stable order id — pick it at cart creation, NOT
// pi.id. The Stripe pi_… goes in reference_payment_id, not reference_order_id.
export async function createPaymentIntentForCart(args: {
orderId: string
customerId: string
customerType: "BUSINESS" | "CONSUMER"
currency: string
address: ShippingAddressInput
lines: CartLineInput[]
captureMethod?: "automatic" | "manual" // "manual" = Flow 2 (auth → capture)
}): Promise<{
clientSecret: string
taxAmountMinor: number
calculationId: string
}> {
// 1. Calculate tax with Numeral.
const calc = await createNumeralCalculation({
customerId: args.customerId,
customerType: args.customerType,
currency: args.currency,
address: args.address,
lines: args.lines,
})
// 2. Create the PaymentIntent for subtotal + tax.
const pi = await stripe.paymentIntents.create({
amount: calc.total_amount_including_tax,
currency: args.currency,
capture_method: args.captureMethod ?? "automatic",
customer: args.customerId.startsWith("cus_") ? args.customerId : undefined,
// Stash calc_id in metadata so the webhook can commit even if our DB
// write fails. This is the critical handoff between server and webhook.
metadata: {
numeral_calculation_id: calc.id,
numeral_calculation_expires_at: String(calc.expires_at),
reference_order_id: args.orderId,
},
})
if (!pi.client_secret) {
throw new Error("Stripe returned PaymentIntent without client_secret")
}
return {
clientSecret: pi.client_secret,
taxAmountMinor: calc.total_tax_amount,
calculationId: calc.id,
}
}
// Call this when AddressElement changes mid-checkout. Recalcs tax, updates
// the PI amount, and pushes the new calc_id into metadata. Never reuse a
// stale calc_id after the address changes — the new calc supersedes it.
export async function updatePaymentIntentForAddressChange(args: {
paymentIntentId: string
orderId: string
customerId: string
customerType: "BUSINESS" | "CONSUMER"
currency: string
address: ShippingAddressInput
lines: CartLineInput[]
}): Promise<{ taxAmountMinor: number; calculationId: string }> {
const calc = await createNumeralCalculation({
customerId: args.customerId,
customerType: args.customerType,
currency: args.currency,
address: args.address,
lines: args.lines,
})
await stripe.paymentIntents.update(args.paymentIntentId, {
amount: calc.total_amount_including_tax,
metadata: {
numeral_calculation_id: calc.id,
numeral_calculation_expires_at: String(calc.expires_at),
reference_order_id: args.orderId,
},
})
return { taxAmountMinor: calc.total_tax_amount, calculationId: calc.id }
}
// Webhook handler for payment_intent.succeeded. Fires AT CAPTURE for
// manual-capture PIs (Flow 2), which is the correct moment to commit.
// On any thrown error, respond 5xx so Stripe retries — the commit is idempotent.
export async function handlePaymentIntentSucceeded(
pi: Stripe.PaymentIntent,
): Promise<void> {
if (pi.status !== "succeeded") {
throw new Error(`unexpected PI status at succeeded webhook: ${pi.status}`)
}
const calculationId = pi.metadata.numeral_calculation_id
const orderId = pi.metadata.reference_order_id
if (!calculationId || !orderId) {
throw new Error(
`PI ${pi.id} missing numeral metadata — calc=${calculationId} order=${orderId}`,
)
}
// If the calc has expired by webhook delivery time (rare; usually means a
// delayed redelivery after an outage), re-calculate from the order's current
// state. Same reference_order_id preserves idempotency.
const expiresAt = Number(pi.metadata.numeral_calculation_expires_at ?? "0")
let calcIdToCommit = calculationId
if (expiresAt > 0 && Date.now() / 1000 > expiresAt) {
const refreshed = await recalculateFromOrderId(orderId)
calcIdToCommit = refreshed.id
}
await commitNumeralTransaction({
calculationId: calcIdToCommit,
referenceOrderId: orderId,
referencePaymentId: pi.id,
})
}
// Project-specific: fetch the order from your DB by id and re-run
// createNumeralCalculation. Replace this stub with your actual data access.
async function recalculateFromOrderId(
orderId: string,
): Promise<NumeralCalculationResponse> {
throw new Error(
`calculation expired before commit for order ${orderId} — implement recalc from your DB`,
)
}
// ============================================================================
// FLOW 3: Stripe Invoicing (one-off invoices)
// FLOW 4: Subscription renewals (same code path — invoice.paid for both)
// ============================================================================
// Call AFTER you've created the invoice and added all your line items, but
// BEFORE finalizing. Computes Numeral tax and adds it as an invoice item.
// Also stashes calculation_id on invoice.metadata so the webhook can commit.
export async function attachNumeralTaxToInvoice(args: {
invoiceId: string // in_…
customerId: string
customerType: "BUSINESS" | "CONSUMER"
address: ShippingAddressInput
lines: CartLineInput[] // mirror of the invoice's line items
}): Promise<{ calculationId: string; taxAmountMinor: number }> {
const invoice = await stripe.invoices.retrieve(args.invoiceId)
if (invoice.status !== "draft") {
throw new Error(
`attachNumeralTaxToInvoice: invoice ${args.invoiceId} must be in draft (got ${invoice.status})`,
)
}
const calc = await createNumeralCalculation({
customerId: args.customerId,
customerType: args.customerType,
currency: invoice.currency,
address: args.address,
lines: args.lines,
})
if (calc.total_tax_amount > 0) {
await stripe.invoiceItems.create({
invoice: args.invoiceId,
customer: invoice.customer as string,
amount: calc.total_tax_amount,
currency: invoice.currency,
description: "Sales tax",
})
}
// Stash calc id on the invoice so the webhook can commit.
await stripe.invoices.update(args.invoiceId, {
metadata: {
numeral_calculation_id: calc.id,
numeral_calculation_expires_at: String(calc.expires_at),
},
})
return { calculationId: calc.id, taxAmountMinor: calc.total_tax_amount }
}
// Webhook handler for invoice.paid. Covers both one-off invoices and
// subscription renewals — the invoice.subscription field tells you which.
//
// reference_order_id = invoice.id (the in_…), per SKILL.md convention for
// Invoicing flows. This makes Numeral's audit trail line up with Stripe's.
export async function handleInvoicePaid(
invoice: Stripe.Invoice,
): Promise<void> {
if (invoice.status !== "paid") {
throw new Error(`unexpected invoice status at paid webhook: ${invoice.status}`)
}
const calculationId = invoice.metadata?.numeral_calculation_id
if (!calculationId) {
// Either Numeral tax was never attached (mis-wired pipeline) or this
// invoice was created some other way (e.g. by the Stripe-Numeral connector,
// which commits from its own side). Don't throw — log and return so we
// don't get stuck retrying a webhook for an invoice we can't commit.
console.warn(
`invoice ${invoice.id} paid without numeral_calculation_id; skipping commit`,
)
return
}
// Invoicing flows are the COMMON case for an expired calculation: NET-30 /
// NET-60 / hosted-invoice payment all leave the original calc id stale long
// before invoice.paid fires. If the stored expiry has passed, re-calculate
// against the invoice's current state and commit the new calc id. The
// original reference_order_id (invoice.id) preserves idempotency.
const expiresAt = Number(invoice.metadata?.numeral_calculation_expires_at ?? "0")
let calcIdToCommit = calculationId
if (expiresAt > 0 && Date.now() / 1000 > expiresAt) {
const refreshed = await recalculateFromInvoice(invoice)
calcIdToCommit = refreshed.id
}
await commitNumeralTransaction({
calculationId: calcIdToCommit,
referenceOrderId: invoice.id, // in_… IS the reference_order_id for Invoicing flows
referencePaymentId:
typeof invoice.payment_intent === "string"
? invoice.payment_intent
: undefined,
})
}
// Project-specific: derive customer type, shipping address, and CartLineInput[]
// from your stored mapping for this invoice, then call createNumeralCalculation
// against the invoice's current state. Replace this stub with your data access.
//
// The invoice payload includes `customer` (cus_…) and `lines.data`, but not
// the integrator's own customer-type/address mapping or per-line SKU/sku
// mapping, which is why this is a stub.
async function recalculateFromInvoice(
invoice: Stripe.Invoice,
): Promise<NumeralCalculationResponse> {
throw new Error(
`calculation expired before commit for invoice ${invoice.id} — implement recalc from your DB`,
)
}
// ============================================================================
// FLOW 5: SetupIntent (no tax — customer creation only)
// ============================================================================
// Webhook handler for setup_intent.succeeded. SetupIntents collect a payment
// method without moving money — there's no transaction and no commit. Use
// this moment to create the customer in Numeral (if not already created) so
// future PI / invoice flows reference a known customer.
//
// POST /tax/customers only stores email (required), name, is_tax_exempt, and
// reference_customer_id. It does NOT store tax_ids or addresses — send those
// on each calculation (customer.tax_ids / customer.address). The endpoint is
// create-only: a repeat call returns 400 DUPLICATE_CUSTOMER, which this
// handler treats as success so webhook replays stay idempotent.
export async function handleSetupIntentSucceeded(
si: Stripe.SetupIntent,
args: {
email: string // from your records or stripe.customers.retrieve(cus_…).email
name?: string
isTaxExempt?: boolean
},
): Promise<void> {
if (!si.customer) {
// No customer attached — nothing to create.
return
}
const stripeCustomerId =
typeof si.customer === "string" ? si.customer : si.customer.id
const res = await fetch(`${NUMERAL_BASE}/tax/customers`, {
method: "POST",
headers: numeralHeaders(),
body: JSON.stringify({
reference_customer_id: stripeCustomerId,
email: args.email,
name: args.name,
is_tax_exempt: args.isTaxExempt,
}),
})
if (!res.ok) {
const errBody = (await res.json().catch(() => ({}))) as NumeralErrorBody
if (errBody.type === "DUPLICATE_CUSTOMER") {
return // already created — webhook replay or repeat SetupIntent
}
throw new Error(
`createCustomer failed status=${res.status} type=${errBody.type ?? "unknown"} request_id=${res.headers.get("x-request-id") ?? "unknown"}`,
)
}
}
// ============================================================================
// Refunds — partial line refund using the committed transaction's tax
// ============================================================================
// Reads the original tax-per-line from the COMMITTED transaction (not from a
// fresh calculation, which risks rate drift). Refund amounts are negated.
export async function refundLine(args: {
referenceOrderId: string // your order id (PI flows) OR invoice.id (Invoicing flows)
referenceLineItemId: string
quantityToRefund: number
}): Promise<void> {
const headers = numeralHeaders()
const lookupRes = await fetch(
`${NUMERAL_BASE}/tax/transactions?reference_order_id=${encodeURIComponent(args.referenceOrderId)}`,
{ headers },
)
if (!lookupRes.ok) await throwSanitized("lookupByReferenceOrderId", lookupRes)
const lookup = (await lookupRes.json()) as {
transactions: NumeralTransactionResponse[]
}
const tx = lookup.transactions[0]
if (!tx) {
throw new Error(
`no committed transaction found for reference_order_id=${args.referenceOrderId}`,
)
}
const line = tx.line_items.find(
(l) => l.product?.reference_line_item_id === args.referenceLineItemId,
)
if (!line) {
throw new Error(
`line not found on committed transaction: reference_line_item_id=${args.referenceLineItemId}`,
)
}
// `amount_excluding_tax` and `tax_amount` on a committed line are
// line-level totals (sum across the line's original quantity), so a
// partial-quantity refund must scale them. Without this, refunding 1 of 3
// units would over-refund 3× — a serious filing error.
const originalQuantity = line.quantity ?? 0
if (originalQuantity <= 0) {
throw new Error(
`committed line has invalid quantity (${originalQuantity}) for reference_line_item_id=${args.referenceLineItemId}`,
)
}
if (args.quantityToRefund <= 0 || args.quantityToRefund > originalQuantity) {
throw new Error(
`refund quantity ${args.quantityToRefund} out of range (1..${originalQuantity}) for reference_line_item_id=${args.referenceLineItemId}`,
)
}
const refundRatio = args.quantityToRefund / originalQuantity
const salesAmount = Math.round((line.amount_excluding_tax ?? 0) * refundRatio)
const taxAmount = Math.round((line.tax_amount ?? 0) * refundRatio)
const refundRes = await fetch(`${NUMERAL_BASE}/tax/refunds`, {
method: "POST",
headers,
body: JSON.stringify({
transaction_id: tx.id,
type: "partial",
line_items: [
{
reference_line_item_id: args.referenceLineItemId,
// Refund amounts MUST be negative.
sales_amount_refunded: -Math.abs(salesAmount),
tax_amount_refunded: -Math.abs(taxAmount),
quantity: args.quantityToRefund,
},
],
}),
})
if (!refundRes.ok) await throwSanitized("refundPartial", refundRes)
}
// ============================================================================
// Single webhook router
// ============================================================================
// Typical webhook handler — adapt to your framework (Express, Next.js, Hono).
// Always verify the signature with stripe.webhooks.constructEvent BEFORE
// calling this function.
export async function routeStripeWebhook(event: Stripe.Event): Promise<void> {
switch (event.type) {
case "payment_intent.succeeded":
await handlePaymentIntentSucceeded(
event.data.object as Stripe.PaymentIntent,
)
return
case "invoice.paid":
await handleInvoicePaid(event.data.object as Stripe.Invoice)
return
case "setup_intent.succeeded":
// Email/name come from your own state or stripe.customers.retrieve —
// pass them in from your handler via handleSetupIntentSucceeded.
return
case "payment_intent.canceled":
// Auth that never captured. Nothing to commit. Log + return.
return
case "invoice.voided":
case "invoice.marked_uncollectible":
// No commit on void. For uncollectible after payment, trigger refundLine
// for each line on the invoice.
return
default:
return
}
}
```
<!-- END stripe-elements-numeral.example.ts -->