Answer: Every ASHDOCS error is JSON. Endpoint errors return {"detail": {"code", "message", and sometimes "hint"}}; transport-level errors from the rate limiter return {"success": false, "error": {"code", "message"}}. The code field is stable — branch on it, not on message text.
The two envelopes
Endpoint validation and business errors (the vast majority) come from the API handler and look like the first example. Rate limiting happens in middleware before the handler and uses the second envelope. Both carry the same machine-readable code idea.
// endpoint error (400/402/403/404/410/413/500)
{ "detail": { "code": "insufficient_credits",
"message": "You need 3 credits but have 1 remaining...",
"hint": "Upgrade or top up at /pricing." } }
// rate limiter (429)
{ "success": false, "error": { "code": "rate_limited", "message": "Rate limit exceeded" } }Retry guidance
Retryable: rate_limited and concurrency_limit_reached (back off using the Retry-After header), and tool_failed (a transient conversion failure — retry once; failed jobs are never charged, so a retry never double-bills). Everything else is a request problem: fix the payload instead of retrying. insufficient_credits clears when your credits reset or you top up.
Triggering errors on purpose
Three quick reproductions you can run against a sandbox key: send unknown-tool to get 404 unknown_tool; send an 11 MB file on the Free plan to get 413 file_too_large; fire 11 requests inside a minute on Free to get 429 rate_limited with Retry-After set.
curl -s -X POST https://www.ashdocs.com/api/v1/tools/not-a-tool \
-H "X-API-Key: ash_test_xxxxxxxx" -H "Content-Type: application/json" -d '{}'
# -> 404 { "detail": { "code": "unknown_tool", ... } }Error code catalog
Derived from the backend source; HTTP status first, then the stable code.
| HTTP | code | What it means | Retryable? |
|---|---|---|---|
| 400 | invalid_options / bad_options | The options JSON is malformed or has wrong types | No — fix the request |
| 400 | missing_field / schema_required / url_required / file_required | A required input wasn't sent | No |
| 400 | no_files / too_many_files | Zero files, or more than the tool accepts | No |
| 400 | unsupported_file_type | The file's type isn't accepted by this tool | No |
| 400 | bad_file_url / file_url_is_webpage / file_url_fetch_failed | file_url didn't resolve to a downloadable document | Only fetch failures |
| 400 | virus_detected | The upload failed the malware scan | No |
| 401 | — (detail is the string "Invalid API key") | Missing, wrong or revoked key | No — rotate the key |
| 402 | insufficient_credits | Not enough credits for this job (checked before running; the job never starts) | After top-up / reset |
| 403 | ssrf_blocked | The URL points at a private or metadata IP | No |
| 403 | batch_not_available / webhooks_not_available | That feature starts on a higher plan | No — upgrade |
| 404 | unknown_tool / document_not_found / template_not_found | The slug or ID doesn't exist (or isn't yours) | No |
| 410 | document_expired / link_expired | The signed URL or stored document passed its expiry | No — re-run the job |
| 413 | file_too_large | The file exceeds your plan's per-file cap (10/25/50/100/200 MB) | No — or upgrade |
| 429 | rate_limited | Plan requests/minute exceeded — see Retry-After | Yes, with backoff |
| 429 | concurrency_limit_reached | Your plan's concurrent-jobs cap is in use | Yes, when a job finishes |
| 500 | tool_failed | The conversion itself failed; the job is marked failed and never charged | Once |
Frequently asked questions
Am I charged for failed jobs?
Never. Credits are only consumed when a job completes successfully — 4xx validation failures don't even start a job, and 500 tool_failed jobs are finalized without any charge.
Is detail.code stable enough to branch on?
Yes — codes are treated as API contract. Messages and hints are for humans and may be reworded.