# Errors

> The error envelope, every error code and the detail reasons the ShopAssist API returns.

Source: https://shopassist.coec.dk/docs/api/errors · Updated 2026-10-01

Every non-2xx JSON response has the same shape:

```json
{
  "error": {
    "code": "forbidden",
    "message": "This API key lacks the required scope.",
    "details": { "required_scope": "leads:read" },
    "request_id": "req_01k6a8x3qv6f2m9c4j7t5r0bwe"
  }
}
```

* `code` is stable; branch on it, not on `message` or the HTTP status alone.
* `details` depends on the code (for validation errors it lists the invalid `fields`).
* `request_id` identifies the request in our logs.

Validation errors are always `422 validation_error`, with `details.fields` as `[{ loc, msg, type }]`. Unknown request fields are rejected. Ignore unknown fields and unknown enum values in responses: they may be added at any time.

## Error codes [#codes]

| Code | Meaning |
| --- | --- |
| `bad_request` | The request is malformed, for example invalid JSON or a missing required header. Fix the request before retrying. |
| `invalid_id` | An id has the wrong prefix (for example `sto_` where `conv_` was expected) or cannot be decoded. |
| `invalid_cursor` | The pagination cursor is invalid or has expired. Start again from the first page. |
| `unauthenticated` | No valid credentials were sent. Send `Authorization: Bearer sa_live_…` (merchant API) or sign in again. |
| `session_invalid` | The widget session is missing, expired or revoked. The widget creates a new session automatically. |
| `identity_required` | The operation needs a verified shopper identity (signed in, or an order verified by email code). |
| `identity_invalid` | The identity token could not be verified. `details.reason` says which check failed (signature, issuer, audience, expiry). |
| `mfa_required` | Multi-factor authentication is required, or a recent sign-in is needed for a sensitive change. |
| `signature_invalid` | A webhook, plugin or proxy signature did not verify. Check the secret and the server clock. |
| `forbidden` | The credentials are valid but not allowed to do this, for example a missing API key scope or a test key calling a write endpoint. |
| `origin_not_allowed` | The page's origin is not in the store's allowed domains. Add the domain in the dashboard. |
| `csrf_failed` | The request's `Origin` header could not be verified. Browsers send it automatically; reload the page. |
| `email_not_verified` | The account email must be verified before this action. |
| `plan_feature_unavailable` | The feature is not in the current plan. `details.required_plan` names the lowest plan that has it. |
| `plan_limit_exceeded` | A plan limit was reached. `details` holds `limit`, `value` and `max`. |
| `store_suspended` | The store is suspended. Contact support. |
| `organization_suspended` | The organization is suspended. Contact support. |
| `challenge_required` | A bot-protection challenge must be solved before the request is accepted. |
| `verification_code_invalid` | The one-time code is wrong. `details.attempts_remaining` says how many attempts are left. |
| `not_found` | The resource does not exist or is not visible to these credentials. |
| `method_not_allowed` | The HTTP method is not allowed on this path. |
| `conflict` | The request conflicts with the current state. `details.reason` explains the conflict. |
| `response_in_progress` | The assistant is still answering in this conversation. Wait for the current reply. |
| `conversation_closed` | The conversation is closed. Start a new one. |
| `action_not_pending` | The proposed action is no longer waiting for confirmation (already confirmed, declined or expired). |
| `sync_in_progress` | A sync of this kind is already running for the store. |
| `idempotency_in_progress` | A request with the same `Idempotency-Key` is still being processed. Retry after a moment. |
| `action_expired` | The confirmation card expired before it was confirmed. |
| `verification_expired` | The verification (for example an email code) expired. Request a new one. |
| `token_expired` | The link or token has expired or was already used. |
| `version_conflict` | The resource was changed by someone else since you read it. Reload and apply your change again. |
| `payload_too_large` | The request body exceeds the size limit of the endpoint. |
| `message_too_long` | The chat message exceeds the maximum length. |
| `file_too_large` | The uploaded file exceeds the size limit. |
| `unsupported_media_type` | The content type is not supported by this endpoint. |
| `validation_error` | The request failed validation. `details.fields` lists each field with its location, and `details.reason` names rule failures. |
| `idempotency_key_reused` | The `Idempotency-Key` was already used with a different request body. |
| `action_no_longer_eligible` | The action is no longer possible, for example because the order shipped in the meantime. |
| `verification_locked` | Too many wrong codes were entered. Request a new code later. |
| `rate_limited` | Too many requests. Wait for the number of seconds in `Retry-After`; `details.scope` names the limit. |
| `quota_exceeded` | The usage quota was reached. `details.reason` says which quota (for example `free_cap_reached`). |
| `internal_error` | Something went wrong on our side. Retry with backoff and contact support with the `request_id` if it persists. |
| `not_implemented` | The endpoint exists in the contract but is not available yet. |
| `platform_error` | The store platform did not respond as expected. `details.retryable` says whether a retry can help. |
| `service_unavailable` | ShopAssist is temporarily unavailable, for example during maintenance. Retry after `Retry-After`. |

## Detail reasons [#reasons]

Some codes carry `details.reason` with a more specific cause:

**`unauthenticated`**

| Reason | Meaning |
| --- | --- |
| `code_invalid` | The connection or pairing code is unknown, expired, already used or does not match the platform. |

**`identity_invalid`**

| Reason | Meaning |
| --- | --- |
| `malformed` | The token is not a valid JWT. |
| `algorithm` | The token is not signed with HS256. |
| `unknown_kid` | The `kid` header does not match the store's current or previous identity key. |
| `signature` | The signature does not verify with the identity secret. |
| `issuer` | `iss` is not `generic:{store_id}` (or the platform issuer). |
| `audience` | `aud` is not `shopassist`. |
| `session_mismatch` | `sid` is not the widget session that requested the token. |
| `expired` | The token has expired. |
| `not_yet_valid` | `iat` or `nbf` lies in the future (check the server clock). |
| `lifetime_too_long` | `exp − iat` is longer than 300 seconds. |

**`mfa_required`**

| Reason | Meaning |
| --- | --- |
| `mfa_pending` | Sign-in is waiting for the second factor. |
| `recent_auth_required` | This change needs a recent sign-in or a fresh MFA code. |

**`session_invalid`**

| Reason | Meaning |
| --- | --- |
| `missing` | No session token was sent. |
| `malformed` | The session token cannot be parsed. |
| `signature` | The session token signature is invalid. |
| `store_mismatch` | The session belongs to another store. |
| `origin_mismatch` | The session was created on another origin. |
| `expired` | The session expired. |
| `revoked` | The session was revoked. |

**`signature_invalid`**

| Reason | Meaning |
| --- | --- |
| `headers_missing` | Signature headers are missing. |
| `unknown_store` | The store in the signature is unknown. |
| `unknown_key` | The key id is not known for the store. |
| `timestamp` | The timestamp is outside the allowed window; check the server clock. |
| `nonce` | The nonce was already used. |
| `store_mismatch` | The key belongs to a different store. |
| `signature` | The signature does not match. |

**`forbidden`**

| Reason | Meaning |
| --- | --- |
| `abuse_blocked` | The visitor or session is blocked by abuse protection. |
| `test_key` | Test keys cannot call write endpoints. |
| `email_mismatch` | The invitation is for a different email address. |
| `ip_not_allowed` | The request IP is not allowed for this endpoint. |
| `support_access_not_granted` | The merchant has not granted support access. |
| `operator_mfa_required` | Operators need TOTP multi-factor authentication. |
| `feature_disabled` | The feature is switched off for this store. |
| `not_generic` | This endpoint only works for stores connected with the Generic connector. |

**`challenge_required`**

| Reason | Meaning |
| --- | --- |
| `invalid_solution` | The challenge solution was wrong. |
| `expired` | The challenge expired. |

**`conflict`**

| Reason | Meaning |
| --- | --- |
| `identity_changed` | The signed-in shopper changed during the conversation. |
| `identity_required_for_conversation` | This conversation belongs to a signed-in shopper. |
| `store_already_connected` | The site is already connected to another ShopAssist store. |
| `clone_detected` | The connection looks like a copy of another site (staging clone). |
| `feed_authoritative` | A product feed owns this catalog; the Push API cannot write to it. |
| `generic_only` | The operation is available only for Generic stores. |
| `no_deletion_scheduled` | No deletion is scheduled that could be cancelled. |
| `scope_required` | A platform scope is missing (`details.scope`). |
| `handoff_active` | A handoff is already active. |
| `open_conversations_limit` | Too many open conversations for this session. |
| `images_limit` | The image limit of the conversation is reached. |
| `retry_not_allowed` | The message cannot be retried. |
| `already_taken_over` | Another agent already took over. |
| `not_assignee` | Only the assigned agent can do this. |
| `mode_not_human` | The conversation is not in human mode. |
| `billed_by_shopify` | The account is billed by Shopify; change the plan in Shopify. |
| `manual_billing` | The account is billed manually by COEC. |
| `contact_sales` | This change needs a sales contact. |
| `subscription_exists` | A subscription already exists. |
| `unpaid_invoices` | There are unpaid invoices. |
| `currency_locked` | The billing currency cannot change after the first invoice. |
| `no_subscription` | There is no subscription. |
| `no_change` | The requested plan is the current plan. |
| `payment_past_due` | A payment is past due. |
| `payment_method_required` | Add a payment method first. |
| `no_scheduled_change` | No plan change is scheduled. |
| `no_customer` | No billing customer exists yet. |
| `annual_cancel_in_dashboard` | Cancel annual plans in the dashboard. |
| `scheduled_change_pending` | A plan change is already scheduled. |
| `not_shopify` | The account is not billed by Shopify. |
| `run_closed` | The replace_all run is already finished or expired; start a new run. |

**`validation_error`**

| Reason | Meaning |
| --- | --- |
| `too_short` | The password is too short. |
| `password_too_long` | The password is too long. |
| `context_word` | The password contains your name, email or the product name. |
| `common_password` | The password is too common. |
| `breached_password` | The password appeared in a data breach. |
| `password_required` | A password is required. |
| `terms_version_outdated` | The terms were updated; reload and accept the current version. |
| `https_required` | The URL must use HTTPS. |
| `port_not_allowed` | The URL uses a port that is not allowed. |
| `url_not_public` | The URL does not resolve to a public internet address. |
| `ssrf_blocked` | The URL points to a private or reserved network. |
| `base_url_forbidden` | This base URL is not allowed. |
| `privacy_policy_url_origin` | The privacy policy URL must be on the store's own domain. |
| `malware_detected` | The file failed the malware scan. |
| `residency_ack_required` | Confirm the BYOK data-processing attestation. |
| `invalid_key_format` | The API key does not have the provider's format. |
| `key_provider_mismatch` | The key belongs to a different provider. |
| `gemini_free_tier` | Free-tier Gemini keys are not accepted; use a paid-tier key. |
| `unknown_model` | The model is not known for this provider. |
| `api_key_required` | An API key is required. |
| `rule_override` | The text tries to override a fixed assistant rule. |
| `discount_code_not_registered` | The text mentions a discount code that is not registered. |
| `ai_disclosure_conflict` | The text would hide or deny that the assistant is an AI. |
| `persona_name_implies_human` | The assistant name suggests a human. |
| `unfair_practice_lint` | The text uses fake urgency or scarcity. |
| `consent_text_lint` | The consent text is not specific enough. |
| `unknown_consent_text_version` | The consent text version is unknown. |
| `cmp_category_map_required` | Map your consent tool's categories first. |
| `attestation_required` | An attestation must be confirmed first. |
| `version_not_current` | The legal document version is not the current one. |
| `store_required` | The attestation needs a store. |
| `site_mismatch` | The ShopAssist store is connected to a different site. |
| `use_shopify_app_store` | Shopify stores are installed from the Shopify App Store. |
| `signature_invalid_check_offloader_header` | Magento could not verify our requests; set the offloader header. |
| `waf_blocked` | A firewall blocked ShopAssist. |
| `basic_auth` | The store is protected by HTTP Basic authentication. |
| `unreachable` | ShopAssist could not reach the store. |
| `acl_missing` | The Magento integration lacks permissions. |
| `rest_blocked` | The WordPress REST API is blocked for our requests. |
| `signature_invalid` | The connection signature could not be verified; check the server clock. |
| `not_purchasable` | The product cannot be bought. |
| `out_of_stock` | The variant is out of stock. |
| `excluded` | The product is hidden from the assistant. |
| `max_qty` | The quantity exceeds the maximum. |
| `max_extensions` | The confirmation cannot be extended again. |
| `in_stock` | The variant is in stock, so no back-in-stock alert is needed. |
| `consent_required` | Consent is required first. |
| `credential_rejected` | The integration rejected the credentials. |
| `currency_not_allowed` | The currency is not offered. |
| `country_required` | A billing country is required. |
| `trial_plan_invalid` | Trials are only for the trial plan. |
| `trial_already_redeemed` | A trial was already used for this store domain. |
| `store_not_connected` | Connect the store first. |
| `currency_mismatch` | The currency does not match the billing account. |
| `amount_out_of_range` | The amount is outside the allowed range. |
| `url_not_allowed` | The URL is not https on one of the store's origins or allowed link domains. |
| `template_invalid` | The URL template is invalid (unknown placeholders, or {items} missing). |
| `mapping_invalid` | The field mapping could not be applied to the feed. |
| `header_not_allowed` | This HTTP header name may not be set on outgoing feed or RPC requests. |
| `run_fields_required` | This request needs the run fields (run_id and mode) of an open import run. |
| `provider_priced` | This billing account is priced by its provider (e.g. Shopify), so this override is not allowed. |

**`action_no_longer_eligible`**

| Reason | Meaning |
| --- | --- |
| `status_changed` | The order status changed. |
| `already_shipped` | The order has shipped. |
| `ownership` | The order does not belong to the shopper. |
| `policy` | The store's policy does not allow it. |
| `window_closed` | The time window has closed. |

**`rate_limited`**

| Reason | Meaning |
| --- | --- |
| `auth_ip` | Too many sign-in attempts from this address. |
| `auth_account` | Too many sign-in attempts for this account. |
| `auth_lockout` | Sign-in is temporarily locked after failed attempts. |
| `visitor_conversations` | Too many new conversations from this visitor. |
| `live_streams` | Too many open live streams. |
| `test_conversations` | Too many test conversations. |
| `shopper_mail` | Too many emails to this shopper. |
| `webhook_test` | Too many webhook test deliveries. |

**`quota_exceeded`**

| Reason | Meaning |
| --- | --- |
| `test_daily_cap_reached` | The daily limit of test AI replies is reached. |
| `free_cap_reached` | The Free plan's monthly conversation cap is reached. |
| `trial_cap_reached` | The trial's conversation cap is reached. |
| `overage_budget_reached` | The overage budget is used up. |
| `subscription_inactive` | The subscription is not active. |
| `managed_ai_not_included` | Managed AI is not included in the plan. |
| `byok_cap_reached` | The own-key conversation allowance is reached. |
| `plan_cap_reached` | The plan's included conversations are used and overage is off. |
| `store_over_plan_limit` | The store is over the plan's store limit. |

## Retrying [#retrying]

Retry `429`, `502` with `details.retryable: true`, `503` and network errors with exponential backoff, honouring `Retry-After`. Use an [idempotency key](/docs/api/idempotency) on `POST` requests you retry.
