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

# Troubleshooting: authentication, key mode, and IP allowlist failures

> Map a 401, 403, or 422 rejection to the exact authentication gate that tripped it — invalid or expired API keys, sandbox/live key-mode mismatches, IP allowlists, public-key write blocks, and SAML/SCIM gates.

# Troubleshooting: authentication, key mode, and IP allowlist failures

Every request to the Orbit API passes through the same chain of gates:
the credential resolves, the key's expiry and revocation state is
checked, the sandbox/live key mode is compared against the workspace,
the source IP is matched against the allowlist, and the key's scopes
are checked against the route. A rejection tells you which gate said
no — but only if you read the `code`, since several gates share the
same HTTP status. This page maps every authentication rejection code
to its gate and the fix, then covers the SAML/SCIM gates enterprise
tenants hit at login and provisioning time.

Find the code on your response in the table, then jump to the section.

| Code                       | HTTP | Gate that tripped                                                                            |
| -------------------------- | ---- | -------------------------------------------------------------------------------------------- |
| `UNAUTHORIZED`             | 401  | Generic authentication refusal — no usable credential, or the credential resolved to nothing |
| `INVALID_API_KEY`          | 401  | The key unknown, revoked, or — for per-key IP allowlists — sent from a non-allowed source IP |
| `EXPIRED_TOKEN`            | 401  | The API key passed its expiry time, or a scheduled revocation cut-off has arrived            |
| `WRONG_KEY_MODE`           | 403  | The key's mode (sandbox vs live) does not match the workspace it was used against            |
| `IP_NOT_ALLOWED`           | 403  | The org-level IP allowlist blocked the request's source IP                                   |
| `INSUFFICIENT_PERMISSIONS` | 403  | The credential authenticated, but its role or scope does not cover the route                 |
| Public-key write attempt   | 422  | Write or administrative scope requested on a public (`dv_..._pk_`) key                       |
| `STALE_WEBHOOK`            | 401  | Webhook or DLR receiver rejected the delivery's timestamp (replay guard)                     |

<Note>
  Several gates deliberately return generic codes. A per-key IP
  allowlist block returns `INVALID_API_KEY` (not `IP_NOT_ALLOWED`) so a
  leaked key cannot enumerate your allowlist. A revoked key is reported
  as `EXPIRED_TOKEN` with the message "The API key has been revoked."
  Read the `message` and `code` together, and treat the response
  envelope's `status` field as authoritative.
</Note>

## The 401 family: UNAUTHORIZED, INVALID\_API\_KEY, EXPIRED\_TOKEN

`UNAUTHORIZED` is the generic refusal: no `X-API-Key` header (or no
dashboard session) reached the route, the value was truncated, or it
carried a character set the key format never uses. Confirm the header
name is `X-API-Key` and the full key value is sent — prefixes like
`dv_live_sk_` alone are not a key.

`INVALID_API_KEY` means the key resolved to nothing usable. Work the
causes in order:

1. **Wrong key value.** Re-copy the key from **Settings → API Keys**.
   The full secret is shown once at creation; if you lost it, create a
   replacement and revoke the old one.
2. **Revoked key.** Keys revoked in the dashboard stop authenticating
   immediately. Check the key's state under **Settings → API Keys**.
3. **Per-key IP allowlist.** If the key has an `allowed_ips` list and
   your request originates outside it, the rejection is
   `INVALID_API_KEY` by design. See the
   [IP allowlist section](#the-ip-allowlist-gates) below.
4. **Workspace mismatch.** A key copied from a different organization
   never authenticates here — create one in this workspace instead.

`EXPIRED_TOKEN` on an API-key request has exactly two causes:

* The key's configured expiry time passed. Create a replacement key
  and update your integration; expiry cannot be extended in place.
* The key had a scheduled revocation cut-off (`revoke_at`) that has
  arrived. The key is treated as revoked from that moment regardless
  of any scheduler timing — rotate to a new key.

The same code also appears on dashboard-session flows (for example a
SAML relay with an out-of-date token). There it means "sign in again,"
not "rotate a key."

## The key-mode split: sandbox vs live

Orbit separates sandbox and live state hard: a sandbox key must never
authenticate into a live workspace, and a live key must never
authenticate into a sandbox workspace. The mode comes from the key
prefix — a `dv_test_sk_` key is sandbox-mode, a `dv_live_sk_` key is
live-mode — and the API compares it against the workspace the key
belongs to. On a mismatch the request fails with `403 WRONG_KEY_MODE`
before any handler runs, and nothing is read or written.

The two directions and their fixes:

| Symptom                                                                | Cause                                                                              | Fix                                                                                         |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Sandbox integration works, production calls fail with `WRONG_KEY_MODE` | You pointed the sandbox (`dv_test_sk_`) key at your live workspace                 | Create a live key under **Settings → API Keys** on the live workspace and send that instead |
| Live integration fails after workspace setup changes                   | You are sending the live (`dv_live_sk_`) key against the sandbox sibling workspace | Use that workspace's `dv_test_sk_` key for sandbox traffic                                  |
| Both keys fail after a project copy                                    | The key belongs to a different organization than the one this environment resolves | Keys never cross organizations — create the key in the organization you are calling         |

The day-one pattern is the first row: integrate and test with a
`dv_test_sk_` key, then switch to `dv_live_sk_` at go-live. Test sends
in sandbox deliver to magic numbers and simulate provider outcomes;
live keys send real traffic and debit the wallet. The
[go-live checklist](/guides/go-live-checklist) walks the full switch.

## The IP allowlist gates

Orbit has two independent IP allowlists, and they fail differently:

* **Per-API-key allowlist** — a list of source IPs or CIDR ranges on
  one key, managed through the key's `allowed_ips` field. A request
  from a non-allowed source is rejected with
  `401 INVALID_API_KEY` before any other gate runs, and the rejection
  is deliberately indistinguishable from an unknown or revoked key.
  Setup, validation rules, and the audit trail are in
  [Per-API-key IP allowlist](/guides/api-key-ip-allowlist).
* **Org-level allowlist** — a workspace-wide list gating the dashboard
  and SAML login surfaces and API requests that carry a session or
  key. A request from a non-allowed source IP is rejected with
  `403 IP_NOT_ALLOWED` and the message "Your IP address is not in the
  allowlist."

To diagnose a suspected allowlist block:

1. Determine which gate. A `403 IP_NOT_ALLOWED` is the org-level list;
   a `401 INVALID_API_KEY` that only appears from certain networks is
   the per-key list.
2. Confirm your egress IP as the API edge sees it (your NAT /
   proxy's public address — `curl https://api.orbit.devotel.io/api/v1/me`
   from the same host and check which call fails where).
3. Compare against the list: **Settings → Security** for the org-level
   list, or `GET /api/v1/settings/api-keys/{keyId}/allowed-ips` for a
   per-key list.
4. Add the egress IP or its CIDR range, or clear the list (`null` or
   `[]`) to make a per-key list unrestricted again.

Changes take effect on the next request — no propagation lag. If you
locked yourself out of the dashboard by tightening the org-level list,
any owner on a still-allowed network can widen it back; if nobody is
on an allowed network, escalate with the details listed
[below](#what-to-send-support).

## Public-key write attempts: 422

Public keys (`dv_..._pk_`), which exist to be embedded in browser or
mobile client code, are restricted to read-only scopes at creation.
Requesting a write or administrative scope — `messages:write`,
`contacts:write`, `admin`, the `*` wildcard, or the sensitive reads
`billing:read` / `settings:read` — on a public key is rejected at
create time with a `422`.

This is a key-creation gate, not a per-request one: you hit it when
creating or updating the key, not when calling an endpoint. The fix is
never to find a way around it — put a secret key (`dv_..._sk_`) on your
backend for anything that sends or modifies data, and keep the public
key's scopes to the minimum reads the client needs. The
[FAQ: Are public keys read-only?](/reference/faq#are-public-keys-read-only)
covers the scope split; [Authentication](/authentication) covers key
formats end to end.

## Webhook rejections: STALE\_WEBHOOK (401)

`STALE_WEBHOOK` fires on inbound webhook and DLR receiver surfaces when
the delivery's timestamp is missing or falls outside the allowed replay
window — it is the clock-skew and replay-attack guard for provider
callbacks, not for requests you authenticate toward the API. If your
own webhook endpoint rejects Orbit deliveries with a 401, that is your
signature verifier, and the failure classes (raw-body parsing, rotated
secrets, mutated bodies, clock skew) are covered in
[Signature verification failures](/webhooks/troubleshooting-signature-failures)
and [Webhook security](/webhooks/security).

## SAML and SCIM gates (enterprise tenants)

These gates surface on the login and provisioning path, not on
API-key traffic.

### SAML\_USER\_NOT\_IN\_ORG

The identity provider assertion succeeded cryptographically — the user
proved who they are — but the SAML callback could not attach them to
the workspace: the asserted email belongs to a different organization,
or there is no invitation or SSO enforcement covering it on the
callback organization.

* The user must exist in the organization they sign into — invite
  them (or have SCIM provision them) before the first login.
* If the same email is a member of two organizations, SSO enforcement
  decides which one the assertion attaches to; signing in with the
  other organization configured as the SSO target fails with this
  code.
* Re-check the IdP's org slug in the SAML login URL — pointing the
  IdP at the wrong workspace produces exactly this rejection.

### SCIM\_ROLE\_CEILING\_EXCEEDED

A SCIM group/role push asked Orbit to assign a role above the
organization's configured `max_assignable_role` ceiling (the default
ceiling is `admin`). The provisioning request is refused and nothing
is assigned.

* Map IdP groups only to roles at or below the ceiling; keep
  `owner`-level assignment to the dashboard.
* If a group genuinely needs the higher role, an owner raises the
  ceiling in the workspace's SSO/SCIM settings — the ceiling is a
  tenant-owned control.

The full SCIM setup — base URL, Bearer token, role mapping, health
checks — is in [SCIM 2.0 provisioning](/compliance/scim-provisioning).

## What to send support

If the rejection survives the fixes above, open a support ticket with:

* Your **tenant ID** (dashboard → Settings → Organization; also
  `organizationId` from `GET /api/v1/me`).
* The **error code and message** exactly as returned, and the
  **`request_id`** from the response's `meta` block — these let
  support find the exact gate decision.
* For key issues: the **key prefix** (`dv_live_sk_…` / `dv_test_sk_…`)
  and whether the key has an IP allowlist configured. Never send the
  full secret value.
* For an IP allowlist lockout: the **source IP** you are calling from
  as your egress sees it, and the list you believe is blocking it.
* For SAML/SCIM: the **IdP** (Okta, Entra ID, …), the org slug in the
  SAML URL, the asserted email's domain, and for SCIM the role the IdP
  tried to push.

Never include the full API key or a signing secret in a ticket — the
prefix and request IDs are all support needs.

## See also

* [Authentication](/authentication) — key formats, creation, and rotation
* [Per-API-key IP allowlist](/guides/api-key-ip-allowlist) — setup and validation rules
* [Go-live checklist](/guides/go-live-checklist) — the sandbox-to-live switch
* [Error codes reference](/reference/error-codes) — every code in the authentication group
* [Signature verification failures](/webhooks/troubleshooting-signature-failures) — webhook-side 401s
* [SCIM 2.0 provisioning](/compliance/scim-provisioning) — IdP-driven user and role provisioning
