The TaxMySaaS API.
A TaxJar-compatible HTTPS surface for rates, tax calculation, transaction capture, nexus, projections, and webhooks. JSON in, JSON out, bearer-token auth.
Base URL: https://app.taxmysaas.com
On this page
Authentication
Every API request must include an Authorization header carrying a bearer token. Tokens look like tms_live_… or tms_test_… and identify both your team and the environment.
GET /api/v2/rates/94103 HTTP/1.1
Host: app.taxmysaas.com
Authorization: Bearer tms_live_xxxxxxxxxxxxxxxxxxxxxxxxTreat the secret like a password
Environments
live: counted against nexus rollups, transactions visible in the dashboard, webhooks fire.test: same endpoints and responses, but does not affect rollups and does not fire webhooks. Safe for CI and staging.
Errors
The API uses conventional HTTP status codes. Every error response carries a JSON body shaped like:
{
"error": {
"code": "invalid_request",
"message": "Invalid request body",
"status": 400,
"details": [{ "path": ["amount"], "message": "Required" }]
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Body or query failed validation. details lists Zod issues. |
| 400 | invalid_state | State code is missing or not supported. |
| 400 | invalid_zip | ZIP malformed or could not be mapped to a state. |
| 400 | invalid_country | Destination country is not US or an EU member state. See Roadmap. |
| 401 | unauthorized | Missing or invalid API key. |
| 403 | forbidden | Feature is not available on your tier (e.g. webhooks on free). |
| 404 | not_found | Resource (transaction, webhook, etc.) does not exist. |
| 409 | conflict | Duplicate resource (e.g. webhook URL already registered). |
| 429 | rate_limit_exceeded | Too many requests. Honor Retry-After. |
| 429 | usage_limit_exceeded | Free monthly transaction allowance reached. Upgrade plan. |
Optional _note field on success responses
Several endpoints can return successful responses with reduced or zero-valued data. When that happens, the response includes a _note: string describing the reason. _note is human-readable and is not machine-parseable. Use sibling typed fields (exemption_type, idempotent_replay, tax.amount_to_collect === 0, projection === null) for branching logic.
| Endpoint | When _note is set | Companion typed field |
|---|---|---|
| POST /api/v2/taxes | Request supplied exemption_type (including EU B2B reverse_charge), OR SaaS is not taxable in the destination state or country. | tax.exemption_type, tax.amount_to_collect === 0 |
| GET /api/v2/rates/:zip | SaaS is not taxable in the ZIP's state (or the EU country when ?country= is used). | rate.combined_rate === 0 (US), rate.standard_rate === 0 (EU) |
| POST /api/v2/transactions | Request collided with an existing external_id (idempotent replay). | idempotent_replay: true |
| GET /api/v2/projections/:state | Not enough history to produce a projection. | projection === null |
Note: on GET /api/v2/projections/:state the _note field is a top-level sibling of projection (not nested inside it) — the only such placement. Everywhere else _note sits inside the typed payload (tax._note, rate._note).
Rate limits and quotas
Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. When a per-second limit is hit, expect a 429 with a Retry-After header in seconds.
Separate monthly quotas apply to recorded transactions and total API calls. Both reset at the start of your billing period; see the portal billing page for the current numbers per plan.
Rates
Look up combined state, county, city, and special-district rates by ZIP code. Use for tax-rate display in checkout UIs or for sanity checks before calling /taxes.
GET /api/v2/rates/:zip
Optional query: state, city, country.
curl https://app.taxmysaas.com/api/v2/rates/37206 \
-H "Authorization: Bearer tms_live_..."{
"rate": {
"zip": "37206",
"state": "TN",
"state_rate": 0.07,
"county": "Davidson",
"county_rate": 0.0225,
"city": "Nashville",
"city_rate": 0,
"combined_district_rate": 0,
"combined_rate": 0.0925,
"freight_taxable": true
}
} Pass ?country= with an EU member state code to get the country-level standard VAT rate. The path segment is ignored for EU lookups (EU postal codes are not used for rate determination), so any placeholder value works. Only the standard rate is researched today; reduced-rate fields are always null.
curl "https://app.taxmysaas.com/api/v2/rates/00000?country=FR" \
-H "Authorization: Bearer tms_live_..."{
"rate": {
"country": "FR",
"name": "France",
"standard_rate": 0.2,
"reduced_rate": null,
"super_reduced_rate": null,
"parking_rate": null,
"distance_sale_threshold": null,
"freight_taxable": true
}
}GET /api/v2/summary_rates
Returns the minimum, average, and maximum tax rate for every US state, plus whether SaaS is taxable in that state, followed by one row per EU member state with its standard VAT rate (all three rate fields carry the same value and region_code is null). Handy for static reports or building a coverage map.
Calculate tax
Tell TaxMySaaS the amount, shipping, and destination address; get back the amount to collect plus a jurisdictional breakdown. SaaS taxability is applied automatically per state.
POST /api/v2/taxes
| Field | Type | Notes |
|---|---|---|
| to_country | string | Two-letter ISO code. US or one of the 27 EU member states. |
| to_state | string | Two-letter US state code (required for US destinations; ignored for EU). |
| to_zip | string | 5- or 9-digit US ZIP code (required for US destinations; optional for EU). |
| to_city | string? | Improves city-level accuracy for US destinations. |
| amount | number | Subtotal in major units (e.g. 199.00 for $199). |
| shipping | number | Defaults to 0. Treated as freight where states tax it; included in the VAT taxable amount for EU destinations. |
| exemption_type | string? | If set, the order is treated as exempt (zero tax). Use reverse_charge for qualifying EU B2B supplies. |
curl https://app.taxmysaas.com/api/v2/taxes \
-H "Authorization: Bearer tms_live_..." \
-H "Content-Type: application/json" \
-d '{
"to_country": "US",
"to_state": "CA",
"to_zip": "94103",
"to_city": "San Francisco",
"amount": 499.00,
"shipping": 0
}'{
"tax": {
"order_total_amount": 541.42,
"shipping": 0,
"taxable_amount": 499.00,
"amount_to_collect": 42.42,
"rate": 0.085,
"has_nexus": true,
"freight_taxable": true,
"tax_source": "destination",
"jurisdictions": {
"country": "US",
"state": "CA",
"county": "San Francisco",
"city": "San Francisco"
},
"breakdown": {
"state_tax_rate": 0.0725,
"state_tax_collectable": 36.18,
"county_tax_rate": 0.0125,
"county_tax_collectable": 6.24,
"combined_tax_rate": 0.085,
"tax_collectable": 42.42
}
}
}When SaaS is not taxable
amount_to_collect is 0 and a _note field explains why (see Optional _note field). You should still record the transaction so your nexus rollups capture the revenue. EU VAT (B2C and reverse charge)
When to_country is an EU member state, tax is calculated as destination-country VAT at that country's standard rate: SaaS is an electronically supplied service, so B2C supplies are taxed where the customer is located. to_state and to_zip are not required, the breakdown uses country-level fields (country_tax_rate, country_taxable_amount, country_tax_collectable), and shipping is included in the taxable amount.
curl https://app.taxmysaas.com/api/v2/taxes \
-H "Authorization: Bearer tms_live_..." \
-H "Content-Type: application/json" \
-d '{
"to_country": "DE",
"amount": 499.00
}'{
"tax": {
"order_total_amount": 593.81,
"shipping": 0,
"taxable_amount": 499.00,
"amount_to_collect": 94.81,
"rate": 0.19,
"has_nexus": true,
"freight_taxable": true,
"tax_source": "destination",
"jurisdictions": {
"country": "DE",
"state": null,
"city": null
},
"breakdown": {
"taxable_amount": 499.00,
"tax_collectable": 94.81,
"combined_tax_rate": 0.19,
"country_taxable_amount": 499.00,
"country_tax_rate": 0.19,
"country_tax_collectable": 94.81
}
}
} For a qualifying B2B supply to a VAT-registered business customer, pass exemption_type: "reverse_charge". The response collects zero VAT and the customer self-accounts under the reverse charge. Validating the customer's VAT ID (e.g. via VIES) is your responsibility — see the Roadmap.
No OSS filing or EU threshold tracking
Roadmap and non-US support
Current scope. US destination addresses (state and local sales tax) plus the 27 EU member states (destination-country VAT calculation — see EU VAT). Any other to_country value is rejected with HTTP 400 and code: "invalid_country".
{
"error": {
"code": "invalid_country",
"message": "Only US and EU member state destinations are currently supported",
"status": 400
}
}Non-goals (this product cycle)
- EU economic nexus / EUR 10,000 threshold monitoring, or OSS / IOSS registration and filing.
- VAT ID validation (VIES), EU reduced rates, and invoicing rules.
- GST (incl. Canadian GST/HST/PST), UK VAT / MTD, and other non-EU jurisdictions.
You can record US and EU transactions. Economic nexus tracking and projections remain US-only; EU sales do not update US nexus rollups. Each further jurisdiction requires its own data pipeline and SaaS-taxability research.
How to express interest
Email support@taxmysaas.com with your destination countries, monthly transaction volume, and whether you need calculation only or full nexus tracking. We use this list to size the work. We do not commit to a date until the quarter the work is queued.
Transactions
The transaction endpoints are how every recorded sale enters TaxMySaaS. They drive the audit ledger, US per-state nexus rollups, and EU B2C revenue totals on the dashboard.
POST /api/v2/transactions
Record a single sale for a US or EU customer destination. Returns the stored transaction. US sales include a fresh nexus_update for the customer state; EU sales return nexus_update: null (key present) because economic nexus remains US-only.
| Field | Type | Notes |
|---|---|---|
| customer.country | string? | Two-letter ISO code. Defaults to US. Accepts US or an EU member state. Required for disambiguating codes that collide with US states (e.g. DE = Germany vs Delaware). |
| customer.state | string? | Two-letter US state code. Required for US destinations; ignored/null for EU. |
| customer.zip | string? | Optional ZIP / postal code. |
| customer.city | string? | Optional city. |
| exemption_type | string? | Optional exemption marker (e.g. reverse_charge for EU B2B). When set, the sale is excluded from EU B2C revenue rollups. Omit for normal B2C. |
| amount | number | Subtotal before tax (major currency units). |
| tax | number | Tax collected on the sale. |
| external_id | string | Your idempotency key (unique per team). |
curl https://app.taxmysaas.com/api/v2/transactions \
-H "Authorization: Bearer tms_live_..." \
-H "Content-Type: application/json" \
-d '{
"transaction_date": "2026-05-23",
"external_id": "inv_7c4f9a",
"customer": { "country": "US", "state": "TX", "city": "Austin", "zip": "78701" },
"amount": 199.00,
"tax": 12.43,
"description": "Scale Plan - Monthly",
"metadata": { "billing_invoice": "in_1OabcXyZ" }
}'{
"transaction": {
"id": "txn_a1b2c3...",
"team_id": "team_...",
"transaction_date": "2026-05-23",
"external_id": "inv_7c4f9a",
"customer_country": "US",
"customer_state": "TX",
"customer_city": "Austin",
"customer_zip": "78701",
"currency": "USD",
"exemption_type": null,
"subtotal": 199.00,
"tax": 12.43,
"total": 211.43,
"tax_rate": 0.0625,
"taxable": true,
"created_at": "2026-05-23T18:42:11.000Z"
},
"nexus_update": {
"state": "TX",
"status": "approaching",
"revenue_percentage": 82.4
}
}curl https://app.taxmysaas.com/api/v2/transactions \
-H "Authorization: Bearer tms_live_..." \
-H "Content-Type: application/json" \
-d '{
"transaction_date": "2026-05-23",
"external_id": "inv_eu_de_01",
"customer": { "country": "DE", "city": "Berlin" },
"amount": 99.00,
"tax": 18.81,
"description": "Pro Plan - Monthly"
}'{
"transaction": {
"id": "txn_e1f2g3...",
"team_id": "team_...",
"transaction_date": "2026-05-23",
"external_id": "inv_eu_de_01",
"customer_country": "DE",
"customer_state": null,
"customer_city": "Berlin",
"customer_zip": null,
"currency": "EUR",
"exemption_type": null,
"subtotal": 99.00,
"tax": 18.81,
"total": 117.81,
"tax_rate": 0.19,
"taxable": true,
"created_at": "2026-05-23T18:42:11.000Z"
},
"nexus_update": null
}external_id is your idempotency key
external_id twice returns the original transaction without inserting a duplicate. Use your internal invoice ID — never a random nonce. Distinguishing fresh inserts from replays
200 and includes idempotent_replay: true alongside the human-readable _note. Branch on the boolean (idempotent_replay) rather than parsing _note. Fresh US inserts include a nexus_update object; fresh EU inserts include nexus_update: null and never carry idempotent_replay. GET /api/v2/transactions
List transactions, cursor-paginated. Up to 100 per page.
| Query | Notes |
|---|---|
| state | Two-letter US state filter. Alone implies customer_country=US and that state (so state=DE is Delaware, not Germany). |
| country | Two-letter country filter (e.g. DE for Germany). Alone filters by country; combined with state both conditions apply (AND). |
| start_date / end_date | ISO date range. |
| limit | 1–100. Defaults to 50. |
| cursor | From pagination.next_cursor. |
| order | asc or desc (default desc). |
GET /api/v2/transactions/:id
Retrieve a single transaction by id.
POST /api/v2/transactions/import
Backfill historical sales. Send up to 1,000 transactions in one call. The endpoint has well-defined per-row, per-state, and per-window semantics — read these before doing a large backfill:
- Inserts are atomic per request. All rows in one HTTP call run inside a single DB transaction. Row-level validation or DB-constraint failures are recorded in
errors[]; the rest of the batch continues. Inserts that succeeded still commit together. - Idempotency by
external_id. Rows whoseexternal_idalready exists for the team are skipped (counted inskipped), not errored. - Usage reservation is atomic with the inserts. The transaction quota is reserved inside the same DB transaction. You cannot import more than your remaining monthly quota in a single call.
- Nexus rollups are recomputed per affected state after commit. For each distinct
customer.statein the batch, the server recomputesnexus_summaryover the trailing 12 months. There are no per-month buckets in TaxMySaaS —nexus_summaryis a single recomputed snapshot per(team, state). - Trailing 12-month window. Transactions dated more than 12 months before the request will be stored but will NOT contribute to current
nexus_summarypercentages. Cross-link: see the Nexus section. - Concurrent imports. Two parallel imports touching the same
(team, state)race on the rollup recompute; last write wins. Data is not lost; the snapshot may be briefly stale. Serialize very large parallel imports per state if you need strict reads. - Webhooks fire only on status worsening. A
nexus.approachingornexus.exceededevent fires only when status transitions to a strictly more severe tier. Same-tier re-crossings and downgrades do NOT fire. Backfilling? Treatprevious_statusas advisory and callGET /api/v2/nexusfor ground truth. skip_nexus_update: trueskips step 4 entirely. After your full backfill, trigger a recompute from the dashboard "Recalculate nexus" action.
Trailing 12-month window is from request time
Nexus
Per-state nexus status, trailing-twelve-month revenue, and transaction counts.
All rollups are computed against the trailing 12 months ending at request time. Older transactions are stored but do not contribute to current.revenue / current.transactions. Recomputes overwrite the existing nexus_summary row for (team, state); concurrent recomputes for the same (team, state) are last-write-wins.
Teams can override or disable a state's default economic-nexus thresholds from the dashboard (state detail → Edit thresholds). thresholds in nexus and projection responses always reflects the effective values, and thresholds.custom: true flags states where a team override is applied. Status, alerts, and projections all use the effective thresholds.
GET /api/v2/nexus
List all nexus states with summary. Optional filters: status (monitoring, approaching, exceeded, registered), sort (urgency, state, revenue, transactions).
{
"nexus_states": [
{
"state_code": "WA",
"state_name": "Washington",
"status": "exceeded",
"thresholds": { "revenue": 100000, "transactions": null, "custom": false },
"current": { "revenue": 118420.12, "transactions": 41 },
"percentages": { "revenue": 118.4, "transactions": null }
}
],
"summary": {
"total_states_with_sales": 12,
"monitoring": 8,
"approaching": 2,
"exceeded": 1,
"registered": 1
}
}GET /api/v2/nexus/:state
Detail for one state, including the revenue and transaction breakdown.
The response includes a monthly_breakdown array: a zero-filled trailing 12-month series (UTC month buckets) in ascending order. Each entry has month (YYYY-MM), revenue (dollars), and transaction_count. Months with no sales are returned with revenue: 0 and transaction_count: 0.
POST /api/v2/nexus/:state
Mark a state as registered. Set status: "registered" and registered_date. Fires the nexus.registered webhook.
curl -X POST https://app.taxmysaas.com/api/v2/nexus/CA \
-H "Authorization: Bearer tms_live_..." \
-H "Content-Type: application/json" \
-d '{ "status": "registered", "registered_date": "2026-04-15", "notes": "Filed via CDTFA" }'DELETE /api/v2/nexus/:state
Clear a registration. Use only if a state was registered by mistake.
Projections
Forward estimates: when each state is projected to cross its economic-nexus threshold based on recent run-rate.
GET /api/v2/projections
All states with an active projection.
GET /api/v2/projections/:state
One state, including a small monthly forecast series.
Webhooks
Get HTTPS callbacks when transactions are recorded or nexus status changes. Available on Growth and above.
POST /api/v2/webhooks
Register an endpoint. Returns the secret exactly once; store it alongside the URL. Internal addresses are rejected.
curl https://app.taxmysaas.com/api/v2/webhooks \
-H "Authorization: Bearer tms_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.acme.com/hooks/taxmysaas",
"events": ["nexus.approaching", "nexus.exceeded", "nexus.registered"]
}'
{
"webhook": {
"id": "whk_...",
"url": "https://api.acme.com/hooks/taxmysaas",
"events": ["nexus.approaching", "nexus.exceeded", "nexus.registered"],
"enabled": true,
"secret": "whsec_..." // shown once
}
}Event types
transaction.created— payload includes the full transaction.nexus.approaching— fires when a state crosses 80%.nexus.exceeded— fires when a state crosses 100%.nexus.registered— fires when you mark a state registered.*— subscribe to all four events above.
The following events are email-only account notifications and are NOT delivered via webhook subscription: webhook.failing, webhook.disabled, usage.warning, usage.limit. Subscribing to * does not deliver them.
Payload schemas
Every delivery uses a common envelope. The data shape is discriminated on event. Machine-readable JSON Schema is published at the URLs below — they are public, unauthenticated, served with Content-Type: application/schema+json, and cached for 5 minutes with a stable ETag (use If-None-Match to revalidate). The body is plain JSON if your tooling does not negotiate the schema+json variant.
Test deliveries are NOT covered by this schema. The dashboard "Send test webhook" button ships event: "test" with a small { message, timestamp } payload — handy for end-to-end smoke tests, but it deliberately does not match any published shape. Branch on the wh_evt_test_ / del_test_ id prefix (see Idempotency and identifiers below) and skip schema validation for those.
{
"id": "wh_evt_aB12cD34eF56",
"event": "nexus.approaching",
"created_at": "2026-05-23T18:42:11.000Z",
"team_id": "team_xyz",
"data": {
"state": "TX",
"status": "approaching",
"previous_status": "monitoring",
"revenue_percentage": 82.4,
"transaction_percentage": null
}
}GET /api/v2/schemas/webhooks.json— full discriminated union.GET /api/v2/schemas/webhooks/nexus.approaching.json— per-event.GET /api/v2/schemas/webhooks/nexus.exceeded.json— per-event.GET /api/v2/schemas/webhooks/nexus.registered.json— per-event (shape differs).GET /api/v2/schemas/webhooks/transaction.created.json— per-event.
| Event | data shape |
|---|---|
| nexus.approaching | { state, status, previous_status, revenue_percentage, transaction_percentage } |
| nexus.exceeded | { state, status, previous_status, revenue_percentage, transaction_percentage } |
| nexus.registered | { state, status, registered_date } — NOT the percentages shape |
| transaction.created | { transaction: <Transaction> } |
Idempotency and identifiers
Each delivery carries two stable identifiers with different scopes. Pick the right one for your use case.
| Field | Where | Stable across | Use for |
|---|---|---|---|
| X-TaxMySaaS-Delivery-Id | HTTP header | QStash retries of THIS delivery (same endpoint, same event) | Recommended idempotency key. Dedup HTTP retries to one endpoint. |
payload.id (wh_evt_*) | JSON body | All subscribed endpoints for one logical event AND across retries | Cross-endpoint correlation. Log → audit-log joins. |
Recommended pattern: store X-TaxMySaaS-Delivery-Id in a Redis-style TTL set with a 7-day TTL. On duplicate, return 200 without re-processing.
Hashing the request body is unnecessary and brittle — both identifiers above are deterministic across our retries.
ID prefix contract. Production event ids are prefixed wh_evt_; deliveries are prefixed del_. The dashboard "Send test webhook" button uses wh_evt_test_ and del_test_. Test deliveries never affect nexus rollups. You can route test traffic by prefix-matching either id.
Behavior change — May 2026
payload.id (wh_evt_*) is generated once per logical event and shared across every subscribed endpoint. Previously each endpoint received a distinct payload.id for the same event. If your integration dedups multiple endpoints on payload.id, switch to X-TaxMySaaS-Delivery-Id to keep per-endpoint dedup. Single-endpoint integrations are unaffected. Verifying signatures
Every delivery carries an X-TaxMySaaS-Signature header containing an HMAC-SHA256 of the raw request body, computed with your endpoint secret. Compute the same HMAC server-side and compare in constant time.
import crypto from 'node:crypto'
function verifySignature(rawBody: string, signature: string, secret: string) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
)
}Endpoint failure backoff
API Settings → Webhooks to retry. Other endpoints
GET /api/v2/webhooks— list endpoints.GET /api/v2/webhooks/:id— fetch one endpoint.PATCH /api/v2/webhooks/:id— update URL, events, enabled flag.DELETE /api/v2/webhooks/:id— remove an endpoint.
