The TokPortal API uses a consistent error format across all endpoints. Errors are returned as JSON with an error object.
{
"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:
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.
| 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. |
| Code | HTTP | Description |
|---|
rate_limited | 429 | Current per-key limiter code. Wait for Retry-After, then retry. See 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.
| 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.
| Code | HTTP | Description |
|---|
INSUFFICIENT_CREDITS | 402 | Not enough credits. Response includes required, available, missing, and breakdown in details. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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 below. |
| 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). |
| 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. |
| Code | HTTP | Description |
|---|
VERIFICATION_CODE_NOT_FOUND | 404 | No pending verification code was found for this account. |
| 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. |
| 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. |
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:
{
"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.
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:
{
"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.
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}`);
}
}