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

# Using the Customers APIs

> How to create and manage customers, sync them in bulk, read their exemptions, and use them as presets in calculations.

Save a customer, then reference it as a **preset** in calculations and transactions. Taxwire applies the exemption certificates on file for that buyer.

Certificates are added in the [Taxwire App](https://app.taxwire.com) and returned on the customer as a read-only `exemption` block. See [Customer exemptions](/api-reference/guides/exemptions) for how they affect tax.

Customers synced by an integration (Stripe, QuickBooks, NetSuite, and so on) already exist in Taxwire. Reference them by the platform's customer id instead of creating them here. If you do create one, match the integration's `source` and `source_id` to avoid a duplicate.

***

## Simple lifecycle

Use this when you manage customers one at a time, for example when a buyer signs up in your product.

### 1. Create the customer

Send a `POST /customers` with the only required field, `source_id`: your own identifier for the customer. Add `name` and `email` if you have them. `source` defaults to `taxwire` and `source_account_id` defaults to `__default__`, which is right for customers you manage directly through this API.

```shell cURL theme={null}
curl --request POST \
  --url https://api.taxwire.com/customers \
  --header 'Content-Type: application/json' \
  --header 'Taxwire-Version: hydrogen.2025-04-15' \
  --header 'X-API-Key: YOUR_API_KEY' \
  --data '{
    "source_id": "CUS-1042",
    "name": "Acme Wholesale",
    "email": "ap@acme.example"
  }'
```

```python Python theme={null}
import requests

url = "https://api.taxwire.com/customers"
headers = {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "Content-Type": "application/json",
    "X-API-Key": "YOUR_API_KEY"
}
payload = {
    "source_id": "CUS-1042",
    "name": "Acme Wholesale",
    "email": "ap@acme.example"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
```

```typescript TypeScript theme={null}
const response = await fetch("https://api.taxwire.com/customers", {
  method: "POST",
  headers: {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "Content-Type": "application/json",
    "X-API-Key": "YOUR_API_KEY",
  },
  body: JSON.stringify({
    source_id: "CUS-1042",
    name: "Acme Wholesale",
    email: "ap@acme.example",
  }),
});
const data = await response.json();
console.log(data);
```

The response includes Taxwire's stable `id` for the customer.

```json Response (201) theme={null}
{
  "id": "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9",
  "source_id": "CUS-1042",
  "source": "taxwire",
  "source_account_id": "__default__",
  "name": "Acme Wholesale",
  "email": "ap@acme.example",
  ...
}
```

> [!NOTE]
>
> A customer is unique on `source` + `source_account_id` + `source_id`. Creating one that already exists returns `409`, with `meta.existing_customer_id` and `meta.existing_customer_path` pointing at the existing record. If the customer was deleted in the Taxwire app, `meta.deleted` is `true` and there is no path.

### 2. Reference the customer as a preset in a calculation

Pass the customer's `source_id` and `source` in `customer.preset` on a line item. Taxwire looks up the customer and derives the customer's tax attributes from its active exemption certificates.

```json Line item using a customer preset theme={null}
{
  "id": "item_1",
  "quantity": "2",
  "unit_price": "1500",
  "product": {
    "tax_attributes": ["TANGIBLE_PERSONAL_PROPERTY._RESALE"]
  },
  "customer": {
    "address": {
      "line1": "123 Main St",
      "city": "Austin",
      "state": "TX",
      "country": "US",
      "postal_code": "78701"
    },
    "preset": {
      "id": "CUS-1042",
      "source": "taxwire"
    }
  }
}
```

> [!WARNING]
>
> `preset.id` is your `source_id` (`CUS-1042`), not the `cust_...` id Taxwire returns. If your organization has more than one account on the source platform, also pass `preset.source_account_id`. Without it, an id held in several accounts matches no customer and the line is taxed.

Two settings on your organization decide whether that certificate changes the tax:

* **Exemption management** must be turned on. While it is off, certificates are ignored and the line is taxed. [Contact support](mailto:support@taxwire.com) to enable it.
* **Certificate treatment** decides whether the line's product also has to carry the matching attribute, such as `_RESALE`. With "Certificate only", the certificate alone exempts every sale in the states it covers. With "Certificate and product tax code", only products that carry the attribute are exempt. Sending `_RESALE` on the line, as above, works in both modes.

See [Customer exemptions](/api-reference/guides/exemptions) for the full picture.

### 3. Update the customer when it changes

`PATCH /customers/{customer_id}` is a partial update. Omit a field, or send `null`, to keep its current value.

```shell cURL theme={null}
curl --request PATCH \
  --url https://api.taxwire.com/customers/cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9 \
  --header 'Content-Type: application/json' \
  --header 'Taxwire-Version: hydrogen.2025-04-15' \
  --header 'X-API-Key: YOUR_API_KEY' \
  --data '{
    "name": "Acme Wholesale Inc.",
    "source_updated_at": "2026-09-15T08:30:00Z"
  }'
```

```python Python theme={null}
import requests

customer_id = "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9"
url = f"https://api.taxwire.com/customers/{customer_id}"
headers = {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "Content-Type": "application/json",
    "X-API-Key": "YOUR_API_KEY"
}
payload = {
    "name": "Acme Wholesale Inc.",
    "source_updated_at": "2026-09-15T08:30:00Z"
}
response = requests.patch(url, json=payload, headers=headers)
print(response.json())
```

```typescript TypeScript theme={null}
const customerId = "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9";
const response = await fetch(`https://api.taxwire.com/customers/${customerId}`, {
  method: "PATCH",
  headers: {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "Content-Type": "application/json",
    "X-API-Key": "YOUR_API_KEY",
  },
  body: JSON.stringify({
    name: "Acme Wholesale Inc.",
    source_updated_at: "2026-09-15T08:30:00Z",
  }),
});
const data = await response.json();
console.log(data);
```

> [!WARNING]
>
> `source_updated_at` can't be cleared or moved to an older date. Omit it if you don't track source-system change timestamps.

### 4. Check what exempts the customer

`GET /customers/{customer_id}` returns the customer with a read-only `exemption` block listing the approved certificates on file, one row per jurisdiction.

```shell cURL theme={null}
curl --request GET \
  --url https://api.taxwire.com/customers/cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9 \
  --header 'Taxwire-Version: hydrogen.2025-04-15' \
  --header 'X-API-Key: YOUR_API_KEY'
```

```python Python theme={null}
import requests

customer_id = "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9"
url = f"https://api.taxwire.com/customers/{customer_id}"
headers = {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "X-API-Key": "YOUR_API_KEY"
}
response = requests.get(url, headers=headers)
customer = response.json()

# Covered if any row for the jurisdiction is currently valid.
covered = any(
    row["jurisdiction"] == "USA.S-CA" and row["status"] == "valid"
    for row in customer["exemption"]["jurisdictions"]
)
print(covered)
```

```typescript TypeScript theme={null}
const customerId = "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9";
const response = await fetch(`https://api.taxwire.com/customers/${customerId}`, {
  headers: {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "X-API-Key": "YOUR_API_KEY",
  },
});
const customer = await response.json();

// Covered if any row for the jurisdiction is currently valid.
const covered = customer.exemption.jurisdictions.some(
  (row: { jurisdiction: string; status: string }) =>
    row.jurisdiction === "USA.S-CA" && row.status === "valid"
);
console.log(covered);
```

```json Response (excerpt) theme={null}
{
  "id": "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9",
  "source_id": "CUS-1042",
  "name": "Acme Wholesale Inc.",
  "exemption": {
    "overrides": { "exempt": false, "resale": false, "manufacturing": false },
    "jurisdictions": [
      {
        "jurisdiction": "USA.S-CA",
        "jurisdiction_name": "California",
        "exemption_type": "resale",
        "status": "valid",
        "starts_at": "2025-04-01",
        "expires_at": "2027-03-31",
        "certificate_id": "cexc_01J9Z8Q4E7K2M3N4P5R6S7T8U9"
      }
    ],
    "updated_at": "2026-09-14T20:00:00Z"
  }
}
```

> [!NOTE]
>
> `exemption` is a snapshot of what is on file, not a promise that a sale is exempt. To get the tax for a sale, run a [calculation](/api-reference/calculations/create). See [Reading a customer's exemptions](/api-reference/guides/exemptions#reading-a-customers-exemptions) for what each field means, including renewed and expired certificates.

***

## Bulk sync lifecycle

Use this when you keep customers in your own system and want Taxwire to mirror them, for example an initial import or a nightly sync.

### 1. Upsert in batches

`POST /customers/batch` takes up to 1000 customers, and up to 5 MB, in a `data` array. Each is matched on `source` + `source_account_id` + `source_id`: an existing customer is updated and a new one is created, so you can safely send the same request again.

This example updates one customer, creates one, and includes a third with no `source_id` so you can see what a failure looks like.

```shell cURL theme={null}
curl --request POST \
  --url https://api.taxwire.com/customers/batch \
  --header 'Content-Type: application/json' \
  --header 'Taxwire-Version: hydrogen.2025-04-15' \
  --header 'X-API-Key: YOUR_API_KEY' \
  --data '{
    "data": [
      {
        "source_id": "CUS-1042",
        "name": "Acme Wholesale Inc.",
        "source_updated_at": "2026-09-15T08:30:00Z"
      },
      {
        "source_id": "CUS-1043",
        "name": "Beta Corp",
        "email": "billing@beta.example",
        "source_updated_at": "2026-09-15T08:29:41Z"
      },
      {
        "name": "Gamma Supply"
      }
    ]
  }'
```

```python Python theme={null}
import requests

url = "https://api.taxwire.com/customers/batch"
headers = {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "Content-Type": "application/json",
    "X-API-Key": "YOUR_API_KEY"
}
payload = {
    "data": [
        {
            "source_id": "CUS-1042",
            "name": "Acme Wholesale Inc.",
            "source_updated_at": "2026-09-15T08:30:00Z"
        },
        {
            "source_id": "CUS-1043",
            "name": "Beta Corp",
            "email": "billing@beta.example",
            "source_updated_at": "2026-09-15T08:29:41Z"
        },
        {"name": "Gamma Supply"}
    ]
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
```

```typescript TypeScript theme={null}
const response = await fetch("https://api.taxwire.com/customers/batch", {
  method: "POST",
  headers: {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "Content-Type": "application/json",
    "X-API-Key": "YOUR_API_KEY",
  },
  body: JSON.stringify({
    data: [
      {
        source_id: "CUS-1042",
        name: "Acme Wholesale Inc.",
        source_updated_at: "2026-09-15T08:30:00Z",
      },
      {
        source_id: "CUS-1043",
        name: "Beta Corp",
        email: "billing@beta.example",
        source_updated_at: "2026-09-15T08:29:41Z",
      },
      { name: "Gamma Supply" },
    ],
  }),
});
const data = await response.json();
console.log(data);
```

> [!WARNING]
>
> If the same customer appears more than once in one request, every copy fails. De-duplicate on `source` + `source_account_id` + `source_id` before you send. A request over the limits returns `413`.

### 2. Read the per-customer results

Each customer succeeds or fails on its own, and the request returns `200` even when some customers fail. `data` is in the same order as the request, and `summary` counts each outcome.

```json Response (200, excerpt) theme={null}
{
  "object": "batch",
  "summary": { "created": 1, "updated": 1, "stale": 0, "deleted": 0, "failed": 1 },
  "data": [
    { "index": 0, "status": "updated", "customer": { "id": "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9", ... } },
    { "index": 1, "status": "created", "customer": { "id": "cust_01J9ZB7V3M8N2K4P6Q1R5S7T9W", ... } },
    {
      "index": 2,
      "status": "failed",
      "errors": [
        {
          "status": "422",
          "code": "validation_required_field",
          "title": "Missing source_id",
          "source": { "pointer": "/data/attributes/data/2/source_id" }
        }
      ]
    }
  ]
}
```

| `status` | Meaning |
| - | - |
| `created` | A new customer was created. |
| `updated` | An existing customer was updated. |
| `stale` | Ignored, because its `source_updated_at` is older than the one Taxwire has. The current customer is returned. |
| `deleted` | Matched a customer deleted in the Taxwire app, so nothing changed. |
| `failed` | Rejected. `errors` says why. Fix it and resend just that customer. |

### 3. Walk the full list when you need to reconcile

`GET /customers` returns customers newest first, with no filters. Page with `limit` and `starting_after`, passing the last customer's `id`, until `has_more` is `false`.

```shell cURL theme={null}
curl --request GET \
  --url 'https://api.taxwire.com/customers?limit=100&starting_after=cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9' \
  --header 'Taxwire-Version: hydrogen.2025-04-15' \
  --header 'X-API-Key: YOUR_API_KEY'
```

```python Python theme={null}
import requests

url = "https://api.taxwire.com/customers"
headers = {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "X-API-Key": "YOUR_API_KEY"
}
params = {"limit": 100, "starting_after": "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9"}
response = requests.get(url, params=params, headers=headers)
print(response.json())
```

```typescript TypeScript theme={null}
const params = new URLSearchParams({
  limit: "100",
  starting_after: "cust_01J9Z8Q4E7K2M3N4P5R6S7T8U9",
});
const response = await fetch(`https://api.taxwire.com/customers?${params}`, {
  headers: {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "X-API-Key": "YOUR_API_KEY",
  },
});
const data = await response.json();
console.log(data);
```

> [!NOTE]
>
> There is no way to look a customer up by `source_id` yet. To find one, page through the whole list and filter on your side, or keep the `id` from the create or batch response.

***

## Deleting a customer

`DELETE /customers/{customer_id}` removes the customer and its exemption certificates, and returns `204` with no body. It also returns `204` when the customer was already deleted, so a retry is safe.

A deleted customer keeps its `source`, `source_account_id` and `source_id`, so creating it again returns `409` with `meta.deleted: true`, and a batch upsert reports it as `deleted`. Restore it in the Taxwire App instead.

***

## Key concepts

| Concept | Detail |
| - | - |
| `source` + `source_account_id` + `source_id` | Your external identifier for a customer. Use `source: "taxwire"` and `source_account_id: "__default__"` for customers you manage directly through this API. |
| `id` | Taxwire's stable `cust_...` identifier. Use it in the path of retrieve, update and delete. Calculations use the source triple through `customer.preset` instead. |
| `source_updated_at` | When the customer last changed in your system. Older values are ignored, so out-of-order syncs can't overwrite newer data. Recommended for every source except `taxwire`. |
| PATCH semantics | Partial update. Omit a field, or send `null`, to leave it unchanged. |
| `exemption` | Read-only. Managed in the Taxwire App, not through this API. Does not include certificates still in review or rejected. |
| `merged_into_customer_id` | Set when the source system merged this customer into another. It no longer gets new sales. |

***

## Putting it all together

Complete nightly sync of customers from your own system. The script:

1. Splits your customers into batches of up to 1000 (de-duplicate them first).
2. Upserts each batch with `POST /customers/batch`, sending `source_updated_at` so replays and out-of-order runs are safe.
3. Retries on `429` and `5xx`, honoring `Retry-After`.
4. Collects per-customer failures instead of stopping, and prints a summary.

```python Python theme={null}
import time
import requests

BASE_URL = "https://api.taxwire.com"
HEADERS = {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "Content-Type": "application/json",
    "X-API-Key": "YOUR_API_KEY",
}
BATCH_SIZE = 1000


def upsert_batch(batch: list[dict], max_attempts: int = 5) -> dict:
    """POST one batch, backing off on rate limits and transient errors."""
    for attempt in range(max_attempts):
        resp = requests.post(f"{BASE_URL}/customers/batch", json={"data": batch}, headers=HEADERS)
        if resp.status_code == 429 or resp.status_code >= 500:
            wait = float(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)
            continue
        resp.raise_for_status()
        return resp.json()
    raise RuntimeError(f"Batch failed after {max_attempts} attempts")


# --- step 1: load customers from your system ---

customers = [
    {
        "source_id": "CUS-1042",
        "name": "Acme Wholesale Inc.",
        "email": "ap@acme.example",
        "source_updated_at": "2026-09-15T08:30:00Z",
    },
    {
        "source_id": "CUS-1043",
        "name": "Beta Corp",
        "email": "billing@beta.example",
        "source_updated_at": "2026-09-15T08:29:41Z",
    },
    # ... thousands more
]

# --- step 2: upsert in batches ---

totals = {"created": 0, "updated": 0, "stale": 0, "deleted": 0, "failed": 0}
failures: list[dict] = []

for start in range(0, len(customers), BATCH_SIZE):
    batch = customers[start:start + BATCH_SIZE]
    result = upsert_batch(batch)

    for key, count in result["summary"].items():
        totals[key] += count

    # --- step 3: collect failures, keyed to your own records ---
    for item in result["data"]:
        if item["status"] == "failed":
            failures.append({
                "source_id": batch[item["index"]].get("source_id"),
                "errors": item["errors"],
            })

    print(f"  batch {start // BATCH_SIZE + 1}: {result['summary']}")

print(f"\nDone: {totals}")
for failure in failures:
    print(f"  ✗ {failure['source_id']}: {failure['errors'][0]['title']}")
```

```typescript TypeScript theme={null}
const BASE_URL = "https://api.taxwire.com";
const HEADERS = {
  "Taxwire-Version": "hydrogen.2025-04-15",
  "Content-Type": "application/json",
  "X-API-Key": "YOUR_API_KEY",
};
const BATCH_SIZE = 1000;

interface CustomerInput {
  source_id: string;
  name?: string;
  email?: string;
  source_updated_at?: string;
}

interface BatchResult {
  summary: Record<"created" | "updated" | "stale" | "deleted" | "failed", number>;
  data: {
    index: number;
    status: string;
    errors?: { title: string }[];
  }[];
}

// POST one batch, backing off on rate limits and transient errors.
async function upsertBatch(batch: CustomerInput[], maxAttempts = 5): Promise<BatchResult> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const res = await fetch(`${BASE_URL}/customers/batch`, {
      method: "POST",
      headers: HEADERS,
      body: JSON.stringify({ data: batch }),
    });
    if (res.status === 429 || res.status >= 500) {
      const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }
    if (!res.ok) throw new Error(`POST /customers/batch failed: ${res.status}`);
    return res.json();
  }
  throw new Error(`Batch failed after ${maxAttempts} attempts`);
}

// --- step 1: load customers from your system ---

const customers: CustomerInput[] = [
  {
    source_id: "CUS-1042",
    name: "Acme Wholesale Inc.",
    email: "ap@acme.example",
    source_updated_at: "2026-09-15T08:30:00Z",
  },
  {
    source_id: "CUS-1043",
    name: "Beta Corp",
    email: "billing@beta.example",
    source_updated_at: "2026-09-15T08:29:41Z",
  },
  // ... thousands more
];

// --- step 2: upsert in batches ---

const totals = { created: 0, updated: 0, stale: 0, deleted: 0, failed: 0 };
const failures: { source_id: string; error: string }[] = [];

for (let start = 0; start < customers.length; start += BATCH_SIZE) {
  const batch = customers.slice(start, start + BATCH_SIZE);
  const result = await upsertBatch(batch);

  for (const [key, count] of Object.entries(result.summary)) {
    totals[key as keyof typeof totals] += count;
  }

  // --- step 3: collect failures, keyed to your own records ---
  for (const item of result.data) {
    if (item.status === "failed") {
      failures.push({
        source_id: batch[item.index].source_id,
        error: item.errors?.[0]?.title ?? "unknown error",
      });
    }
  }

  console.log(`  batch ${start / BATCH_SIZE + 1}:`, result.summary);
}

console.log("\nDone:", totals);
for (const f of failures) console.log(`  ✗ ${f.source_id}: ${f.error}`);
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.