# Storefront · Tax

Part of AppEngine API Documentation. Full index: https://appengine.appmint.io/documentation.md
## POST /storefront/tax/calculate

**Calculate tax for a cart**

`operationId: TaxController_calculateCartTax`

Tax due on a cart from its lines and destination. `taxableAmount` is `subtotal`, or `price × quantity` summed across `items` when `subtotal` is absent. When `shippingAddress` is present and jurisdiction rows match it, the cart is taxed by destination; otherwise by the venue named in `businessLocationId`. `shippingCost` is taxed separately by the rows whose `appliesTo` is `shipping` or `all`, and that tax is included in `taxAmount` (also reported as `shippingTax`). A tax-exempt `customerId` yields `0`.

**How a rate is chosen.** Active `sf_tax_rate` rows effective on the order date are kept when every jurisdiction field the row specifies equals the address field (case-insensitive; `zipPrefix` is a prefix match on `zip`) and, if the row has a `businessLocationId`, it equals the order's venue. Matches are applied in `priority` order: a non-compound rate taxes the taxable base, a compound rate taxes base + tax already applied. Every line is rounded to the cent and the tax is the sum of the lines. When no row matches, the venue's `location.taxRate` is used; when there is no venue rate either, tax is `0` and `configured` is `false`.

#### Signature

```http
POST /storefront/tax/calculate (body) -> The tax result
```

#### Access

Public — no credentials required.

#### Notes

- `taxRate` is a fraction (0.0825) for backwards compatibility; `breakdown[].rate` and `POST /storefront/tax/rate` use percent.
- With neither a matching address nor a venue rate the result is `taxAmount: 0, configured: false` — check `configured` before treating `0` as "no tax due".
- Rates are records: create / edit them with the repository API on `datatype: "sf_tax_rate"`.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/tax/rate`
- `GET /storefront/tax/rates/resolve`
- `POST /storefront/checkout-cart`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

The cart to price tax for.

```json
{
  "subtotal": 129.99,
  "shippingCost": 4.99,
  "shippingAddress": {
    "country": "US",
    "state": "TX",
    "city": "Dallas",
    "zip": "75201"
  }
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The tax result |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "taxAmount": 11.13,
  "taxRate": 0.0825,
  "taxableAmount": 129.99,
  "shippingTax": 0.41,
  "taxName": "TX State + Dallas City",
  "breakdown": [
    {
      "name": "TX State",
      "rate": 6.25,
      "amount": 8.12
    },
    {
      "name": "Dallas City",
      "rate": 2,
      "amount": 2.6
    },
    {
      "name": "TX State (shipping)",
      "rate": 6.25,
      "amount": 0.31
    },
    {
      "name": "Dallas City (shipping)",
      "rate": 2,
      "amount": 0.1
    }
  ],
  "source": "jurisdiction",
  "exempt": false,
  "currency": "USD",
  "configured": true
}
```

## POST /storefront/tax/rate

**Get the tax rate stack for an address**

`operationId: TaxController_getTaxRateForLocation`

Resolves the `sf_tax_rate` rows that apply to an address and returns them in application order with the combined percent. Same result as `GET /storefront/tax/rates/resolve`, as a POST body.

**How a rate is chosen.** Active `sf_tax_rate` rows effective on the order date are kept when every jurisdiction field the row specifies equals the address field (case-insensitive; `zipPrefix` is a prefix match on `zip`) and, if the row has a `businessLocationId`, it equals the order's venue. Matches are applied in `priority` order: a non-compound rate taxes the taxable base, a compound rate taxes base + tax already applied. Every line is rounded to the cent and the tax is the sum of the lines. When no row matches, the venue's `location.taxRate` is used; when there is no venue rate either, tax is `0` and `configured` is `false`.

#### Signature

```http
POST /storefront/tax/rate (body) -> The resolved stack
```

#### Access

Public — no credentials required.

#### Notes

- `rate` here is a **percent**; `POST /storefront/tax/calculate` returns `taxRate` as a fraction.
- An address with no matching row returns `rate: 0, rates: [], configured: false`.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/tax/rates/resolve`
- `POST /storefront/tax/calculate`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

The address to resolve.

```json
{
  "country": "US",
  "state": "TX",
  "city": "Dallas",
  "zip": "75201"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The resolved stack |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "rate": 8.25,
  "taxName": "TX State + Dallas City",
  "jurisdiction": "Dallas, TX, US",
  "rates": [
    {
      "id": "a1b2",
      "name": "Texas State Sales Tax",
      "taxName": "TX State",
      "rate": 6.25,
      "compound": false,
      "priority": 1,
      "appliesTo": "all",
      "jurisdictionType": "state",
      "jurisdiction": "US / TX"
    },
    {
      "id": "c3d4",
      "name": "Dallas City Sales Tax",
      "taxName": "Dallas City",
      "rate": 2,
      "compound": false,
      "priority": 2,
      "appliesTo": "all",
      "jurisdictionType": "city",
      "jurisdiction": "US / TX / Dallas"
    }
  ],
  "date": "2026-09-02",
  "configured": true
}
```

## GET /storefront/tax/rates/resolve

**Check which tax rates apply to an address**

`operationId: TaxController_resolveRates`

Operator configuration check: the `sf_tax_rate` rows that would be applied to an order shipped to (or taken at) the given address / venue on the given date, in application order, with the combined percent. Use it after creating or editing rate records to confirm they match the way you expect.

**How a rate is chosen.** Active `sf_tax_rate` rows effective on the order date are kept when every jurisdiction field the row specifies equals the address field (case-insensitive; `zipPrefix` is a prefix match on `zip`) and, if the row has a `businessLocationId`, it equals the order's venue. Matches are applied in `priority` order: a non-compound rate taxes the taxable base, a compound rate taxes base + tax already applied. Every line is rounded to the cent and the tax is the sum of the lines. When no row matches, the venue's `location.taxRate` is used; when there is no venue rate either, tax is `0` and `configured` is `false`.

#### Signature

```http
GET /storefront/tax/rates/resolve (country?: string, state?: string, county?: string, city?: string, zip?: string, businessLocationId?: string, date?: string, appliesTo?: string) -> The resolved stack
```

#### Access

Public — no credentials required.

#### Notes

- Every query parameter is optional; with none, only rows that specify no jurisdiction (custom rates) can match.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/tax/rate`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `country` | query | string | — | ISO 3166-1 alpha-2. |
| `state` | query | string | — |  |
| `county` | query | string | — |  |
| `city` | query | string | — |  |
| `zip` | query | string | — |  |
| `businessLocationId` | query | string | — | Venue slug; includes rates restricted to it. |
| `date` | query | string | — | YYYY-MM-DD the rows must be effective on. Default today. |
| `appliesTo` | query | string | — | `goods` \| `services` \| `shipping` \| `all`. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The resolved stack |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "rate": 8.25,
  "taxName": "TX State + Dallas City",
  "jurisdiction": "Dallas, TX, US",
  "rates": [
    {
      "id": "a1b2",
      "name": "Texas State Sales Tax",
      "taxName": "TX State",
      "rate": 6.25,
      "compound": false,
      "priority": 1,
      "appliesTo": "all",
      "jurisdictionType": "state",
      "jurisdiction": "US / TX"
    },
    {
      "id": "c3d4",
      "name": "Dallas City Sales Tax",
      "taxName": "Dallas City",
      "rate": 2,
      "compound": false,
      "priority": 2,
      "appliesTo": "all",
      "jurisdictionType": "city",
      "jurisdiction": "US / TX / Dallas"
    }
  ],
  "date": "2026-09-02",
  "configured": true
}
```

## POST /storefront/tax/product

**Calculate tax for one product**

`operationId: TaxController_calculateProductTax`

The single-line counterpart to the cart calculation: `price × quantity` (default `1`) is the taxable base, resolved by `shippingAddress` when rows match it, else by the venue in `businessLocationId`. `productId` / `sku` are informational — the product is not looked up, so `price` must be supplied.

**How a rate is chosen.** Active `sf_tax_rate` rows effective on the order date are kept when every jurisdiction field the row specifies equals the address field (case-insensitive; `zipPrefix` is a prefix match on `zip`) and, if the row has a `businessLocationId`, it equals the order's venue. Matches are applied in `priority` order: a non-compound rate taxes the taxable base, a compound rate taxes base + tax already applied. Every line is rounded to the cent and the tax is the sum of the lines. When no row matches, the venue's `location.taxRate` is used; when there is no venue rate either, tax is `0` and `configured` is `false`.

#### Signature

```http
POST /storefront/tax/product (body) -> The tax result
```

#### Access

Public — no credentials required.

#### Notes

- Passing `sku` without `price` taxes a base of `0` — the SKU is not resolved to a price.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/tax/calculate`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

The product line to price tax for.

```json
{
  "sku": "DRK-COLA-330",
  "price": 12,
  "quantity": 2,
  "shippingAddress": {
    "country": "US",
    "state": "TX",
    "city": "Austin"
  }
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The tax result |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "taxAmount": 1.5,
  "taxRate": 0.0625,
  "taxableAmount": 24,
  "taxName": "TX State",
  "breakdown": [
    {
      "name": "TX State",
      "rate": 6.25,
      "amount": 1.5
    }
  ],
  "source": "jurisdiction",
  "exempt": false,
  "currency": "USD",
  "configured": true
}
```

## POST /storefront/tax/check-exempt

**Check tax exemption status**

`operationId: TaxController_checkTaxExempt`

Reads the customer record's `taxExempt` flag (with `taxExemptReason` and `taxExemptCertificate`). Looks the customer up by `customerId`, falling back to `email`. `configured: false` means no customer was found for the input — not that the customer is taxable.

#### Signature

```http
POST /storefront/tax/check-exempt (body) -> The exemption state
```

#### Access

Public — no credentials required.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/tax/apply-exemption`
- `POST /storefront/tax/remove-exemption`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

Who to check.

```json
{
  "customerId": "cus_4821"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The exemption state |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "exempt": true,
  "reason": "Reseller",
  "certificate": "EX-99182",
  "customerId": "cus_4821",
  "configured": true
}
```

## POST /storefront/tax/apply-exemption

**Apply a tax exemption to a customer**

`operationId: TaxController_applyTaxExemption`

Sets `taxExempt: true` on the customer record and stores the reason and certificate number. From then on `computeOrderTax` / `calculate` / `product` charge this customer no tax (`source: "exempt"`).

#### Signature

```http
POST /storefront/tax/apply-exemption (body) -> The stored exemption state
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | — | customerId or email is required | Neither `customerId` nor `email` is supplied. | Send one of them. |
| `404` | — | Customer "…" not found | No customer matches `customerId` (any id) or `email`. | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/tax/check-exempt`
- `POST /storefront/tax/remove-exemption`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

The exemption to record.

```json
{
  "customerId": "cus_4821",
  "exemptionNumber": "EX-99182",
  "reason": "Reseller"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The stored exemption state |
| `400` | customerId or email is required — Neither `customerId` nor `email` is supplied. |
| `404` | Customer "…" not found — No customer matches `customerId` (any id) or `email`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "success": true,
  "customerId": "cus_4821",
  "exempt": true,
  "reason": "Reseller",
  "certificate": "EX-99182"
}
```

## POST /storefront/tax/remove-exemption

**Remove a tax exemption from a customer**

`operationId: TaxController_removeTaxExemption`

Clears `taxExempt`, `taxExemptReason` and `taxExemptCertificate` on the customer record.

#### Signature

```http
POST /storefront/tax/remove-exemption (body) -> The cleared exemption state
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | — | customerId or email is required | Neither `customerId` nor `email` is supplied. | Send one of them. |
| `404` | — | Customer "…" not found | No customer matches `customerId` (any id) or `email`. | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/tax/apply-exemption`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

Which customer.

```json
{
  "customerId": "cus_4821"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The cleared exemption state |
| `400` | customerId or email is required — Neither `customerId` nor `email` is supplied. |
| `404` | Customer "…" not found — No customer matches `customerId` (any id) or `email`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "success": true,
  "customerId": "cus_4821",
  "exempt": false,
  "reason": null,
  "certificate": null
}
```

