The error codes a real integration hits
A field guide to the auth, scope, throttle, credit, and moderation rejections a working Versely integration actually sees, with the cause behind each.
A working Versely integration spends most of its error-handling on five families: the bearer was wrong, the key was not allowed to call that path, the key or the IP fired too fast, the balance could not cover the job, or the prompt was rejected by moderation. Read the status and the string. Do not switch on HTTP status alone: insufficient credits show up as 403 or 402 depending on when the check ran, and 429 bodies come in two shapes.
This is the field guide for the REST surface on the developer page: the rejections you see before a job is in flight, plus the status-poll outcomes once it is.
Auth: 401, and the string tells you which
API-key failures use an error field. Session JWT failures use message. OAuth (MCP) uses error or message depending on the branch. Log the whole JSON.
| Status | Body (short) | Actual cause |
|---|---|---|
| 401 | No valid authorization token provided |
Missing Authorization or it does not start with Bearer (session path) |
| 401 | No authorization token provided |
Same idea on the API-key path; the strings are not identical |
| 401 | Invalid API key |
Token started with vsk_ but no row matched the SHA-256 hash |
| 401 | API key has been revoked |
revoked_at is set. Rotate; do not retry the same raw value |
| 401 | API key has expired |
expires_at is in the past |
| 401 | Malformed token |
JWT did not split into three parts |
| 401 | Token has expired |
Session JWT exp is past, including a 60-second skew leeway |
| 401 | Invalid token issuer / Invalid token audience |
Session JWT iss is not Supabase, or aud is not authenticated. MCP tokens take the OAuth path instead. |
| 401 | Token has been revoked |
Session JWT is on the logout/password-change denylist |
| 401 | Invalid or expired token |
Supabase getUser rejected the signature or expiry |
| 401 | User not found |
Token verified, user row missing |
| 401 | Token subject mismatch |
JWT sub did not match the user Supabase returned |
| 401 | Bad signature / Token expired / Unexpected aud / Unexpected iss |
MCP token failed local verify. message is that error; fallback is Invalid OAuth token. |
Invalid API key after a rotation usually means the worker still has the old secret, or a truncated copy (the raw value is vsk_ plus 40 hex characters; a prefix is not enough). API key has been revoked means the delete already succeeded; mint a replacement. An MCP token in VERSELY_API_KEY is not Invalid token issuer; a verify failure uses the OAuth message strings above.
Scope and identity: 403
| Status | Body | Actual cause |
|---|---|---|
| 403 | API key lacks required scope: <name> |
The path is owned by a catalog scope the key does not have. generate does not cover /social/posts. post does not cover /social/accounts. |
| 403 | OAuth token lacks required scope: <name> |
Same check, MCP access token |
| 403 | Access denied: user_id does not match authenticated user |
The body sent a user_id / userId that is not the token's user. Omit the field; the middleware fills it from the token. |
| 403 | Access denied: you can only access your own resources |
A :userId path param that is not you |
The scope name in the 403 is the fix: grant that name on a new key (there is no PATCH), or call the path your key actually owns. A generate worker that 403s on /agentic/chat is not broken. Chat is a read prefix. Point the worker at /generate/....
Throttle: 429, two bodies
Per-key RPM (default 60, set at mint, clamped 1–1000) returns:
{ "success": false, "error": "Rate limit exceeded", "retryAfter": 42 }
plus X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (unix timestamp). Sleep retryAfter seconds. Do not POST /generate again as a wait; there is no idempotency key, so a retry that lands is a second job.
Named limiters (generate 30/min per IP, agentic chat 60/min per user, social post 10/min per user, social sync 5/min per user, auth 30 per 15 min per IP) return:
{
"success": false,
"error": { "code": "429", "message": "Too many requests. Please try again later." },
"retryAfter": 12
}
If error is a string, it is the per-key fuse. If error is an object with code: "429", it is a named limiter. A production key at 60 RPM that 429s with the object shape on /generate is the 30/min IP cap, often because CI and production share a NAT. Auth 429s on /api/v1/auth/api-keys mean a mint/revoke loop, not a generate problem.
Credits and moderation: 403, 402, 400
Credits are checked twice, and the status codes are not the same. The credits page is the unit. Pricing is how the balance is topped up. There is no free API allowance.
| Status | Body | When it fires |
|---|---|---|
| 403 | Insufficient credits |
Middleware on generate: the profile balance is <= 0. The job never starts. |
| 402 | Insufficient credits (often with a suffix: for this generation, for editor export, credits_required) |
Charge time. Balance was positive but not enough for this model/duration/count. |
| 400 | User not found from the credits middleware |
Authenticated, but no profile row to read a balance from |
A 403 on a zero balance and a 402 on a shortfall for this job is why a client that only handles 402 still looks "up" on an empty wallet. Handle both. Pre-flight with GET /api/v1/user/me (read user.credits) and with POST /api/v1/ai-models/calculate-credits before you submit a batch. The agent versions of the same idea are check_credits and estimate_cost; check credits before generating and estimating credit cost before you dispatch walk those.
Moderation runs on POST/PUT/PATCH text fields before the handler. GET and DELETE skip it. A block is:
{
"error": "Content policy violation",
"category": "<category>",
"message": "Your request contains content that violates our usage policy. Please modify your input and try again."
}
Status 400. Categories the filter names include CSAM, Hate Speech, Dangerous, Harassment, and non-consensual sexual content. Creative, commercial, dark fiction, and ambiguous prompts are supposed to pass. The middleware fail-opens if the moderation backend errors, so a 400 with that error string is a real policy hit, not an outage. Changing one adjective and retrying the same CSAM-shaped prompt will 400 again. See safety checker for the product concept; the API signal is this 400.
The editor has one extra 429: preview: true is a free 480p pass with a short per-user cooldown (five seconds). Fire it again inside that window and you get 429 { success: false, error: "Preview cooldown: please wait a few seconds before requesting another preview", retry_after_ms: <remaining> }. Sleep that many milliseconds. The final export is a single charge regardless of clip count; insufficient balance on that export is 402 Insufficient credits for editor export.
Once the job is in flight
POST /generate/... returns a request_id (providers also use task_id; read whichever the body has). Poll GET /api/v1/status/:requestId:
| Status | Meaning | What you do |
|---|---|---|
generating |
Not done | Poll again. |
completed |
result_url / result_urls present |
Take the URL. Stop polling. |
failed |
error (sanitized), transient, retry_suggested |
If transient / retry_suggested is true, submitting again can make sense. If not, fix the input. |
| HTTP 404 | Generation not found for this request_id |
Wrong id, or it belongs to another user |
transient is true when the raw failure reason contains "try again", "internal error", or "temporary". That is the closest thing the API gives you to "retry the generate." Everything else is a bad prompt, a bad asset, or a permanent model/input problem. Looping POST /generate on every failed is how a quiet night becomes a credit hole.
Key-management 400s you will also hit while wiring this: Name is required (max 100 characters), At least one scope is required..., Invalid scopes.... Those are create-body bugs, not generate bugs. 404 API key not found or already revoked on DELETE means you already finished the rotation.
FAQ
Why did I get 403 Insufficient credits with credits still on the account?
You probably did not. The 403 middleware fires when credits <= 0. A positive but too-small balance fails later at charge time with 402. If you truly see 403 with a non-zero user.credits on GET /user/me, you are not looking at the same user the token authenticates. Check that the worker's key belongs to the account you are reading in the dashboard.
Should I retry every 429 with exponential backoff?
Sleep the retryAfter the body gave you. Exponential backoff that ignores retryAfter either waits too long or retries inside the window. And only retry the same POST if you intend a second generation. Status polls are the wait path for work already accepted.
A 400 Content policy violation on a product-ad prompt: now what?
Read category. If it is a false hit on ordinary commercial copy, strip the phrasing that names a blocked concept (negation often still names it) and resubmit. If category is CSAM, Dangerous, or non-consensual sexual content, do not retry. The hard-block patterns and the model agree on those.
Why is user_id in every example if sending it 403s?
Older snippets put user_id in the generate body. enforceUserId now overwrites it from the token and rejects a mismatch. Send the prompt, the model, the duration. Do not send a user id. The 403 string is Access denied: user_id does not match authenticated user.