Errors
Unsuccessful requests return a consistent JSON shape. Branch on the machine-readable code — never on message, which is human-facing and may be reworded at any time.
Error shape
400 — validation
{
"statusCode": 400,
"code": "validation_error",
"error": "Bad Request",
"message": [
"platforms must contain only valid values: youtube, tiktok, instagram",
"keywords must contain at least 1 elements"
]
}
| Field | Type | Description |
|---|---|---|
statusCode | integer | HTTP status, mirroring the response status |
code | string | Stable machine-readable identifier. Branch on this. Values are only ever added — never renamed or repurposed |
error | string | Short human label for the status |
message | string | string[] | Human-readable detail (an array for validation errors). Not stable — do not parse |
Some errors add fields specific to the failure — a 402 carries required_credits and remaining_credits, a 429 carries limit, remaining, reset_at, and retry_after.
Error codes
code | Status | When it happens | What to do |
|---|---|---|---|
validation_error | 400 | A parameter is missing, malformed, or out of range | Read message[] and fix the request |
invalid_date_range | 400 | start_date/end_date missing, unparseable, or spanning more than the endpoint's maximum window (90 days on hashtags) | Narrow the window |
unknown_region | 400 | A region value isn't a supported trend region | Call GET /v1/trends/regions for the current list |
unknown_event_type | 400 | A webhook subscription names an event that doesn't exist | See supported events |
missing_api_key | 401 | No Authorization header was sent | Add Authorization: Bearer virlo_tkn_… |
invalid_api_key | 401 | The key is malformed, revoked, or inactive | Regenerate from the dashboard |
insufficient_credits | 402 | Balance is below the cost of the requested operation | Top up, then retry. Compare required_credits to remaining_credits |
forbidden | 403 | The key is valid but not permitted on this resource | Check the resource belongs to your team |
not_found | 404 | The resource doesn't exist, or isn't owned by your team | Verify the ID; a wrong-team ID reports as not-found by design |
conflict | 409 | The operation conflicts with current state | Re-read the resource and retry |
rate_limit_exceeded | 429 | You exceeded a rate limit window | Wait retry_after seconds — see Rate Limits |
upstream_error | 502 / 504 | A dependency was unreachable or too slow | Retry with backoff; the request was not billed |
service_unavailable | 503 | Virlo is temporarily unavailable | Retry with backoff |
internal_error | 500 | Unexpected server-side failure | Retry once; if it persists, contact support with the timestamp |
code is additive to the response — every field that existed before is unchanged. If you already branch on statusCode, nothing breaks; adopt code when you want to distinguish two failures that share a status (for example invalid_date_range vs a generic validation_error, both 400).
Status codes
| Range | Meaning | Billed? |
|---|---|---|
2xx | Success | Yes — only successful responses are charged |
4xx | Client error (auth, validation, balance, rate limit) | No |
5xx | Server error | No |
Every response carries X-Cost and X-Credits-Used headers so you can confirm what a call charged — see Billing.
Handling errors
A reasonable client:
- Treats
4xxas terminal except429(waitretry_after) and408. - Retries
5xxwith exponential backoff — these are never billed, so retries are free. - Branches on
code, notmessage. - Surfaces
required_credits/remaining_creditson402rather than retrying, since retrying cannot succeed.
Branching on code
const res = await fetch(url, { headers })
if (!res.ok) {
const err = await res.json()
switch (err.code) {
case 'rate_limit_exceeded':
await sleep(err.retry_after * 1000)
return retry()
case 'insufficient_credits':
throw new Error(
`Need ${err.required_credits}, have ${err.remaining_credits}`,
)
case 'upstream_error':
case 'service_unavailable':
case 'internal_error':
return retryWithBackoff()
default:
throw new Error(err.message)
}
}
