API reference

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.

Authorization headerhttp
GET /api/v2/rates/94103 HTTP/1.1
Host: app.taxmysaas.com
Authorization: Bearer tms_live_xxxxxxxxxxxxxxxxxxxxxxxx

Treat the secret like a password

The token grants full access to your team's tax data. Store it in a secret manager, never commit it to source control, and rotate whenever a teammate leaves.

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 responsejson
{
  "error": {
    "code": "invalid_request",
    "message": "Invalid request body",
    "status": 400,
    "details": [{ "path": ["amount"], "message": "Required" }]
  }
}
StatusCodeMeaning
400invalid_requestBody or query failed validation. details lists Zod issues.
400invalid_stateState code is missing or not supported.
400invalid_zipZIP malformed or could not be mapped to a state.
400invalid_countryDestination country is not US or an EU member state. See Roadmap.
401unauthorizedMissing or invalid API key.
403forbiddenFeature is not available on your tier (e.g. webhooks on free).
404not_foundResource (transaction, webhook, etc.) does not exist.
409conflictDuplicate resource (e.g. webhook URL already registered).
429rate_limit_exceededToo many requests. Honor Retry-After.
429usage_limit_exceededFree 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.

EndpointWhen _note is setCompanion typed field
POST /api/v2/taxesRequest 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/:zipSaaS 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/transactionsRequest collided with an existing external_id (idempotent replay).idempotent_replay: true
GET /api/v2/projections/:stateNot 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.

Requestbash
curl https://app.taxmysaas.com/api/v2/rates/37206 \
    -H "Authorization: Bearer tms_live_..."
Response 200json
{
  "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.

Request (EU)bash
curl "https://app.taxmysaas.com/api/v2/rates/00000?country=FR" \
    -H "Authorization: Bearer tms_live_..."
Response 200 (EU)json
{
  "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

FieldTypeNotes
to_countrystringTwo-letter ISO code. US or one of the 27 EU member states.
to_statestringTwo-letter US state code (required for US destinations; ignored for EU).
to_zipstring5- or 9-digit US ZIP code (required for US destinations; optional for EU).
to_citystring?Improves city-level accuracy for US destinations.
amountnumberSubtotal in major units (e.g. 199.00 for $199).
shippingnumberDefaults to 0. Treated as freight where states tax it; included in the VAT taxable amount for EU destinations.
exemption_typestring?If set, the order is treated as exempt (zero tax). Use reverse_charge for qualifying EU B2B supplies.
Requestbash
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
    }'
Response 200json
{
  "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

In states where 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.

Request (EU B2C)bash
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
    }'
Response 200 (EU B2C)json
{
  "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

EU support covers rate lookup, checkout-time calculation, and transaction recording (with per-country B2C revenue on the dashboard). TaxMySaaS does not monitor the EU-wide EUR 10,000 threshold, register you for OSS, or file OSS returns. Economic nexus tracking remains US-only. The EUR 10,000 threshold only applies to suppliers established in one EU member state; non-EU suppliers generally owe destination VAT from the first B2C sale.

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

Response 400 for unsupported countryjson
{
  "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.

FieldTypeNotes
customer.countrystring?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.statestring?Two-letter US state code. Required for US destinations; ignored/null for EU.
customer.zipstring?Optional ZIP / postal code.
customer.citystring?Optional city.
exemption_typestring?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.
amountnumberSubtotal before tax (major currency units).
taxnumberTax collected on the sale.
external_idstringYour idempotency key (unique per team).
Request (US)bash
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" }
    }'
Response 200 (US)json
{
  "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
  }
}
Request (EU B2C)bash
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"
    }'
Response 200 (EU B2C)json
{
  "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

POSTing the same 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

On idempotent replay the response is 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.

QueryNotes
stateTwo-letter US state filter. Alone implies customer_country=US and that state (so state=DE is Delaware, not Germany).
countryTwo-letter country filter (e.g. DE for Germany). Alone filters by country; combined with state both conditions apply (AND).
start_date / end_dateISO date range.
limit1–100. Defaults to 50.
cursorFrom pagination.next_cursor.
orderasc 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:

  1. 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.
  2. Idempotency by external_id. Rows whose external_id already exists for the team are skipped (counted in skipped), not errored.
  3. 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.
  4. Nexus rollups are recomputed per affected state after commit. For each distinct customer.state in the batch, the server recomputes nexus_summary over the trailing 12 months. There are no per-month buckets in TaxMySaaS — nexus_summary is a single recomputed snapshot per (team, state).
  5. Trailing 12-month window. Transactions dated more than 12 months before the request will be stored but will NOT contribute to current nexus_summary percentages. Cross-link: see the Nexus section.
  6. 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.
  7. Webhooks fire only on status worsening. A nexus.approaching or nexus.exceeded event fires only when status transitions to a strictly more severe tier. Same-tier re-crossings and downgrades do NOT fire. Backfilling? Treat previous_status as advisory and call GET /api/v2/nexus for ground truth.
  8. skip_nexus_update: true skips step 4 entirely. After your full backfill, trigger a recompute from the dashboard "Recalculate nexus" action.

Trailing 12-month window is from request time

If you import a year of historical orders, your nexus percentages will reflect all of them. If you import five years, only the most recent twelve months affect rollups.

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

Response 200json
{
  "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.

Mark CA as registeredbash
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.

Register a webhookbash
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.

Envelope (all events)json
{
  "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.
Eventdata 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.

FieldWhereStable acrossUse for
X-TaxMySaaS-Delivery-IdHTTP headerQStash retries of THIS delivery (same endpoint, same event)Recommended idempotency key. Dedup HTTP retries to one endpoint.
payload.id (wh_evt_*)JSON bodyAll subscribed endpoints for one logical event AND across retriesCross-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

As of this release the 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.

Node.js verificationts
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

After repeated 5xx or timeout responses, TaxMySaaS pauses deliveries to the endpoint and exposes the failure count in the portal. Fix your handler, then click Resume in 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.

Companion guides