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

# CORS origins

> Allow-list the web origins that may call the Endstate API from a browser, and know what the API expects from a cross-origin request.

Browser requests are checked against your organization's origin allow-list before they reach an endpoint. Requests from your own backend are not - CORS is a browser mechanism, so server-side calls are unaffected by everything on this page.

You need this if your page calls the API directly. Most commonly that means:

* Exchanging a tap for a session token with `POST /v1/taps` (a verify page with no backend of its own).
* Introspecting or revoking a session token with `GET`/`DELETE /v1/session-tokens/current`.
* Reading a unit, or polling claim or transfer status, with a session token.

The other half of browser setup is the [publishable key](/settings/publishable-keys) that identifies your organization.

## Allow-list your origins

Manage the list with your API key. `PUT` replaces it wholesale - send the complete list, not a delta.

<CodeGroup>
  ```ts SDK theme={null}
  await endstate.settings.corsOrigins.replace({
    cors_origins: ["https://brand.example", "https://*.brand.example"],
  });
  ```

  ```bash cURL theme={null}
  curl -X PUT https://api2.endstate.io/v1/settings/cors-origins \
    -H "Authorization: Bearer end_sk_..." \
    -H "Content-Type: application/json" \
    -d '{ "cors_origins": ["https://brand.example", "https://*.brand.example"] }'
  ```
</CodeGroup>

<Accordion title="Response">
  ```json theme={null}
  {
    "cors_origins": ["https://brand.example", "https://*.brand.example"]
  }
  ```
</Accordion>

Send an empty array to remove every origin. `GET /v1/settings/cors-origins` returns the current list.

<Note>
  Changes take effect within about a minute. The allow-list is cached for 60
  seconds, so a newly added origin may be rejected briefly after you save it.
</Note>

## Entry formats

Up to 50 entries, each at most 255 characters, with **one wildcard per entry**.

| Form               | Example                   | Matches                                                             |
| ------------------ | ------------------------- | ------------------------------------------------------------------- |
| Exact origin       | `https://brand.example`   | That origin only                                                    |
| Subdomain wildcard | `https://*.brand.example` | Any subdomain depth - `shop.brand.example`, `eu.shop.brand.example` |
| Port wildcard      | `http://localhost:*`      | Any port on that host                                               |

An origin is `scheme://host[:port]` - no path, query, or fragment. Entries are stored in canonical form and returned as saved, so `https://Brand.Example/` comes back as `https://brand.example`.

A subdomain wildcard needs at least two labels after the `*`: `https://*.brand.example` is accepted, `https://*.com` is not. The wildcard does not match the bare domain - allow-list `https://brand.example` separately if you serve from the apex.

<Warning>
  Every listed origin can call the API from a browser on your organization's
  behalf. Port wildcards are meant for local development - remove
  `http://localhost:*` and any other development origin before you go live.
</Warning>

## What the API expects

**Send the `Authorization` header, with the default credentials mode.** Do not set `credentials: "include"` - the API never returns `Access-Control-Allow-Credentials`, so the browser will block the response.

```js theme={null}
const res = await fetch("https://api2.endstate.io/v1/taps", {
  method: "POST",
  headers: {
    Authorization: "Bearer end_pk_live_...",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ chip_id: chipId, e }),
});
```

Preflight `OPTIONS` requests are handled for you; no configuration is needed.

<Note>
  [`@endstate-sdk/core`](/sdks/core/quickstart) sends requests this way already

  * it never sets `credentials`, so a browser client works as long as the origin
    is allow-listed. The raw `fetch` above is what the SDK does for you.
</Note>

## Reading response headers

Browser JS can read these cross-origin:

`X-Request-Id`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`, `Idempotent-Replayed`.

Include `X-Request-Id` in any support request - it identifies the exact call in our logs.

## When an origin is rejected

A request from an origin that is not on your list is rejected with `403` and `auth.forbidden` before it reaches the endpoint.

The rejection is still CORS-decorated, which means your JavaScript can read the [error envelope](/conventions/errors) and show something useful instead of an opaque network failure. Echoing the origin on a rejection grants nothing - the request was already refused.

Authentication failures are decorated too, so a `401` from a missing or invalid credential is readable in the browser rather than opaque.

One case is **not** decorated, because there is no valid origin to echo: a malformed `Origin`, or one that resolves to `null` (`file://`, a sandboxed iframe, a `data:` URL). That surfaces as a generic network error.

If a call fails and you see nothing in the response, check the browser console for the CORS message, then confirm the exact origin - scheme, host, **and** port - is on the list.

***

## Next steps

<CardGroup cols={2}>
  <Card title="Publishable keys" icon="key" href="/settings/publishable-keys">
    The client-safe credential that identifies your organization.
  </Card>

  <Card title="Host your own verify page" icon="scan-line" href="/guides/host-verify-page">
    Build the post-tap experience on your domain, with or without a backend.
  </Card>
</CardGroup>
