# Error reference

> Every error is JSON with an `error` field. This page lists them all, by status, taken from the server's source code.

Source: https://straitlink.in/docs/errors/

## The shape of an error

Every error under `/v1` is JSON, never an HTML page:

```json
{ "error": "slug already exists in this workspace" }
```

Some errors add fields: `message` (a sentence you can show to a person), `reason` (which limit or check), `limit`, `resetsAt`, `hint` or `upgradeUrl`. When `error` is a single `snake_case` word it's a stable code you can branch on; the table below lists every one. Any other `error` is a plain-English sentence: show it or log it, but don't match on its exact wording, because it can change.

## What each status means

| Status | Meaning | What to do |
|---|---|---|
| `400` | The request is wrong: a missing or invalid field, or a body that isn't valid JSON | Fix the request. The message names the field |
| `401` | No key, a wrong key, or the wrong kind of key | Send the secret key as `Authorization: Bearer st_live_…` (server), or the publishable key in the body (apps). See [Keys](https://straitlink.in/docs/keys/) |
| `402` | Your plan doesn't include this, or a plan limit is reached | Existing links keep working. Upgrade, or wait for the next month for the tap limit |
| `403` | Signed in, but not allowed (a viewer, or two-factor sign-in needed) | Ask an owner or admin, or sign in again with your code |
| `404` | No such link, webhook, delivery or domain in this workspace, or an unknown `/v1` path | Check the id or slug. Another workspace's ids are always `404` |
| `409` | It already exists, or it's busy right now | Use another slug or name, or try again in a moment |
| `410` | The link is gone | Create a new link |
| `413` | The body is too large | Send fewer items per call (a batch takes up to 500 links) |
| `421` | The request reached the server directly instead of through your link domain | Use `https://strait.link` or your link domain |
| `422` | The domain you asked the App Links checker about can't be found in DNS, or an `Idempotency-Key` was reused with a different body | Check the spelling, or use a new key |
| `429` | Too many requests | Wait for `Retry-After` seconds when it's sent. See [Rate limits](https://straitlink.in/docs/rate-limits/) |
| `500` | Our fault: `{ "error": "internal error" }`, with no internal details | Retry later. If it keeps happening, write to support@straitlink.in |
| `502` | Our certificate provider didn't answer (custom domains) | Try again in a minute; the message says whether anything was saved |
| `503` | The feature isn't switched on, or is read-only right now | Try again later |

## Machine-readable codes

| `error` | Status | When | What to do |
|---|---|---|---|
| `destination_blocked` | 400 | A link's destination failed the unsafe-destination check. `reason` says why (`invalid_url`, `scheme`, `credentials_in_url`, `ip_address`, `blocked_domain`, …) | Use a public `https` address |
| `invalid_domain` | 400 | The App Links checker got something that isn't a domain | Send a domain such as `example.com` |
| `refused` | 400 | The App Links checker was asked about a private address, an IP address or a non-public name | Check a public domain |
| `invalid_report` | 400 | A link report (`POST /v1/report`) is missing a field or has a bad one; `message` says which | Fix the field |
| `invalid_url` | 400 | The link checker (`POST /v1/tools/link-check`) got something that isn't a full link | Send a full link such as `https://yourapp.page.link/abc` |
| `dns_failed` | 422 | The App Links checker couldn't find the domain in DNS | Check the spelling |
| `unauthorized` | 401 | A link endpoint was called with no `Authorization` header (also an operator-only endpoint without its key) | Send `Authorization: Bearer st_live_…` |
| `plan_required` | 402 | Custom domains on a plan without them | Upgrade to Scale. See [Custom domains](https://straitlink.in/docs/custom-domains/) |
| `mfa_required` | 403 | A dashboard session that still needs its two-factor code | Sign in again and enter your code |
| `fresh_mfa_required` | 403 | Closing a workspace needs a two-factor code from the last 10 minutes | Enter a current code, then try again |
| `key_scope` | 403 | A secret key used outside its access (a read-only key writing, a links-only key outside `/v1/links`) | Use a key with the access the call needs ([Keys](https://straitlink.in/docs/keys/)) |
| `invalid_idempotency_key` | 400 | The `Idempotency-Key` header isn't 1 to 255 letters, digits or `. _ : -` | Fix the key. See [Retry safely](https://straitlink.in/docs/api/#retry-safely-with-idempotency-key) |
| `idempotency_key_in_use` | 409 | A request with the same `Idempotency-Key` is still running; sends `Retry-After: 1` | Retry after a second |
| `idempotency_key_reused` | 422 | The `Idempotency-Key` was used in the last 24 hours with a different body | Use a new key for a new request |
| `link_blocked` | 409 | Turning a link back on (`PATCH /v1/links/{slug}` with `{"isActive": true}`) when it was disabled for breaking the acceptable use policy | It can't be turned back on; create a new link |
| `slug_in_use` | 409 | Turning a link back on when another active link now uses its slug or the same Firebase link | Pause or rename the other link first, then try again |
| `new_account_limit` | 429 | A new workspace reached its first-day cap on links or taps. Adds `reason` (`links` or `redirects`), `message`, `limit` and `resetsAt`, and sends `Retry-After` | Wait until `resetsAt`; the cap lifts by itself |
| `busy` | 429 | The link checker is at its shared limit for all visitors; sends `Retry-After` | Wait for `Retry-After` (about 30 seconds) |
| `rate_limited` | 429 | The App Links checker, the link checker, the link report form or the health check was called too often | Wait for `Retry-After` |
| `domains_not_enabled` | 503 | Custom domains aren't switched on yet | Try again once they are |
| `domains_read_only` | 503 | Domains can't be added or removed right now | Try again later |

Plan limits answer `402` with the database's sentence in `error`, a `reason` and an `upgradeUrl`:

| `reason` | When |
|---|---|
| `links` | The plan's new links for this month are used up |
| `clicks` | This month's taps are over twice the plan's limit |
| `webhooks` | The plan's number of webhooks is reached |

Your existing links keep redirecting whatever the plan limit; only new links and webhooks wait. Plans and their limits are on [Pricing](https://straitlink.in/pricing/).

## Every error, by status

The list below is generated from the server's source, so it's exactly what the API can send. `…` stands for a value filled in at the time (a number, a name or a list). Field checks that build their message from what you sent (a slug, a hostname, a date) aren't listed one by one; they're all `400`. Errors that only the dashboard can get (billing, the Firebase import) are left out.

<!-- generated:engine-errors (npm run sync-errors) -->

### 400 Bad request

- `country must be a 2-letter country code (ISO 3166-1, e.g. IN)`
- `destination_blocked`
- `deviceId required: the SDK debug device id (8–64 letters, digits, - or _)`
- `expiresAt must be epoch milliseconds, an ISO 8601 date-time, or null`
- `from / to must be ISO dates (e.g. 2026-01-31) or epoch ms`
- `invalid cursor`
- `invalid_domain`
- `invalid_idempotency_key`
- `invalid_report`
- `invalid_url`
- `isActive must be true or false`
- `limit must be 1 to 100`
- `limit must be an integer from 1 to 100`
- `linkId + device fields required`
- `linkId required`
- `links must be an array of 1 to … links`
- `longUrl must be https`
- `malformed JSON body`
- `part must be one of …`
- `platform + device fields required`
- `platform must be 'android' or 'ios'`
- `refused`
- `send isActive on its own, then other fields in a second request`
- `slug ([a-zA-Z0-9_-], ≤64) and https longUrl are required`
- `status must be active or paused`
- `tenantId is required with the admin key`
- `tenantId required`
- `token must be a Strait handoff token (22 base64url characters)`
- `token was issued for another part`
- `type the workspace link handle exactly to confirm`
- `ua required: a preset (…) or a raw user agent`
- `unknown linkId`
- `url must be a full short link, e.g. https://…/your-slug`
- `url must be a full short link, e.g. https://you.strait.link/summer`

### 401 Unauthorized

- `analytics needs a secret key (Authorization: Bearer st_live_…); publishable keys only work for the SDK endpoints`
- `download link expired or already used — start the download again`
- `invalid API key`
- `invalid publishable key`
- `invalid secret key`
- `publishable key required (find it in Dashboard → Get started)`
- `secret key (Authorization: Bearer st_live_…) or publishableKey required`
- `secret key or dashboard session required (Authorization: Bearer …)`
- `secret key required (Authorization: Bearer st_live_…)`
- `session expired — sign in again`
- `sign in to the dashboard to close a workspace (a key cannot)`
- `the referral report needs a secret key (Authorization: Bearer st_live_…)`
- `unauthorized`

### 402 Payment required

- `plan_required`
- *(plan limit, `reason: "clicks"`, message from the database)*
- *(plan limit, `reason: "links"`, message from the database)*
- *(plan limit, `reason: "webhooks"`, message from the database)*

### 403 Forbidden

- `fresh_mfa_required`
- `key_scope`
- `mfa_required`
- `not a member of this workspace`
- `only an owner can close this workspace`
- `only owners and admins can export (an export contains all workspace data)`
- `viewers cannot add domains`
- `viewers cannot create links`
- `viewers cannot redeliver webhooks`
- `viewers cannot remove domains`
- `viewers cannot see webhook deliveries`

### 404 Not found

- `delivery not found`
- `domain not found`
- `link not found`
- `no active link "…" in this workspace`
- `no such link in this workspace`
- `not a trace token`
- `not configured`
- `not found`
- `webhook not found`
- `workspace not found`

### 409 Conflict

- `A workspace can have up to … custom domains. Remove one first.`
- `already delivered`
- `cancel your paid plan in Plan & usage first, then close the workspace`
- `idempotency_key_in_use`
- `link_blocked`
- `slug already exists in this workspace`
- `slug_in_use`
- `this delivery is being sent right now — try again in … minutes`
- `This domain is already added to a Strait workspace.`
- `this old link (legacyHost + legacyCode) is already imported in this workspace`
- `your payment is still being confirmed — open Plan & usage in a minute, cancel the plan there, then close the workspace`

### 413 Payload too large

- `request body too large`

### 421 Misdirected request

- `Use https://strait.link (or your link domain); this origin only answers Strait's edge.`

### 422 Unprocessable

- `dns_failed`
- `idempotency_key_reused`

### 429 Too many requests

- `busy`
- `new_account_limit`
- `QR rendering is busy: try again in a few seconds, or ask for format=svg`
- `rate limited`
- `rate limited: at most 30 analytics calls a minute per workspace`
- `rate limited: at most 30 referral report calls a minute per workspace`
- `rate limited: at most 30 usage calls a minute per workspace`
- `rate limited: at most 60 QR codes a minute per workspace`
- `rate limited: domain status is checked at most every few seconds`
- `rate_limited`
- `Too many domain changes. Try again in a few minutes.`

### 500 Internal error

- `internal error`

### 502 Bad gateway

- `Couldn't remove the domain from our certificate provider. It's still listed; try again in a minute.`
- `Couldn't set up the domain with our certificate provider. Nothing was saved; try again in a minute.`

### 503 Unavailable

- `domains_not_enabled`
- `domains_read_only`

<!-- /generated:engine-errors -->

## Related

- [REST API](https://straitlink.in/docs/api/): every endpoint, with the statuses it answers.
- [Rate limits](https://straitlink.in/docs/rate-limits/): what answers `429`, and when to try again.
- [Webhooks](https://straitlink.in/docs/webhooks/): failed deliveries show `HTTP <status>`, `timeout`, `unsafe_target` or `webhook_removed`.
