SKILL.md) plus a complete, copy-and-adapt reference client in TypeScript.
How to use a skill
- Download and unzip the skill below.
- Drop the unzipped folder into your project’s
.claude/skills/directory (or attach theSKILL.mdto 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.
Download skill
integrating-numeral-tax-api.zip — SKILL.md + numeral-client.example.ts
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 tax_ids, exemption status, default address (`POST /tax/customers`)
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
`numeral-client.example.ts` (next to this file) implements calculate, idempotent commit, lookup, full + partial refund, and customer upsert in ~200 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.
## 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()
const err = await res.json()
const code = err.error?.error_code
if (code === "reference_order_id_already_exists" || code === "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} code=${err.error?.error_code ?? "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 upsert. 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)
`$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.
### 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 stored `tax_ids`
For intra-EU B2B with a valid VAT id, the calculation may return `total_tax_amount: 0`. That is correct (reverse-charge applies). Pre-create the customer once with `POST /tax/customers` storing the `tax_ids`, then reference `customer.id` on every later calculation. Do not re-send tax_ids on each calculation — it produces an inconsistent customer profile.
### 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 |
| 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) |
| 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 |
| Re-sending `tax_ids` on every calculation | Inconsistent stored customer profile | Upsert customer once, reference by id |
| 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.
Download skill
integrating-numeral-with-stripe-elements.zip — SKILL.md + stripe-elements-numeral.example.ts
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 upsert 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
Stripe and Numeral both use minor units (cents for USD/EUR/GBP/CAD, whole units for JPY/KRW). Stripe's `PaymentIntent.amount` and Numeral's `amount` field share this convention — you can pass values straight through without converting.
### 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()
const err = await res.json()
const code = err.error?.error_code
if (code === "reference_order_id_already_exists" || code === "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} code=${err.error?.error_code ?? "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, and often their billing address (via `<AddressElement>` in setup mode). Use that moment to upsert their tax profile 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({
id: stripeCustomerId, // cus_… if live-mode Stripe-Numeral connector is on; otherwise your own customer id
type: customerType, // "BUSINESS" or "CONSUMER"
address: { ... }, // from the AddressElement payload
tax_ids: [ ... ], // optional, only if collected
}),
})
```
If you skip this, the first PaymentIntent against this customer still works — but no exemptions, VAT/EIN handling, or default-address shortcuts will apply until you upsert.
## `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 upsert |
| 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 upsert ran; 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
`stripe-elements-numeral.example.ts` (next to this file) implements 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).