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.

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 16-character hex verification code 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: preparedconfirmed; 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 (preparedsubmittedconfirmed; 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 (claimingclaimed / expired / failed).
See Claims and the Claim a unit guide.