Errors and rate limits
Every failure — including 500s — returns the same envelope. Branch on code; the prose is written for a human reading a log and may be reworded without notice.
{
"error": {
"code": "validation_failed",
"message": "Request validation failed",
"details": [
{ "path": "nationality_iso3", "issue": "Nationality must be an uppercase ISO 3166-1 alpha-3 code" }
],
"request_id": "0f4a1c2e-..."
}
}The codes
| code | HTTP | Meaning |
|---|---|---|
validation_failed | 400 | The request body or query did not validate. details[] names each field. |
unauthenticated | 401 | No key, unknown key, revoked key, or an inactive tenant. |
forbidden | 403 | The key is valid but lacks the scope, or a publishable key used from the wrong origin. |
not_found | 404 | Absent, or another tenant’s. The two are deliberately indistinguishable. |
conflict | 409 | A stale version on autosave, or a state transition that is not allowed. |
rate_limited | 429 | Over the tenant budget. See retry-after. |
provider_unavailable | 503 | A downstream provider is down. Retryable. |
eligibility_failed | 500 | The engine could not resolve an answer. Do not treat as "no visa needed". |
payment_declined | 402 | The payment provider declined. |
document_rejected | 422 | An uploaded file failed validation. |
internal | 500 | Something broke. Quote request_id. |
404 rather than 403, on purpose
Asking for a record that belongs to another tenant returns 404, not 403. A 403 would confirm the record exists — which tells you something about another partner that you have no business knowing.
Rate limits
Budgets are per tenant, not per IP. An airline calling from one egress IP and a thousand partner offices behind a shared NAT must not share a ceiling.
| Plan | Requests / minute | Checks / month |
|---|---|---|
| Developer | 60 | 100 |
| Starter | 300 | 10,000 |
| Growth | 1,200 | 100,000 |
| Enterprise | 6,000 | Unlimited |
Every response carries the current state, so you can throttle yourself before being throttled:
x-ratelimit-limit: 1200
x-ratelimit-remaining: 1197
x-ratelimit-reset: 60On a 429, honour retry-after. The TypeScript SDK does this already, with jittered backoff — without the jitter, every client rate-limited in the same second retries in the same second.
What is safe to retry
GETandDELETE— always safe.POST /v1/check— safe. It is a query that uses POST to carry a body, so it has no side effect.POST /v1/applications,POST /v1/capture— only with anIdempotency-Keyheader. Without one, a retry can create a second application or a second authorisation.
curl -X POST https://api.haavisa.com/v1/capture \
-H "Authorization: Bearer $HAAVISA_API_KEY" \
-H "Idempotency-Key: order-42" \
-H "Content-Type: application/json" \
-d '{ "application_id": "...", "provider_id": "stripe", "return_url": "https://example.com/return" }'A replay returns the original result with replayed: true rather than charging twice.