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.

the one error shape
{
  "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

codeHTTPMeaning
validation_failed400The request body or query did not validate. details[] names each field.
unauthenticated401No key, unknown key, revoked key, or an inactive tenant.
forbidden403The key is valid but lacks the scope, or a publishable key used from the wrong origin.
not_found404Absent, or another tenant’s. The two are deliberately indistinguishable.
conflict409A stale version on autosave, or a state transition that is not allowed.
rate_limited429Over the tenant budget. See retry-after.
provider_unavailable503A downstream provider is down. Retryable.
eligibility_failed500The engine could not resolve an answer. Do not treat as "no visa needed".
payment_declined402The payment provider declined.
document_rejected422An uploaded file failed validation.
internal500Something 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.

PlanRequests / minuteChecks / month
Developer60100
Starter30010,000
Growth1,200100,000
Enterprise6,000Unlimited

Every response carries the current state, so you can throttle yourself before being throttled:

response headers
x-ratelimit-limit: 1200
x-ratelimit-remaining: 1197
x-ratelimit-reset: 60

On 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

idempotent capture
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.