Error codes
Every error Vorlek returns shares the same envelope shape. The category + retry_safe pair is the agent's branching signal: don't hand-parse messages.
Envelope shape
{
"status": "error",
"error": {
"code": "FIELD_TYPE_MISMATCH",
"message": "SendGrid field 'loyalty_tier' is declared as Number but received string 'gold'.",
"category": "user_input",
"retry_safe": false,
"provider": "sendgrid",
"fix": {
"action": "supply_matching_type",
"hint": "Pass a numeric value or rename the property to avoid the existing field."
}
},
"meta": {
"request_id": "01JD5K9X7M0Q8V0QY4FYVQ3WZV",
"total_latency_ms": 87
}
}
Category & retry semantics
| Category | Meaning | Typical response |
user_input | Caller-side mistake (bad params, expired key, quota hit) | Change input/credentials. Don't blind-retry. |
provider_fault | The connected ESP rejected the request (auth scope, plan limits) | Reconnect / fix at provider, then retry. |
transient | Temporary network / 5xx / rate limit | Retry with exponential backoff. Honor fix.hint retry-after. |
system | Vorlek-side bug or infrastructure failure | Echo request_id in a support email. Retry once if retry_safe. |
When error.retry_safe is true, retry with exponential backoff (start ≥ 500 ms; cap at 30 s). When false, retrying without changing input or credentials returns the same error.
Codes — upsert_contact endpoint
Auth
| HTTP | Code | Category | retry_safe | Recovery |
| 400 | AUTH_MALFORMED | user_input | false | Authorization header missing, not Bearer, or fails the vk_(live|test)_… regex. Re-acquire a key. |
| 401 | AUTH_INVALID | user_input | false | Vorlek key prefix matched a row but bcrypt comparison failed. The key is wrong. Re-acquire. |
| 401 | AUTH_REVOKED | user_input | false | Key was rotated. fix.action: rotate_api_key. Use the new key. |
Input validation
| HTTP | Code | Category | retry_safe | Recovery |
| 400 | INVALID_PARAMS | user_input | false | Zod rejected the body. error.message carries <field>: <reason>. |
| 413 | PAYLOAD_TOO_LARGE | user_input | false | Provider rejected the size. fix.action: reduce_payload_size. |
Quota & connections
| HTTP | Code | Category | retry_safe | Recovery |
| 429 | QUOTA_EXCEEDED | user_input | false | Account hit the period limit. meta.quota populated. fix.action: upgrade. |
| 400 | CONNECTION_MISSING | user_input | false | No active connection for provider. Call POST /v1/connections first. |
| 400 | CONNECTION_NOT_ACTIVE | user_input | false | Connection was soft-disconnected. Reconnect. |
Provider-side
| HTTP | Code | Category | retry_safe | Recovery |
| 401 | AUTH_INVALID | provider_fault | false | The stored provider key is no longer valid. fix.action: reconnect. |
| 403 | AUTH_SCOPE_INSUFFICIENT | provider_fault | false | Provider key lacks the scope. fix.action: expand_scope (e.g., SendGrid Marketing). |
| 400 | FIELD_TYPE_MISMATCH | user_input | false | A properties value's type doesn't match the existing provider field. Change value type, or rename the property. |
| 404 | NOT_FOUND | user_input | false | Provider returned 404 (rare on upsert; possible on reference lookups). |
Transient (safe to retry)
| HTTP | Code | Category | retry_safe | Recovery |
| 429 | PROVIDER_RATE_LIMITED | transient | true | Provider's rate limit. Honor fix.hint retry-after. |
| 504 | PROVIDER_TIMEOUT | transient | true | Network or provider timeout. Retry with backoff. |
| 502 | PROVIDER_UNAVAILABLE | transient | true | DNS / TLS / 5xx. Retry with backoff. |
System (Vorlek-side)
| HTTP | Code | Category | retry_safe | Recovery |
| 500 | CONNECTION_DECRYPT_FAILED | system | false | Server-side AES failure. File a support ticket with request_id. |
| 500 | INTERNAL_ERROR | system | true | Unhandled error path. Retry once; then escalate with request_id. |
Recovery patterns for agents
A reasonable agent loop:
response = call_vorlek(...)
if response.status == "success":
proceed()
elif response.error.retry_safe:
# transient or system-retry-safe
sleep_with_backoff()
retry() # cap at 3 attempts
elif response.error.fix.action == "rotate_api_key":
refresh_api_key_from_secret_store()
retry()
elif response.error.fix.action == "reconnect":
notify_user(f"Reconnect {response.error.provider}: {response.error.fix.hint}")
halt()
elif response.error.code == "FIELD_TYPE_MISMATCH":
coerce_value_to_matching_type()
retry() # once
else:
log(response.error, response.meta.request_id)
halt()
Don't parse error.message. It's human-readable and may evolve. Branch on error.code, error.category, error.retry_safe, and error.fix.action only.