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

# SKILL

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