> ## Documentation Index
> Fetch the complete documentation index at: https://docs.numeral.com/llms.txt
> Use this file to discover all available pages before exploring further.

> 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.

# SKILL

# 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 theme={null}
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 theme={null}
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 theme={null}
{ 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 theme={null}
// 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).
