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

CategoryMeaningTypical response
user_inputCaller-side mistake (bad params, expired key, quota hit)Change input/credentials. Don't blind-retry.
provider_faultThe connected ESP rejected the request (auth scope, plan limits)Reconnect / fix at provider, then retry.
transientTemporary network / 5xx / rate limitRetry with exponential backoff. Honor fix.hint retry-after.
systemVorlek-side bug or infrastructure failureEcho 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

HTTPCodeCategoryretry_safeRecovery
400AUTH_MALFORMEDuser_inputfalseAuthorization header missing, not Bearer, or fails the vk_(live|test)_… regex. Re-acquire a key.
401AUTH_INVALIDuser_inputfalseVorlek key prefix matched a row but bcrypt comparison failed. The key is wrong. Re-acquire.
401AUTH_REVOKEDuser_inputfalseKey was rotated. fix.action: rotate_api_key. Use the new key.

Input validation

HTTPCodeCategoryretry_safeRecovery
400INVALID_PARAMSuser_inputfalseZod rejected the body. error.message carries <field>: <reason>.
413PAYLOAD_TOO_LARGEuser_inputfalseProvider rejected the size. fix.action: reduce_payload_size.

Quota & connections

HTTPCodeCategoryretry_safeRecovery
429QUOTA_EXCEEDEDuser_inputfalseAccount hit the period limit. meta.quota populated. fix.action: upgrade.
400CONNECTION_MISSINGuser_inputfalseNo active connection for provider. Call POST /v1/connections first.
400CONNECTION_NOT_ACTIVEuser_inputfalseConnection was soft-disconnected. Reconnect.

Provider-side

HTTPCodeCategoryretry_safeRecovery
401AUTH_INVALIDprovider_faultfalseThe stored provider key is no longer valid. fix.action: reconnect.
403AUTH_SCOPE_INSUFFICIENTprovider_faultfalseProvider key lacks the scope. fix.action: expand_scope (e.g., SendGrid Marketing).
400FIELD_TYPE_MISMATCHuser_inputfalseA properties value's type doesn't match the existing provider field. Change value type, or rename the property.
404NOT_FOUNDuser_inputfalseProvider returned 404 (rare on upsert; possible on reference lookups).

Transient (safe to retry)

HTTPCodeCategoryretry_safeRecovery
429PROVIDER_RATE_LIMITEDtransienttrueProvider's rate limit. Honor fix.hint retry-after.
504PROVIDER_TIMEOUTtransienttrueNetwork or provider timeout. Retry with backoff.
502PROVIDER_UNAVAILABLEtransienttrueDNS / TLS / 5xx. Retry with backoff.

System (Vorlek-side)

HTTPCodeCategoryretry_safeRecovery
500CONNECTION_DECRYPT_FAILEDsystemfalseServer-side AES failure. File a support ticket with request_id.
500INTERNAL_ERRORsystemtrueUnhandled 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.