Source: https://developers.tokportal.com/errors/
Markdown: https://developers.tokportal.com/errors.md

# Errors

The TokPortal API uses a consistent error format across all endpoints. Errors are returned as JSON with an `error` object.

## Error Response Format

```json
{
  "error": {
    "code": "ERROR_CODE",
    "message": "A human-readable description of the problem.",
    "details": {}
  }
}
```

| Field     | Type     | Description                                                                           |
| --------- | -------- | ------------------------------------------------------------------------------------- |
| `code`    | `string` | Machine-readable error code. Use this for programmatic handling.                      |
| `message` | `string` | Human-readable description. May change — do not match against this.                   |
| `details` | `object` | Optional. Additional context (e.g., which field failed validation, credit breakdown). |

Every response also includes a correlation header:

```http
X-TokPortal-Request-ID: req_...
```

Generated SDKs expose this value on structured API errors as `requestId`, `request_id`, or `RequestID`. Include it when contacting support about a failed request.

## Error Code Reference

### Authentication Errors

| Code                   | HTTP | Description                                                                    |
| ---------------------- | ---- | ------------------------------------------------------------------------------ |
| `AUTH_MISSING_KEY`     | 401  | No `X-API-Key` header was provided in the request.                             |
| `AUTH_INVALID_KEY`     | 401  | The API key does not match any account.                                        |
| `AUTH_REVOKED_KEY`     | 401  | The API key has been revoked. Generate a new key from the developer dashboard. |
| `AUTH_EXPIRED_KEY`     | 401  | The API key expired. Rotate it in the developer dashboard.                     |
| `AUTH_FORBIDDEN_SCOPE` | 403  | The key does not have the scope required by this endpoint.                     |

### Rate Limiting

| Code                  | HTTP | Description                                                                                                                        |
| --------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `rate_limited`        | 429  | Current per-key limiter code. Wait for `Retry-After`, then retry. See [Rate Limits](https://developers.tokportal.com/rate-limits). |
| `RATE_LIMIT_EXCEEDED` | 429  | Legacy rate-limit code. Handle it like `rate_limited`.                                                                             |
| `capacity_cooldown`   | 429  | The rolling bundle capacity for the client workspace is temporarily exhausted. Team activity shares the same window; retry later.  |

API-key throttling responses include `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. Bundle-capacity cooldowns do not expose capacity counters.

### Idempotency

| Code                                                 | HTTP | Description and safe recovery                                                                                                                                                                                                                                                                                         |
| ---------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `IDEMPOTENCY_KEY_TOO_LONG`                           | 400  | Use a stable key of at most 255 characters.                                                                                                                                                                                                                                                                           |
| `IDEMPOTENCY_KEY_NOT_ALLOWED_FOR_SENSITIVE_RESPONSE` | 400  | This endpoint can return a credential, verification code, signing secret, signed upload URL, or private report artifact. Remove `Idempotency-Key` and retry once without it. `details.retry_without_idempotency_key` is `true`. TokPortal rejects the request before the operation runs.                              |
| `IDEMPOTENCY_KEY_REUSED`                             | 409  | The key already belongs to a different request fingerprint. Do not retry with that key. Generate a new key only for a genuinely new logical action.                                                                                                                                                                   |
| `IDEMPOTENCY_KEY_IN_PROGRESS`                        | 409  | The claim is still `processing`. Retry the exact same method, path, body, and key after a short delay. If it persists beyond normal request time, stop automated retries and do not use a new key: the mutation may have committed before its response was stored. Contact support for log and ledger reconciliation. |
| `IDEMPOTENCY_UNAVAILABLE`                            | 503  | TokPortal did not execute the mutation because the ledger was unavailable. Retry the exact same request with the same key.                                                                                                                                                                                            |

For a network timeout where no response was received, also retry the exact same request with the same key. Reusing a key with a changed body is always an integration error. Completed responses replay for 24 hours after completion. Uncertain `processing` claims never expire automatically into a second execution; they remain fail-closed until TokPortal support/operator reconciles the request, resource, and financial logs. Include the `X-TokPortal-Request-ID`, request timestamp, method, path, idempotency key, and a non-secret correlation reference when asking for reconciliation.

### Billing

| Code                   | HTTP | Description                                                                                             |
| ---------------------- | ---- | ------------------------------------------------------------------------------------------------------- |
| `INSUFFICIENT_CREDITS` | 402  | Not enough credits. Response includes `required`, `available`, `missing`, and `breakdown` in `details`. |

### Bundle Errors

| Code                       | HTTP | Description                                                                                                                                                                                                                                                    |
| -------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BUNDLE_NOT_FOUND`         | 404  | The specified bundle does not exist.                                                                                                                                                                                                                           |
| `BUNDLE_NOT_OWNED`         | 403  | The bundle belongs to another account.                                                                                                                                                                                                                         |
| `BUNDLE_INVALID_STATUS`    | 409  | The bundle is not in a valid status for this operation (e.g., trying to configure a published bundle).                                                                                                                                                         |
| `BUNDLE_ALREADY_PUBLISHED` | 409  | The bundle has already been published and cannot be modified.                                                                                                                                                                                                  |
| `DUPLICATE_ACCOUNT_BUNDLE` | 409  | A new-account bundle matched an existing `external_ref` or the short no-reference duplicate guard. Inspect `details.existing_bundle_id`. This detects a likely duplicate but does not replay the prior response; only `Idempotency-Key` provides retry/replay. |

### Account Errors

| Code                     | HTTP | Description                                                       |
| ------------------------ | ---- | ----------------------------------------------------------------- |
| `ACCOUNT_NOT_FOUND`      | 404  | The specified account does not exist.                             |
| `ACCOUNT_INVALID_STATUS` | 409  | The account is not in a valid status for this operation.          |
| `ACCOUNT_NOT_CONFIGURED` | 409  | The account must be configured before this operation can proceed. |

### Saved Account Errors

| Code                                | HTTP | Description                                                                                                                                                    |
| ----------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SAVED_ACCOUNT_NOT_FOUND`           | 404  | The specified saved account does not exist.                                                                                                                    |
| `SAVED_ACCOUNT_NOT_OWNED`           | 403  | The saved account belongs to another user.                                                                                                                     |
| `ACCOUNT_CREDENTIALS_NOT_AVAILABLE` | 409  | The account has no complete credential payload. Nothing was charged or detached. Do not accept a partial response.                                             |
| `MANAGED_ACCOUNT_TASK_BLOCKED`      | 409  | TokPortal Coverage does not currently allow work on this account. Read `details.reason`, fetch the Coverage state, and reactivate if the state is recoverable. |

### TokPortal Coverage

| Code                                   | HTTP | Description and next action                                                                                                                                        |
| -------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `MANAGED_SUBSCRIPTION_NOT_FOUND`       | 404  | No Coverage record is available for this account. This alone does not prove grandfathering; also check platform, account creation time, and delivery state.        |
| `MANAGED_SUBSCRIPTION_NOT_PAUSED`      | 409  | Coverage is not in a recoverable paused state. Fetch the current state and do not submit payment again.                                                            |
| `MANAGED_SUBSCRIPTION_NOT_CANCELLABLE` | 409  | Coverage cannot be paused in its current state, usually because it is already terminal. Fetch the current state.                                                   |
| `MANAGED_ACCOUNT_UNAVAILABLE`          | 409  | The saved account is no longer available, so Coverage cannot be reactivated. Do not retry payment.                                                                 |
| `QUOTE_CHANGED`                        | 409  | No debit or resume occurred. Fetch a new Coverage snapshot, show the new amount, and confirm it with a new idempotency key.                                        |
| `INSUFFICIENT_CREDITS`                 | 402  | The wallet cannot cover the current quote. Show `details.required`, `details.available`, and `details.missing`; after top-up, fetch a fresh quote before retrying. |

### Credential Reveal and Account Owning Fee

| Code                                  | HTTP | Description and next action                                                                                                                                                                                                                         |
| ------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CREDENTIALS_ACKNOWLEDGMENT_REQUIRED` | 428  | No reveal, debit, or detach occurred. Display the complete `details.disclosure` and `details.acknowledgment`, then retry only after explicit confirmation with `acknowledge_support_forfeit: true` and the exact `details.policy_version`.          |
| `CREDENTIAL_REVEAL_QUOTE_CHANGED`     | 409  | No reveal, debit, or detach occurred. The caller supplied a nonempty stale policy version, or the price or policy changed during the atomic operation. Fetch the current preview, display it again, and request fresh confirmation before retrying. |
| `ACCOUNT_CREDENTIALS_NOT_AVAILABLE`   | 409  | No usable credentials are stored. No credits were charged and the account was not detached.                                                                                                                                                         |
| `OWNING_FEE_REQUIRED`                 | 402  | The admin-approved Account Owning Fee agreement is not ready or its billing setup is incomplete. Follow `details.activation_url` when present. Pending approval does not remove the 150-credit reveal option.                                       |
| `BILLING_SUSPENDED`                   | 402  | Account Owning Fee billing is suspended. Resolve billing before retrying an owning-fee-gated action.                                                                                                                                                |

### Video Errors

| Code                          | HTTP | Description                                            |
| ----------------------------- | ---- | ------------------------------------------------------ |
| `VIDEO_NOT_FOUND`             | 404  | The specified video does not exist.                    |
| `VIDEO_INVALID_STATUS`        | 409  | The video is not in a valid status for this operation. |
| `VIDEO_POSITION_OUT_OF_RANGE` | 400  | The video position exceeds the available slots.        |
| `VIDEO_NO_DOWNLOAD_ISSUE`     | 409  | The video does not have a download issue to fix.       |

### Account Edit Request Errors

| Code                           | HTTP | Description                                                                                 |
| ------------------------------ | ---- | ------------------------------------------------------------------------------------------- |
| `EDIT_REQUEST_NO_CM`           | 409  | The active order has no eligible active manager.                                            |
| `EDIT_REQUEST_ALREADY_EXISTS`  | 409  | Another edit request is already in progress.                                                |
| `EDIT_REQUEST_NO_ACTIVE_ORDER` | 409  | The latest delivery is older than 14 days, or no active historical manager can be resolved. |

### Validation Errors

| Code                      | HTTP | Description                                                                                                                                                                                                                                                                                                                                |
| ------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `INVALID_COUNTRY`         | 400  | The country code is not supported.                                                                                                                                                                                                                                                                                                         |
| `INVALID_PLATFORM`        | 400  | The platform string is not one the API recognises. It accepts `tiktok`, `instagram` and `youtube`, but recognising `youtube` is not the same as being able to order it — see `YOUTUBE_DELAYED` below.                                                                                                                                      |
| `YOUTUBE_DELAYED`         | 400  | Bundle creation supports TikTok and Instagram only. `GET /credit-costs` lists a YouTube price and `GET /platforms` lists YouTube, but a YouTube bundle is rejected here; YouTube accounts are delivered through a separate flow arranged with the team.                                                                                    |
| `INVALID_VIDEO_TYPE`      | 400  | The video type is not valid for the target platform.                                                                                                                                                                                                                                                                                       |
| `INVALID_DATE`            | 400  | The date is malformed, or earlier than the minimum publish lead time. `details.min_days_ahead` carries the lead time applied (1 or 3) and `details.earliest_allowed` the first acceptable day (`YYYY-MM-DD`). Raised by video configuration, batch, CSV import and — since 2026-08-26 — `PATCH /bundles/:id/videos/:position` reschedules. |
| `VIDEOS_PER_DAY_EXCEEDED` | 400  | The bundle already has 3 videos scheduled on that day (max 3 per day per bundle).                                                                                                                                                                                                                                                          |
| `INVALID_FIELD`           | 400  | A field value is invalid (details specify which field and why).                                                                                                                                                                                                                                                                            |
| `INVALID_BODY`            | 400  | The request body is malformed or not valid JSON.                                                                                                                                                                                                                                                                                           |
| `MISSING_FIELD`           | 400  | A required field is missing from the request body.                                                                                                                                                                                                                                                                                         |
| `UNKNOWN_FIELD`           | 400  | The body carries a field TokPortal does not recognise, on an endpoint where that cannot be tolerated. See [Unknown fields](https://developers.tokportal.com/errors/#unknown-fields) below.                                                                                                                                                 |

### Configuration Errors

| Code                              | HTTP | Description                                                                                                                                                                                                                                                                                                                                                 |
| --------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EDIT_SLOTS_EXCEEDED`             | 400  | The number of edit slots exceeds the allowed limit.                                                                                                                                                                                                                                                                                                         |
| `WARMING_CONFLICT`                | 400  | Conflicting warming options: `wants_advanced_warming` and `wants_niche_warming` are mutually exclusive.                                                                                                                                                                                                                                                     |
| `DEEP_WARMING_PLATFORM`           | 400  | Legacy code (deep warming was Instagram-only). New deep-warming orders now fail earlier with `DEEP_WARMING_DEPRECATED`.                                                                                                                                                                                                                                     |
| `DEEP_WARMING_DEPRECATED`         | 400  | Deep warming has been discontinued. Use `wants_advanced_warming` with `advanced_warming_terms` instead.                                                                                                                                                                                                                                                     |
| `ADVANCED_WARMING_PLATFORM`       | 400  | Advanced warming is only available for TikTok and Instagram accounts.                                                                                                                                                                                                                                                                                       |
| `ADVANCED_WARMING_TERMS`          | 400  | `advanced_warming_terms` must contain 3-30 terms (multiple of 3), each 2-50 characters.                                                                                                                                                                                                                                                                     |
| `ADVANCED_WARMING_FLAG_REQUIRED`  | 400  | `advanced_warming_terms` or `advanced_warming_terms_count` was sent without `wants_advanced_warming: true` — absent, or explicitly `false`. TokPortal never infers the flag, so the call is refused instead of charging for the bundle and dropping the targets. Nothing is debited. On `POST /bundles/bulk` it rejects the whole batch.                    |
| `ADVANCED_WARMING_TERMS_REJECTED` | 400  | At least one target would have been removed by cleanup — trimmed then compared case-insensitively, so a duplicate, or a term outside 2-50 characters. `details.dropped_terms` lists them. TokPortal will not deliver, or bill, fewer targets than you asked for, so the call fails rather than silently shrinking the order. Send distinct terms and retry. |
| `ADVANCED_WARMING_COUNT_MISMATCH` | 400  | `advanced_warming_terms` and `advanced_warming_terms_count` state different quantities. Send the term list **or** the count; neither quantity silently wins any more.                                                                                                                                                                                       |
| `REWARM_NO_ACTIVE_ORDER`          | 409  | No routable active manager or non-cancelled support order is available. Completed bundles remain eligible; cancelled orders do not.                                                                                                                                                                                                                         |
| `REWARM_ALREADY_ACTIVE`           | 409  | An advanced warming session is already active for this account.                                                                                                                                                                                                                                                                                             |
| `WARMING_SESSION_NOT_FOUND`       | 404  | Warming session not found or not owned by your account.                                                                                                                                                                                                                                                                                                     |
| `TERM_GENERATION_FAILED`          | 502  | Search-term generation failed — retry with a more detailed description or provide terms manually.                                                                                                                                                                                                                                                           |
| `AI_UNAVAILABLE`                  | 503  | AI term generation is temporarily unavailable — provide the search terms manually.                                                                                                                                                                                                                                                                          |
| `VIDEOS_ONLY_REQUIRES_ACCOUNT`    | 400  | A videos-only bundle requires an existing saved account.                                                                                                                                                                                                                                                                                                    |
| `ACCOUNT_ID_NOT_ALLOWED`          | 400  | The `account_id` field was passed with a `bundle_type` other than `videos_only`. To add videos to an existing saved account, set `bundle_type` to `videos_only` (this also skips the account-creation credit charge).                                                                                                                                       |

### Analytics Errors

| Code                       | HTTP | Description                                                      |
| -------------------------- | ---- | ---------------------------------------------------------------- |
| `ANALYTICS_COOLDOWN`       | 429  | Analytics were recently refreshed. Wait before requesting again. |
| `ANALYTICS_QUOTA_EXCEEDED` | 429  | Analytics refresh quota has been exceeded for this period.       |
| `ANALYTICS_NOT_FOUND`      | 404  | No analytics data is available for this account.                 |

### Verification Errors

| Code                          | HTTP | Description                                              |
| ----------------------------- | ---- | -------------------------------------------------------- |
| `VERIFICATION_CODE_NOT_FOUND` | 404  | No pending verification code was found for this account. |

### CSV Errors

| Code                   | HTTP | Description                                                             |
| ---------------------- | ---- | ----------------------------------------------------------------------- |
| `CSV_PARSE_ERROR`      | 400  | The CSV file could not be parsed. Check formatting and encoding.        |
| `CSV_VALIDATION_ERROR` | 400  | The CSV content failed validation. `details` includes row-level errors. |

### Server Errors

| Code             | HTTP | Description                                                             |
| ---------------- | ---- | ----------------------------------------------------------------------- |
| `UPLOAD_FAILED`  | 500  | A file upload failed. Retry the request.                                |
| `INTERNAL_ERROR` | 500  | An unexpected server error occurred. If this persists, contact support. |

## Unknown fields

A field TokPortal does not recognise used to be dropped during validation and the request succeeded anyway. On a full-replace write that was quietly destructive: a body sending `biograhpy` instead of `biography` was accepted with a `200`, and because the endpoint replaces the whole record, the stored biography was wiped to `null`.

Unrecognised fields are now reported, and on the endpoints where dropping them destroys data they are refused:

| Endpoint                              | Behaviour                                                   |
| ------------------------------------- | ----------------------------------------------------------- |
| `POST /bundles`                       | Refused — `400 UNKNOWN_FIELD`                               |
| `POST /bundles/bulk`                  | Refused — `400 UNKNOWN_FIELD`                               |
| `PUT /bundles/{id}/account`           | Refused — `400 UNKNOWN_FIELD`                               |
| `PUT /bundles/{id}/videos/{position}` | Refused — `400 UNKNOWN_FIELD`                               |
| `PUT /bundles/{id}/videos/batch`      | Refused — `400 UNKNOWN_FIELD`                               |
| `PUT /bundles/{id}/warming-terms`     | Refused — `400 UNKNOWN_FIELD`                               |
| Every other endpoint                  | Runs normally; the names come back in `meta.ignored_fields` |

The error names the fields, suggests the closest valid one, and lists what the endpoint does accept:

```json
{
  "error": {
    "code": "UNKNOWN_FIELD",
    "message": "The request body contains fields TokPortal does not recognise. On this endpoint a write is a FULL REPLACE, so a misspelled field is not merely ignored — it erases the value it was meant to set. Check \"details.unknown_fields\" for the exact names and \"details.did_you_mean\" for the closest valid field. Endpoints that only ignore unknown fields report them in \"meta.ignored_fields\" instead of failing.",
    "details": {
      "unknown_fields": ["biograhpy"],
      "did_you_mean": { "biograhpy": "biography" },
      "accepted_fields": ["username", "display_name", "biography", "profile_picture_url", "link_in_bio"]
    }
  }
}
```

The check descends into arrays, so a typo inside one item of a batch body is caught too.

### When the field is real but sent to the wrong place

A spelling guess is the wrong answer for a field that exists, is spelled correctly, and is simply not settable on that endpoint. Those cases answer `400 UNKNOWN_FIELD` with `details.hints`, and the `message` **is** the fix:

```json
{
  "error": {
    "code": "UNKNOWN_FIELD",
    "message": "auto_publish is a top-level field on this endpoint and applies to every video in the call — move it out of videos[]. It cannot be set per item: there is one publish attempt per call, not one per video.",
    "details": {
      "unknown_fields": ["videos[0].auto_publish"],
      "hints": {
        "videos[0].auto_publish": "auto_publish is a top-level field on this endpoint and applies to every video in the call — move it out of videos[]. It cannot be set per item: there is one publish attempt per call, not one per video."
      },
      "accepted_fields": ["videos", "auto_publish"]
    }
  }
}
```

| Field sent                  | Where                                                                                 | What you get                                                                                                                                                  |
| --------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target_publish_end_date`   | any video **configuration** body (`PUT .../videos/:position`, `PUT .../videos/batch`) | Refused: the window's end is derived as `target_publish_date + 1 day` and cannot be chosen. Use `PATCH /bundles/:id/videos/:position` for an explicit window. |
| `target_publish_start_date` | any video **configuration** body                                                      | Refused: on those endpoints the field is called `target_publish_date`, and it already **is** the window's start.                                              |
| `auto_publish`              | inside `videos[]` on `PUT .../videos/batch`                                           | Refused: the flag is top-level on that endpoint and covers every video in the call.                                                                           |
| `target_publish_end_date`   | a **CSV import** column header                                                        | Refused with `CSV_PARSE_ERROR` for the same reason.                                                                                                           |

> **WARNING: These fire for every caller, not only MCP**
> Unlike the did-you-mean refusal below, the `hints` refusals ignore `API_STRICT_UNKNOWN_FIELDS`. Both fields used to be dropped in silence, so the call was answered `200` and the thing it asked for never happened — a publishing window nobody chose, a batch that was never published. A `400` naming the fix is strictly better than that. Two production REST integrations were sending `target_publish_end_date` on configure when this shipped.

> **INFO: Who gets the 400 today**
>
> The refusal is gated by the `API_STRICT_UNKNOWN_FIELDS` server setting, currently `mcp`:
>
> - **MCP callers** get the `400 UNKNOWN_FIELD`.
> - **REST callers** run as before and get the names back in `meta.ignored_fields` on the response.
>
> The gate exists because request bodies are not stored, so there is no way to know in advance which live integrations would start failing. `meta.ignored_fields` is the telemetry that will decide when the refusal is extended to every caller — treat anything appearing there as a bug in your integration, not as a supported way to send extra data.

Two related cases are reported rather than refused:

- A `link_in_bio` on a non-Instagram bundle is accepted, stored as `null`, and named in the response's `_warnings` array.
- Platform-inapplicable video fields (TikTok-only fields on an Instagram slot, and vice versa) come back as `ignored_fields`.

Read `meta.ignored_fields` and `_warnings`, not just the status code.

## Handling Errors in Code

```typescript
const response = await fetch("https://app.tokportal.com/api/ext/bundles", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.TOKPORTAL_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(bundleConfig),
});

const result = await response.json();

if (result.error) {
  switch (result.error.code) {
    case "INSUFFICIENT_CREDITS":
      console.error(
        `Need ${result.error.details.missing} more credits.`
      );
      break;
    case "AUTH_INVALID_KEY":
      console.error("Invalid API key. Check your configuration.");
      break;
    case "rate_limited":
    case "RATE_LIMIT_EXCEEDED": // Legacy code
      // Wait and retry
      await new Promise((r) => setTimeout(r, 5000));
      break;
    default:
      console.error(`API error: ${result.error.message}`);
  }
}
```
