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

# Authentication

> Hold one platform x-api-key for the control plane, exchange it for a short-lived POS terminal JWT for money and session calls, and scope every credential.

## Two credentials, two jobs

Feddi authenticates partner calls with two credential types. You hold one long-lived secret and mint short-lived tokens from it.

The platform `x-api-key` is your root credential. You provision it once, store it, and use it for the control plane: key lifecycle, SKU registration, reload-bonus config, settlement ingest, and exchanging for terminal tokens. Present it in the `x-api-key` HTTP header.

The POS terminal JWT is a short-lived bearer token (600 second TTL) you mint from your `x-api-key`. Each terminal uses it for per-terminal money and session calls: identify, `POST /payments`, top-up confirm, enroll verify, and offer apply or redeem. Present it in the `Authorization: Bearer` header.

<Info>
  Most-specific scope wins. An `x-api-key` can be enterprise, brand, or branch scoped, and the terminal JWT inherits and narrows that scope through its claims. We recommend a branch-scoped key for cashier terminals and a broader scope only for control-plane automation.
</Info>

The two security schemes are defined in the OpenAPI document as `ApiKeyAuth` (the `x-api-key`) and `PosTerminalJWT` (the bearer). `GET /openapi` serves the live, CI-validated spec, so the schemes you read there are always current.

## Base URL and paths

All paths in this reference are relative to the `/v1/partner` base.

| Environment | Base URL                              |
| ----------- | ------------------------------------- |
| Production  | `https://api.feddi.io/v1/partner`     |
| Development | `https://api.dev.feddi.io/v1/partner` |

Sandbox access is granted per partner agreement. Request sandbox credentials from your Feddi contact, then exchange and call exactly as you will in production.

## The platform x-api-key

The `x-api-key` is your platform-scoped or merchant-scoped secret. It carries the tenant boundary: `context.merchant_id` on every call is key-enforced to a merchant this key is authorized for. It is never a free-text trust field.

The raw key format is `fddi_<segment>_<base64url>` over 24 random bytes. The `<segment>` is currently `odo` for all providers regardless of POS platform: a historical artifact, not provider-derived, so treat `key_prefix` as an opaque value and never parse a provider out of it. Feddi stores only a bcrypt hash plus a short prefix and last-four for display, so the raw key is returned exactly once at provision time and is never retrievable again.

Send it on every control-plane call:

```bash theme={null}
curl https://api.feddi.io/v1/partner/capabilities \
  -H "x-api-key: fddi_odo_AbC123..."
```

<Warning>
  The `x-api-key` is your most powerful credential. Use it for the control plane and for the token exchange only. Do not put it on a POS terminal that handles money: exchange it for a terminal JWT and ship the JWT to the terminal instead.
</Warning>

## The POS terminal JWT

The terminal JWT is an HS256 bearer token with a 600 second TTL. It is terminal-scoped and domain-separated from the consumer JWT (`iss=feddi-pos-terminal`, `aud=feddi-api`), so a consumer token can never be mistaken for a POS terminal token. It carries these scope claims, derived from the key:

<ResponseField name="integration_id" field-type="string" required="true">
  The integration the JWT acts as. Written from the key, enforced on every call.
</ResponseField>

<ResponseField name="enterprise_id" field-type="string" required="true">
  The enterprise tenant the JWT is scoped to.
</ResponseField>

<ResponseField name="brand_id" field-type="string">
  The brand scope, present when the key is brand scoped or narrower.
</ResponseField>

<ResponseField name="branch_id" field-type="string">
  The branch scope, present when the key is branch scoped. A branch-scoped JWT can act on one branch only.
</ResponseField>

<ResponseField name="cashier_id" field-type="string">
  Optional, server-validated (≤ 64 characters, no control characters). Trusted only because the server validated it at mint time, never from the request body alone.
</ResponseField>

Send it on every money and session call:

```bash theme={null}
curl https://api.feddi.io/v1/partner/payments \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -H "Content-Type: application/json" \
  -d '{ "meta": { "api_version": "2026-06-01" }, "context": { "merchant_id": "..." } }'
```

<Tip>
  Refresh before a money call, never during one. A 600 second TTL means a JWT minted at the start of a slow checkout can expire mid-tender. Use `GET /auth/token/validate` to check remaining TTL before you start a checkout, not after you have a customer waiting.
</Tip>

## Key lifecycle

Provisioning, listing, revoking, regenerating, and deleting keys are all GA. Token exchange and validate are Beta: callable today, with an additive contract that may still tighten. Pin your `api_version` and confirm the live enabled set at runtime via `GET /capabilities` and `GET /openapi`.

| Operation                   | Path                            | Method |
| --------------------------- | ------------------------------- | ------ |
| Provision a key             | `/auth/keys`                    | POST   |
| List key metadata           | `/auth/keys`                    | GET    |
| Revoke a key                | `/auth/keys/{keyId}/revoke`     | POST   |
| Regenerate a key            | `/auth/keys/{keyId}/regenerate` | POST   |
| Delete a key                | `/auth/keys/{keyId}/delete`     | POST   |
| Exchange for a terminal JWT | `/auth/token`                   | POST   |
| Validate a terminal JWT     | `/auth/token/validate`          | GET    |

### Key scoping (most-specific wins)

A key's authority is set by which of `enterprise_id`, `brand_id`, and `branch_id` you supply at creation. `enterprise_id` is required, and the other two narrow the scope.

<Steps>
  <Step title="Enterprise-scoped key" icon="building-2" title-type="p">
    Supply `enterprise_id` only. The key acts on any branch under the enterprise.
  </Step>

  <Step title="Brand-scoped key" icon="tag" title-type="p">
    Supply `enterprise_id` and `brand_id`. The key acts on any branch under that brand.
  </Step>

  <Step title="Branch-scoped key" icon="map-pin" title-type="p">
    Supply `enterprise_id`, `brand_id`, and `branch_id`. The key acts on one branch only. A requested `branch_id` outside your own scope returns `FORBIDDEN` (HTTP 403).
  </Step>
</Steps>

## Provision a key

`POST /auth/keys` provisions a scoped key. Requires the `integrations` CREATE permission. Idempotent on the `Idempotency-Key` header: a retried create with the same key and same payload replays the original record (including the same one-time `raw_key` bytes) with `meta.idempotency_replayed: true`. A same-key, different-payload request returns `IDEMPOTENCY_KEY_REUSED` (HTTP 422).

<ParamField header="x-api-key" param-type="string" required="true">
  Your platform `x-api-key`. The tenant boundary is enforced on this key.
</ParamField>

<ParamField header="Idempotency-Key" param-type="string" required="true">
  A partner-generated UUID. Required on this mutating call.
</ParamField>

<ParamField body="enterprise_id" param-type="string (uuid)" required="true">
  The enterprise the key belongs to. Must be a tenant the caller is authorized for.
</ParamField>

<ParamField body="brand_id" param-type="string (uuid)" required="false">
  Narrows the key to one brand under the enterprise.
</ParamField>

<ParamField body="branch_id" param-type="string (uuid)" required="false">
  Narrows the key to one branch. Validated against the caller scope. Out-of-scope returns HTTP 403.
</ParamField>

<ParamField body="label" param-type="string" required="false">
  Human-friendly name for the ops list, up to 120 characters. Example: 'Al-Olaya Branch POS-360-0007'.
</ParamField>

<ParamField body="expires_at" param-type="string (date-time)" required="false">
  Optional future expiry. Omit or null for a non-expiring key.
</ParamField>

The response is the `ApiKeyCreated` object:

<ResponseField name="key_id" field-type="string" required="true">
  The key id, used in lifecycle paths such as `/auth/keys/{keyId}/revoke`.
</ResponseField>

<ResponseField name="raw_key" field-type="string" required="true">
  The full secret, format `fddi_odo_<base64url>`. Shown once. Store it the moment you receive it.
</ResponseField>

<ResponseField name="key_prefix" field-type="string" required="true">
  Prefix plus the first 4 random characters, used for quick-reject in verification and for list display.
</ResponseField>

<ResponseField name="key_last_four" field-type="string" required="true">
  Last 4 characters, for list disambiguation.
</ResponseField>

<ResponseField name="scope" field-type="string" required="true">
  The derived authority level: one of `enterprise`, `brand`, `branch`.
</ResponseField>

<ResponseField name="enterprise_id" field-type="string" required="true">
  The enterprise the key belongs to.
</ResponseField>

<ResponseField name="brand_id" field-type="string">
  The brand scope, when set. Null otherwise.
</ResponseField>

<ResponseField name="branch_id" field-type="string">
  The branch scope, when set. Null otherwise.
</ResponseField>

<ResponseField name="label" field-type="string">
  The human-friendly name, when supplied.
</ResponseField>

<ResponseField name="is_sandbox" field-type="boolean" required="true">
  `true` for sandbox keys (mock providers, sandbox JWT claim, higher rate limit).
</ResponseField>

<ResponseField name="status" field-type="string" required="true">
  One of `active`, `inactive`.
</ResponseField>

<ResponseField name="expires_at" field-type="string (date-time)">
  The configured expiry, or null for a non-expiring key.
</ResponseField>

<ResponseField name="created_at" field-type="string (date-time)" required="true">
  When the key was provisioned.
</ResponseField>

<CodeGroup tabs="cURL,200 Created,403 Forbidden">
  ```bash theme={null}
  curl -X POST https://api.feddi.io/v1/partner/auth/keys \
    -H "x-api-key: $FEDDI_PLATFORM_KEY" \
    -H "Idempotency-Key: 6f1d2c8a-3b44-4e77-9a01-2c5e7f0a9b31" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": { "partner_request_id": "9a1c4e22-7b3f-4d0a-8e21-0f9c2d3b4a5e", "occurred_at": "2026-06-05T08:00:00Z", "sent_at": "2026-06-05T08:00:01Z", "api_version": "2026-06-01" },
      "context": { "merchant_id": "11111111-1111-1111-1111-111111111111" },
      "enterprise_id": "11111111-1111-1111-1111-111111111111",
      "brand_id": "44444444-4444-4444-4444-444444444444",
      "branch_id": "22222222-2222-2222-2222-222222222222",
      "label": "Al-Olaya Branch POS-360-0007"
    }'
  ```

  ```json theme={null}
  {
    "ok": true,
    "data": {
      "key_id": "ak_01H9X2",
      "raw_key": "fddi_odo_3pQ8r2kL9wX1mN7vB4tY6zC0sD5fG8hJ",
      "key_prefix": "fddi_odo_3pQ8",
      "key_last_four": "8hJ",
      "scope": "branch",
      "enterprise_id": "11111111-1111-1111-1111-111111111111",
      "brand_id": "44444444-4444-4444-4444-444444444444",
      "branch_id": "22222222-2222-2222-2222-222222222222",
      "label": "Al-Olaya Branch POS-360-0007",
      "is_sandbox": false,
      "status": "active",
      "expires_at": null,
      "created_at": "2026-06-05T08:00:01Z"
    },
    "error": null,
    "meta": { "request_id": "req_k1a2", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json theme={null}
  {
    "ok": false,
    "data": null,
    "error": { "code": "FORBIDDEN", "message": "Requested branch_id is outside the caller's scope." },
    "meta": { "request_id": "req_k1a3", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

<Danger>
  `raw_key` appears once, at provision time (or in an idempotent replay of that exact create). It is never retrievable again. A lost key is regenerated, not recovered. If you misplace a key, call `regenerate`: same scope, a new raw key shown once.
</Danger>

## List, revoke, regenerate, delete

`GET /auth/keys` lists non-secret metadata only (`key_id`, `scope`, prefix, last-four, status), never the raw key. It is scoped to the calling integration, supports `status` and `branch_id` filters plus cursor pagination, and requires the `integrations` READ permission.

| Operation                            | Behavior                                                                                                                                                                  |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /auth/keys/{keyId}/revoke`     | Soft-revoke. Flips `status` to `inactive` and preserves the row for audit. Re-revoking is a no-op. Requires `integrations` UPDATE.                                        |
| `POST /auth/keys/{keyId}/regenerate` | Zero-downtime rotation. Issues a new `raw_key` (shown once) preserving the same scope, and returns `previous_key_id` for the audit trail. Requires `integrations` UPDATE. |
| `POST /auth/keys/{keyId}/delete`     | Hard-delete for deprovision or cleanup. Modeled as POST to carry the envelope and `Idempotency-Key`. The audit event is preserved. Requires `integrations` DELETE.        |

A revoke or delete returns the compact `ApiKeyStatusResult` (`key_id`, `status`, and `revoked_at` or `deleted_at`). The `status` enum is `inactive` after a revoke and `deleted` after a delete. A `keyId` that does not exist, or that belongs to another integration, returns `NOT_FOUND` (HTTP 404) with no existence leak. All three mutations accept an optional `reason` (≤ 200 characters) for the audit trail, for example 'scheduled\_rotation' or 'terminal\_decommissioned'.

<Info>
  Rotation is immediate, with no overlap grace window for in-flight requests still presenting the old key. After `regenerate`, the old raw key fails verification at once and the new one works. Sequence the terminal swap accordingly. Already-minted terminal JWTs are a separate window: after a revoke or a regenerate they stay valid for their remaining 600 second TTL (there is no JWT revocation list), so a key-compromise response must assume up to 10 minutes of residual terminal sessions.
</Info>

## Exchange your key for a terminal JWT

`POST /auth/token` exchanges your `x-api-key` (in the header, never the body) for a 600 second POS terminal JWT scoped to the key. This is an authentication exchange, not a money or identity call: it returns a token, never a debit token and never customer PII. A revoked or expired key, or a key whose integration is not ACTIVE, returns `INVALID_API_KEY` (HTTP 401). The exchange is idempotent on the `Idempotency-Key` header: a retried exchange within the validity window replays the same JWT.

<ParamField header="x-api-key" param-type="string" required="true">
  The platform key being exchanged. The raw key goes here, not in the body.
</ParamField>

<ParamField header="Idempotency-Key" param-type="string" required="true">
  A partner-generated UUID, so a retried exchange replays the same JWT.
</ParamField>

<ParamField body="cashier_id" param-type="string" required="false">
  Optional cashier identifier embedded in the JWT scope. Server-validated: ≤ 64 characters, no control characters. Trusted only because it is signed into the JWT here.
</ParamField>

The response is the `PosTerminalToken` object:

<ResponseField name="token" field-type="string" required="true">
  The compact JWT. Present it as `Authorization: Bearer <token>` on subsequent terminal calls.
</ResponseField>

<ResponseField name="token_type" field-type="string" required="true">
  Always `Bearer`.
</ResponseField>

<ResponseField name="expires_in" field-type="integer" required="true">
  Seconds until expiry (600).
</ResponseField>

<ResponseField name="expires_at" field-type="string (date-time)" required="true">
  Absolute expiry timestamp.
</ResponseField>

<ResponseField name="scope" field-type="object" required="true">
  The decoded scope claims: `integration_id`, `enterprise_id`, `brand_id`, `branch_id`, `cashier_id`.
</ResponseField>

<ResponseField name="sandbox" field-type="boolean" required="true">
  Mirrors the originating key's sandbox flag, carried as a JWT claim.
</ResponseField>

<Steps>
  <Step title="Send the exchange request with your x-api-key" icon="key" title-type="p">
    `POST /auth/token` with the `x-api-key` header. Pass an `Idempotency-Key` so a retried exchange replays the same JWT.
  </Step>

  <Step title="Read the token and its TTL" icon="clock" title-type="p">
    The response carries `token`, `token_type`, `expires_in`, `expires_at`, the resolved `scope`, and the `sandbox` flag.
  </Step>

  <Step title="Cache the JWT on the terminal, keyed on expires_at" icon="database" title-type="p">
    Reuse it until it nears expiry, then re-exchange. Refresh before a checkout, not during one.
  </Step>
</Steps>

<CodeGroup tabs="cURL,200 OK,401 Invalid key">
  ```bash theme={null}
  curl -X POST https://api.feddi.io/v1/partner/auth/token \
    -H "x-api-key: $FEDDI_PLATFORM_KEY" \
    -H "Idempotency-Key: aa07c4e1-9d22-4b3f-8c10-7e5a2f0d4c88" \
    -H "Content-Type: application/json" \
    -d '{
      "meta": { "api_version": "2026-06-01" },
      "context": { "merchant_id": "11111111-1111-1111-1111-111111111111", "branch_id": "22222222-2222-2222-2222-222222222222", "terminal_id": "POS-360-0007", "cashier_id": "cashier-42" },
      "cashier_id": "cashier-42"
    }'
  ```

  ```json theme={null}
  {
    "ok": true,
    "data": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "token_type": "Bearer",
      "expires_in": 600,
      "expires_at": "2026-06-05T09:10:00Z",
      "scope": {
        "integration_id": "33333333-3333-3333-3333-333333333333",
        "enterprise_id": "11111111-1111-1111-1111-111111111111",
        "brand_id": "44444444-4444-4444-4444-444444444444",
        "branch_id": "22222222-2222-2222-2222-222222222222",
        "cashier_id": "cashier-42"
      },
      "sandbox": false
    },
    "error": null,
    "meta": { "request_id": "req_tk1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json theme={null}
  {
    "ok": false,
    "data": null,
    "error": { "code": "INVALID_API_KEY", "message": "Missing, invalid, revoked, or expired x-api-key." },
    "meta": { "request_id": "req_tk2", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

## Keep the JWT fresh

The JWT lives 600 seconds. A long cashier shift outlives many tokens, so check freshness before a money call and re-exchange when needed.

`GET /auth/token/validate` pre-checks a JWT's validity and remaining TTL without performing any business operation. Present the JWT via the `Authorization: Bearer` header. A valid token and an invalid token both return HTTP 200: a present-but-expired token is a data answer, not a 401. The check is read-only, rate-limited to deter token-probing, and does not extend the token. The only 401 is when no bearer token is presented at all.

The response is the `TokenValidation` object:

<ResponseField name="valid" field-type="boolean" required="true">
  `true` when the token is well-formed, correctly signed, the correct audience, and not expired.
</ResponseField>

<ResponseField name="expires_at" field-type="string (date-time)">
  Absolute expiry. Present when `valid` is `true`.
</ResponseField>

<ResponseField name="remaining_seconds" field-type="integer">
  Seconds of TTL left. Present when `valid` is `true`.
</ResponseField>

<ResponseField name="sandbox" field-type="boolean">
  The token's sandbox flag. Present when `valid` is `true`.
</ResponseField>

<ResponseField name="scope" field-type="object">
  The decoded scope claims. Present when `valid` is `true`, null otherwise.
</ResponseField>

<ResponseField name="reason" field-type="string">
  Why the token is invalid. One of `expired`, `malformed`, `wrong_audience`, `bad_signature`. Non-null only when `valid` is `false`.
</ResponseField>

<CodeGroup tabs="cURL,200 Valid,200 Expired">
  ```bash theme={null}
  curl https://api.feddi.io/v1/partner/auth/token/validate \
    -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  ```

  ```json theme={null}
  {
    "ok": true,
    "data": {
      "valid": true,
      "expires_at": "2026-06-05T09:10:00Z",
      "remaining_seconds": 412,
      "sandbox": false,
      "scope": {
        "integration_id": "33333333-3333-3333-3333-333333333333",
        "enterprise_id": "11111111-1111-1111-1111-111111111111",
        "brand_id": "44444444-4444-4444-4444-444444444444",
        "branch_id": "22222222-2222-2222-2222-222222222222",
        "cashier_id": "cashier-42"
      },
      "reason": null
    },
    "error": null,
    "meta": { "request_id": "req_tv1", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```

  ```json theme={null}
  {
    "ok": true,
    "data": {
      "valid": false,
      "expires_at": null,
      "remaining_seconds": null,
      "sandbox": null,
      "scope": null,
      "reason": "expired"
    },
    "error": null,
    "meta": { "request_id": "req_tv2", "idempotency_replayed": false, "api_version": "2026-06-01" }
  }
  ```
</CodeGroup>

A standalone token-refresh entry point is not yet available. To refresh, re-exchange your `x-api-key` at `POST /auth/token`.

## Scope enforcement is per-call

Authentication answers who you are. Scope answers what you may touch. Feddi enforces both on every call, and a cross-tenant attempt never leaks existence or PII.

A read or money call outside your key's scope collapses to `NOT_FOUND` or `FORBIDDEN`, never a partial answer. A terminal JWT cannot escalate past the key it was minted from: a branch-scoped JWT acting on a sibling branch fails. `context.merchant_id` is checked against the key's authorized merchants on every request, never trusted from the body.

## Many-merchant tenancy

If you integrate as a POS platform with many merchants, one platform integration maps to many Feddi merchant tenants. You mint per-merchant scoped keys from your platform credential rather than modeling the platform as one flat tenant.

<Warning>
  Each merchant is a distinct Feddi tenant. Mint a scoped key per merchant (or per branch), and let `context.merchant_id` enforcement do the isolation. Cross-tenant access collapses to `NOT_FOUND` or `FORBIDDEN` with no existence or PII leak. See [Platform and discovery](/guides/platform).
</Warning>

## Sandbox first

Build and self-test against your sandbox credentials before you touch production money or PII. Sandbox keys mint JWTs with `sandbox: true`, route to mock providers, and never touch production money or settlements. Sandbox rows are flagged and carry a higher rate limit.

<Steps>
  <Step title="Get sandbox credentials" icon="flask-conical" title-type="p">
    Request a sandbox key from your Feddi contact. It returns flagged `is_sandbox: true`.
  </Step>

  <Step title="Exchange and build" icon="hammer" title-type="p">
    Exchange the sandbox key for a JWT and run your full flow against mock providers. No real money or PII is touched.
  </Step>

  <Step title="Flip live" icon="rocket" title-type="p">
    Provision a live key with `POST /auth/keys`, swap the base URL to `https://api.feddi.io/v1/partner`, and repeat your checks.
  </Step>
</Steps>

## Troubleshooting a 401

Every authentication failure returns a typed string code in `error.code`, never a bare HTTP number. The code for a credential failure is `INVALID_API_KEY`.

<Expandable title="Why did I get INVALID_API_KEY (401)?" default-open="false">
  An HTTP 401 with `error.code: "INVALID_API_KEY"` means the credential you presented was missing, malformed, revoked, expired, or scoped to an integration that is not ACTIVE. Work through these in order:

  * Terminal JWT expired. The most common cause. The TTL is 600 seconds. Re-exchange your `x-api-key` at `POST /auth/token`. Pre-check freshness with `GET /auth/token/validate` before the next checkout so you never hit this mid-tender.
  * Wrong header. The `x-api-key` goes in the `x-api-key` header, and the terminal JWT goes in `Authorization: Bearer`. A JWT in the `x-api-key` header, or a raw key in the bearer header, fails as invalid.
  * Revoked or regenerated key. If you called `revoke` or `regenerate`, the old `raw_key` is dead. Use the new one. Regenerate keeps the scope but issues a fresh secret.
  * Integration not ACTIVE. If the integration behind the key is suspended or not yet activated, the exchange and direct calls both return `INVALID_API_KEY`. Resolve the integration state first.

  A scope failure is distinct: a valid key acting outside its `branch_id` authority returns `FORBIDDEN` (HTTP 403), not `INVALID_API_KEY`. If you see 403, the credential is good but the target is out of scope. See [Errors](/api-reference/errors).
</Expandable>

```json theme={null}
{
  "ok": false,
  "data": null,
  "error": { "code": "INVALID_API_KEY", "message": "Missing or invalid x-api-key / POS terminal JWT." },
  "meta": { "request_id": "req_4f2a", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
```

## Where to go next

<Columns cols="2">
  <Card title="Identity and credentials" href="/concepts/identity-and-credentials" icon="user-search" horizontal="false">
    The customer credential types that resolve who the shopper is, and why identity never returns a debit token.
  </Card>

  <Card title="Idempotency and errors" href="/concepts/idempotency-and-errors" icon="shield-alert" horizontal="false">
    The envelope, the Idempotency-Key rule, and the full typed-error model.
  </Card>

  <Card title="API conventions" href="/api-reference/conventions" icon="code" horizontal="false">
    The request and response envelope every authenticated call inherits.
  </Card>

  <Card title="Quickstart" href="/getting-started/quickstart" icon="zap" horizontal="false">
    Exchange a key for a JWT and make your first authenticated call.
  </Card>
</Columns>
