Skip to main content
While the v1 API is in early access, breaking changes may occasionally ship in place. Every one is recorded here with what changed and how to migrate.

Unit descriptions and content updates

Additive, nothing removed.
  • The unit resource gains description, a plain-text string or null. It is returned by POST /v1/units, GET /v1/units, GET /v1/units/{unit_id} and PATCH /v1/units/{unit_id}, and inside unit on GET /v1/chips/{chip_id}, POST /v1/chips and POST /v1/chips/bulk.
  • POST /v1/units accepts an optional description (plain text, 1 to 10,000 characters).
  • PATCH /v1/units/{unit_id} now updates external_id, name, description and attributes, as well as redirect_url. Send only the fields to change; the body must contain at least one field. attributes replaces the full set, and null clears a field. external_id cannot be cleared, and an external_id that another unit in your environment uses returns 409 unit.already_exists. redirect_url is now optional, so existing update requests stay valid.
See Units.

Filter units by status and chip

Additive, nothing removed.
  • GET /v1/units accepts two optional query parameters: status (pending or active, the unit’s collection.token.status) and chip (paired or unpaired). They combine with each other, with external_id and collection_id, and with cursor pagination. Any other value returns validation.failed.
See Units and Pagination.

Collection unit counts and units by collection

Additive, nothing removed.
  • The collection resource gains unit_count, the number of units in the collection. It is returned by GET /v1/collections, GET /v1/collections/{collection_id}, POST /v1/collections, and PATCH /v1/collections/{collection_id}.
  • GET /v1/units accepts a collection_id query parameter that lists only the units in that collection. The response keeps the usual list shape and cursor pagination, and an id that is not a UUID returns validation.failed.
See Collections and Pagination.

API keys enforce role permissions

During early access, API-key operations now check the key’s role before running. A key without the required permission receives 403 auth.forbidden. This includes collection creation and API-key reads of units, claims, and transfers. Chip-tap sessions retain their existing unit-bound access.Migration: ADMIN keys retain access to these operations. If an integration uses a lower-privilege key for an operation its role does not permit, provision a replacement key with the required role before switching over. Unknown role values grant no permissions. See Errors.

c is carried verbatim

Additive, nothing removed. POST /v1/chips and POST /v1/taps no longer require c to be 16 uppercase hex characters. Whatever the tap URL carries is accepted and forwarded as-is, and a chip is never rejected because of its c. The next @endstate-sdk/core and @endstate-sdk/reader releases stop uppercasing or dropping c client-side to match. Nothing you already send stops working. See the c value.

Tap redirects send endstate_chip_id

Additive, nothing removed. A tap redirect appends the tapped chip’s id as endstate_chip_id, next to the one-time credential endstate_e, so a destination you own has everything it needs to verify the tap:
@endstate-sdk/core reads a redirect destination for you:
See Tap redirects and Tap sessions.

Retry a create safely with Idempotency-Key

Every call that creates something now accepts an Idempotency-Key header, so a create whose response you never saw can be retried without creating the resource twice.POST /v1/units, POST /v1/collections, POST /v1/chips, and POST /v1/units/{unit_id}/chip-replacements join POST /v1/taps, POST /v1/chips/bulk, and the claim and transfer calls, which already accepted one.
  • Send the same key on the retry and you get the original response back with Idempotent-Replayed: true.
  • The header is optional. Omit it and nothing about these calls changes.
  • Updates (PATCH, PUT) still need no key: they set fields to the values you send, so a repeat leaves the same result.
See Idempotency.

Breaking: the tap endpoints moved to /v1/taps

Verifying a tap, simulating one, and listing tap history are now three operations on one resource.Migration: send chip_id in the request body rather than in the path - the response is otherwise unchanged. For tap history, the list key is taps instead of scans; pagination is unchanged. Clients generated from openapi.json get createTap, listTaps, and createTestTap.

Verify a tap, with or without a backend

POST /v1/taps records a tap and returns a session token. One endpoint takes either credential: your server calls it with an API key, or your page calls it directly with a publishable key and no server of your own behind it.
  • Send chip_id and the one-time e value from the tap URL - plus the optional c if the tag provides one. The response is the tap id, the session token with its expiry and what it is bound to, the chip, the unit, and the redirect URL.
  • Authenticate with end_sk_... from your server, or end_pk_live_... from the browser. This is the only endpoint that accepts a publishable key.
  • ttl and dry_run are API-key only - a server is trusted to choose a token lifetime; a browser is not. A publishable-key call gets the 600-second default.
  • Calling from the browser also needs the origins your page is served from registered with PUT /v1/settings/cors-origins, or the browser will block the call.
See Credentials and Session tokens.

Simulate a tap without hardware

POST /v1/test-helpers/taps produces a valid single-use e for a test chip - the same value a physical chip emits when it is tapped. Send chip_id; the response is { "chip_id": ..., "e": ..., "count": ... }. Feed chip_id and e straight to POST /v1/taps to exercise verification end to end. Secret key only, and test chips only.See Testing without hardware.

Added: fetch a single chip

GET /v1/chips/{chip_id} returns one chip by its identifier, with a snapshot of the unit it is paired to (or null when the chip is not paired yet). Same chip fields as GET /v1/chips, plus the full unit and its issuance status - so you no longer have to page the list to read one chip.

Added: stable operationId on every endpoint

Every operation in the OpenAPI spec now carries a hand-chosen operationId such as createTap, createUnit, and listTaps. Clients generated from openapi.json get these as method names instead of names derived from the URL path. These names are stable and will not be renamed in place.

Added: staging base URL in the OpenAPI spec

servers now lists staging alongside production. See Environments.

Added: optional c verification code on pairing and tap endpoints

POST /v1/chips (pairing) and POST /v1/taps (verify) now accept an optional c - the tap verification value some tap URLs include alongside e. Nothing changes for existing integrations; include c whenever you have it to prepare for upcoming chip-verification hardening. See the c value.

Breaking: one canonical session-token shape

GET /v1/session-tokens/current now returns the same shape as the session_token object in the tap response (minus token, which is only shown once at issuance): the binding moved into a nested scope object.Before: { "chip_id": ..., "unit_id": ..., "organization_id": ..., "expires_at": ... } After: { "expires_at": ..., "scope": { "chip_id": ..., "unit_id": ..., "organization_id": ... } }Migration: read the three identifiers from scope.* instead of the top level. expires_at is unchanged.

Browser calls: use the default credentials mode

Responses no longer send Access-Control-Allow-Credentials. Endstate authenticates with the Authorization header, which is not a browser “credential”, so the header was never needed - but if your client-side code passes credentials: "include", the browser will now block the response with an opaque CORS error.Migration: remove credentials: "include" from fetch calls to the API. The default mode ("same-origin") sends no credentials on a cross-origin request, which is what the API expects. Keep sending Authorization: Bearer ... exactly as before - it is unaffected by the credentials mode. Server-side calls are unaffected.Browser responses now also expose X-Request-Id, the X-RateLimit-* headers, Retry-After, and Idempotent-Replayed, so client code can read them off both successes and errors.

Publishable keys

Retrieve the publishable key that identifies your organization:
  • GET /v1/publishable-keys returns your active keys. If you have none, one is created automatically, so a first call always returns a usable key.
  • A publishable key (end_pk_live_...) is safe to embed in browser code: it identifies your organization and grants no access by itself. Retrieving it requires your secret API key.
See Credentials.

Manage your own CORS origins

Calling the API from a browser no longer requires asking Endstate to allowlist your origins:
  • GET /v1/settings/cors-origins returns the origins allowed to call the API from a browser; PUT replaces the list (send an empty array to remove all origins).
  • Each entry is an exact origin (https://brand.example), a subdomain wildcard (https://*.brand.example), or a port wildcard (http://localhost:*) - one wildcard per entry. Entries are stored in canonical form and returned as saved.
  • Changes take effect within about a minute. Remove development origins before you go live.
See the new CORS origins page.

Tap history

See every verified tap of your chips, not just the per-chip counter:
  • GET /v1/taps returns a cursor-paginated history of verified taps. Filter by chip_id, unit_id, or a time window; test-chip taps are excluded unless is_test=true.
  • Each item’s id matches the id POST /v1/taps returned when the tap was recorded.
See Tap history.

Pair chips in bulk

Pair up to 100 chips in a single request - built for encoding lines and batch fulfillment:
  • POST /v1/chips/bulk accepts a list of the same per-chip payloads as POST /v1/chips (test or encoded). All units must belong to the same collection, and units are issued in the order provided.
  • The request is all-or-nothing: if any entry fails validation, nothing is paired.
  • Send an Idempotency-Key header to retry safely.
  • New error codes: chip.bulk_mixed_collections, chip.bulk_pending.
See Pairing chips in bulk.

Transfer a unit between owners

A unit can now move from its current owner to a new owner - tap-authorized like a claim, but executed by the current owner:
  • POST /v1/units/{unit_id}/transfers (session token required) creates the transfer and returns a transaction payload. Only the unit’s current owner can submit it - Endstate authorizes the transfer but never relays it, and the authorization is valid for 30 minutes.
  • GET /v1/units/{unit_id}/transfers/{transfer_id} (API key or session token) polls status: prepared → confirmed; expired and failed are terminal.
  • New error codes: transfer.in_progress, transfer.already_to_recipient, transfer.owner_unknown, transfer.not_found.
See the new Transfers concept page.

Replace a unit’s chip

Until a unit is claimed, its chip can now be replaced - for example when a chip is damaged or fails QA after pairing but before the item reaches its owner:
  • POST /v1/units/{unit_id}/chip-replacements pairs an existing, unpaired chip to the unit and retires the current one. Endstate performs the swap; replacement is asynchronous.
  • GET /v1/units/{unit_id}/chip-replacements/{replacement_id} polls status (prepared → submitted → confirmed; failed is terminal).
  • New error codes: chip_replacement.locked (unit already claimed - the chip is permanently locked), chip_replacement.in_progress, and chip_replacement.not_found.
See Replacing a chip.

chain_id is now optional on collection create

Collections are created on your organization’s default network when chain_id is omitted - configured by Endstate at onboarding, so most integrations never need to send a network identifier. An explicit chain_id is still accepted as an override, but must be one of the networks enabled for your organization.GET /v1/settings now returns your network configuration alongside default_redirect_url: default_chain_id (the default network, or null if none is configured) and allowed_chain_ids (the networks an explicit chain_id may use). Both are read-only.No migration needed - requests that send chain_id keep working unchanged.

Single-resource responses are now top-level (breaking)

POST, GET, and PATCH responses for collections and units previously wrapped the resource in an envelope - { "collection": {...} } / { "unit": {...} }. They now return the resource’s fields at the top level, making every single-resource response uniform (chips, taps, session tokens, claims, and settings were already top-level).Migration: if your integration reads response.collection.id or response.unit.id from these endpoints, read the same fields at the top level instead (response.id, response.collection.token, …). List responses are unchanged - they still wrap under a plural key alongside pagination. See Response shape.

Empty request bodies accepted on all-optional POSTs

A POST with a JSON content-type and an empty body previously failed JSON parsing. An empty or whitespace-only body is now treated as {}, so a POST whose body fields are all optional works without a body. Endpoints with required body fields still validate as before.

Tap redirects

Control where a user lands after tapping a chip, at three levels - the most specific one wins:
  • redirect_url is a new (nullable, always-present) field on the unit and collection resources, accepted on create and editable via new PATCH /v1/units/{unit_id} and PATCH /v1/collections/{collection_id} endpoints.
  • New GET/PATCH /v1/settings endpoints manage the org-wide default_redirect_url.
  • Redirected taps carry the tapped chip’s id and its one-time credential as query parameters, so your page can verify the tap.
See Tap redirects.

Claims

Transfer on-chain ownership of a unit to a recipient wallet, authorized by the session token from a recent tap of that unit’s chip:
  • POST /v1/units/{unit_id}/claims creates a claim (endstate_relay or client_broadcast execution).
  • GET /v1/units/{unit_id}/claims/{claim_id} polls status (claiming → claimed / expired / failed).
See Claims and the Claim a unit guide.