Skip to main content

Error Codes

All API errors return a consistent JSON format:

{
"error": {
"code": "error_code",
"message": "Human-readable description",
"details": {}
},
"meta": {
"request_id": "req_xyz789"
}
}

Use the request_id when contacting support.

Authentication Errors​

CodeHTTP StatusDescriptionResolution
unauthorized401Missing or invalid API keyCheck your X-API-Key header
forbidden403API key lacks required permissionsUse a key with appropriate scopes
ip_not_allowed403Request from non-allowlisted IPAdd the IP to your key's allowlist in Settings
account_locked403Account is lockedContact support@zyphr.dev
account_suspended403Account suspended for policy violationContact support@zyphr.dev
account_inactive403The account is not active. On end-user sign-in (passkey, OAuth, magic link, gaming, anonymous, MFA completion, password change, account conversion), the end user is suspended, usually by your applicationIf you suspended them, re-activate the end user (PATCH /v1/auth/users/:id with status: "active"). If you didn't, or for your own Zyphr account, contact support@zyphr.dev
invalid_token401The bearer/session token is invalidRe-authenticate to obtain a fresh token
token_expired401The token has expiredRefresh the token or re-authenticate
key_revoked401The API key has been revokedCreate a new API key in Settings → API Keys
email_not_verified403The account/user email must be verified for this operationVerify the email address, then retry
geo_restricted403The request originates from a restricted regionContact support if you believe this is in error

Validation Errors​

CodeHTTP StatusDescriptionResolution
validation_error400Request body failed validationCheck details for specific field errors
invalid_url400Malformed URL providedEnsure valid URL format
invalid_body400Request body shape is invalidCheck the request schema
invalid_params400Invalid query or path parametersCheck the parameter values
invalid_provider400The specified provider is not a supported/valid provider for this operationUse a supported provider value
bad_request400The request is malformed or missing required contextCheck the request against the endpoint's documentation
wrong_endpoint400The credential/request was sent to the wrong endpoint (e.g. an end-user token on an account endpoint)Use the endpoint that matches your credential type

Rate Limiting​

CodeHTTP StatusDescriptionResolution
rate_limit_exceeded429Too many API requestsBack off and retry after retry_after seconds
otp_send_rate_limit_exceeded429Too many OTP/MFA code requestsWait before requesting another code
otp_verify_rate_limit_exceeded429Too many OTP verification attemptsWait before retrying
sms_send_rate_limit_exceeded429Too many SMS sends for this number (direct SMS-send API)Wait before retrying
rate_limited429Too many login attempts for this identifierWait before retrying
quota_exceeded429Monthly send / MAU quota reachedUpgrade your plan or wait for the quota to reset
usage_limit_exceeded429The monthly Webhooks-as-a-Service event limit for your plan is reached (POST /v1/waas/applications/{appId}/events). A batch publish still returns 201; each event over the limit is listed in results with code: "usage_limit_exceeded"Upgrade your plan or wait for the monthly reset
mau_quota_exceeded402An end-user sign-in would add a new monthly active user to an account already at its MAU limit. Users already counted this month can still sign in. The body includes limit and currentUpgrade your plan, or wait for the monthly reset
batch_exceeds_limit429The batch request exceeds the per-request recipient/item capSplit into smaller batches
daily_limit_exceeded429The channel's daily send limit for your plan is reached (push)Wait for the daily reset or upgrade your plan
sms_daily_limit_reached429The daily SMS send limit for your plan is reachedWait for the daily reset or upgrade your plan
sms_limit_reached429The SMS send limit for your plan is reachedUpgrade your plan or wait for the reset
session_limit_exceeded403The end user has reached the application's max_concurrent_sessions and the application refuses new sessions (deny_new). Returned by sign-ins that open a session for an existing user (password, phone/email OTP, passkey, OAuth, magic link, gaming, MFA). Anonymous sign-in, registration and account conversion don't apply the limit. For MFA logins it is normally returned before a challenge is issued, so no TOTP or backup code is spent. It can still be returned at MFA completion if the user reached the limit after the challenge was issued. The body includes max_sessions and current_sessionsRevoke an existing session, raise the limit, or switch the app to revoke_oldest
test_environment_limit403Creating this end user would exceed the test environment's user limitDelete test users or use the live environment
free_account_limit_reached403The per-user free-account cap has been reachedUpgrade a plan, or use an existing account

Account-level send throttle/freeze (reputation) has its own codes — see Sending Status (Reputation) Errors below.

Rate limit headers are included in every response:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1705312800

Sending Status (Reputation) Errors​

When your account's sending reputation degrades (high bounce or complaint rate, or a sudden volume spike), Zyphr throttles or freezes sending to protect the shared IP pool. These states apply only to send-capable endpoints — read, billing, and deliverability endpoints stay available so you can diagnose and recover.

CodeHTTP StatusDescriptionResolution
account_frozen403Sending is frozen for the account (reputation). The error message carries the specific reason.Stop sending to invalid addresses, clean your recipient list, then contact support@zyphr.dev to unfreeze.
account_throttled429A percentage of sends are rejected while reputation recovers. The error message carries the reason.Back off and retry; reduce bounce/complaint rates. Throttling lifts automatically once rates return to acceptable levels.

Every authenticated response includes the current sending state as headers, so you can surface it without a separate call:

X-Account-Sending-Status: frozen
X-Account-Sending-Status-Reason: Bounce rate (12.50%) exceeded freeze threshold (10%)

X-Account-Sending-Status is one of active, throttled, or frozen. X-Account-Sending-Status-Reason is present only when the status is not active. See API Limits for details.

Not Found Errors​

CodeHTTP StatusDescriptionResolution
not_found404Requested resource doesn't existCheck the resource ID
user_not_found404User doesn't exist (e.g. on MFA endpoints; also returned as 401 if the user disappears mid-MFA-challenge)Verify the user ID
template_not_found404Template ID doesn't existVerify the template_id
subscriber_not_found404Subscriber doesn't existCreate the subscriber first
device_not_found404Device ID doesn't existCheck device registration
topic_not_found404Topic doesn't existCreate the topic first
account_not_found404Account doesn't existVerify the account context
organization_not_found404End-user organization doesn't existVerify the organization ID
environment_not_found404Environment (test/live) doesn't existVerify the environment

Webhook 404s return the generic not_found code (with a "Webhook not found" message), not a webhook_not_found code.

Conflict Errors​

CodeHTTP StatusDescriptionResolution
conflict409Resource already existsUse update instead of create
domain_registered_elsewhere409Domain is claimed by another accountUse a different domain or contact support
already_verified400Domain/email is already verifiedNo action needed
duplicate_provider409An identity/OAuth provider of this type is already configured on the applicationUpdate the existing provider instead of adding a new one
duplicate_connection409The end user already has a connection for this providerNo action needed — the connection exists
subscription_exists400A subscription already exists for this accountManage the existing subscription instead
step_key_exists409A workflow step with this step_key already existsUse a unique step_key

Configuration Errors​

CodeHTTP StatusDescriptionResolution
sms_not_configured400 / 503Twilio credentials not set up. 400 on the direct SMS-send API; 503 on the auth phone-OTP endpoints (see Auth OTP Errors).Configure Twilio in Settings > SMS
domain_not_verified400 / 422Sending domain not verified (status is route-dependent)Verify domain in Settings > Domains
no_verified_domain422No verified sending domain on the projectAdd and verify a domain in Settings > Domains
domain_blocked403The sending domain is blocklistedContact support
not_configured501The requested feature/provider is not configuredEnable/configure it in Settings

MFA & Security Errors​

CodeHTTP StatusDescriptionResolution
mfa_required—MFA verification needed. Not an error — the login/verify endpoints return this as a 200 response body flag ({ "data": { "mfa_required": true, "mfa_challenge": { … } } }), not an error status. Branch on the mfa_required field.Complete the MFA challenge with the returned mfa_challenge.token
otp_invalid400OTP code is incorrectEnter the correct code
otp_expired400OTP code has expiredRequest a new code
otp_max_attempts400Too many failed attempts on this code — it is now lockedRequest a new code
invalid_totp401TOTP code is incorrectEnter the current code from the authenticator app
invalid_backup_code401Backup code is invalid or already usedUse an unused backup code, or re-verify with TOTP
invalid_mfa401MFA challenge is invalid or expiredRestart the MFA challenge
invalid_verification401Verification code is incorrect (TOTP re-verification of an active session)Re-enter the current code
invalid_code400Verification code is incorrect (during MFA enrollment)Re-enter the code from the authenticator app
mfa_not_enabled400MFA is not enabled for this accountEnroll in MFA first
mfa_already_enabled409MFA is already enabledNo action needed
no_pending_enrollment400No MFA enrollment is in progress to verifyStart enrollment before verifying
email_required400The account must have an email address to enable MFAAdd an email to the account first
Branch on code, not message

Every error response includes a stable, machine-readable error.code alongside the human-readable error.message. Branch your control flow on error.code (and the HTTP status) — treat message as display-only text that may be reworded or localized at any time.

Auth OTP Errors (Email & Phone)​

Returned by the email-OTP (/auth/email-otp/*) and phone-OTP (/auth/phone/*) send/verify endpoints.

CodeHTTP StatusDescriptionResolution
email_not_found400No account exists for this email (on login send/verify)Fall back to a registration flow, or prompt the user to sign up
phone_not_found400No account exists for this phone number (on login send/verify)Fall back to a registration flow
email_already_registered400Email is already registered (on registration send)Switch to the login flow
phone_already_registered400Phone number is already registered (on registration send)Switch to the login flow
otp_invalid400Verification code is incorrectRe-enter the code
otp_expired400No active code found, or it has expiredRequest a new code
otp_max_attempts400Code locked after too many failed attemptsRequest a new code
invalid_email400Email address is malformedCorrect the address
invalid_phone400Phone number is not valid E.164Use E.164 format (e.g. +14155551234)
user_inactive400The end user is suspended or deleted. Returned both when an OTP is requested and when it is verifiedRe-activate the end user in your application, or create a new one
email_not_configured503Email OTP requires a verified sending domain on the projectVerify a domain in Settings → Domains
sms_not_configured503Phone OTP requires configured SMS (Twilio) credentials. On the auth-OTP endpoints this is 503; the same code is returned as 400 by the direct SMS-send API (see Configuration Errors).Configure Twilio in Settings → SMS
sms_send_failed400The SMS provider failed to send the codeRetry; if persistent, check the SMS provider configuration
otp_send_rate_limit_exceeded429Too many code requests for this identifierWait retry_after seconds before requesting another code
otp_verify_rate_limit_exceeded429Too many verification attempts for this identifierWait retry_after seconds before retrying

Password Errors​

CodeHTTP StatusDescriptionResolution
password_validation_error400Password doesn't meet the application's requirementsCheck details; see the password-requirements endpoint
invalid_password400Password is incorrect (e.g. wrong current password on change-password)Enter the correct password

Payment & Billing Errors​

CodeHTTP StatusDescriptionResolution
plan_upgrade_required402 / 403Feature or limit requires a plan upgrade. Status is route-dependent — mostly 403 (plan-gated features: webhooks, WaaS, organizations, Muse); 402 on a few paywall paths (e.g. custom domains). Branch on the code, not the status.Upgrade your plan
subscriber_limit_exceeded403Subscriber cap for the current plan reachedUpgrade your plan
topic_limit_exceeded403Topic cap for the current plan reachedUpgrade your plan

Request Size​

CodeHTTP StatusDescriptionResolution
payload_too_large413Request body exceeds the maximum allowed sizeSee API Limits. The details object includes limit_bytes and received_bytes

Gaming Identity Errors​

Native gaming identity endpoints (POST /v1/auth/game-center, POST /v1/auth/google-play-games) return intentionally opaque errors on any verification failure — the specific control that failed (signature, certificate chain, timestamp, bundle id, token exchange) is never disclosed, to avoid giving an attacker a probing oracle.

CodeHTTP StatusDescriptionResolution
invalid_assertion401Apple Game Center assertion failed verification (any of: signature, certificate chain, timestamp freshness/replay, or bundle-id binding). Opaque by design.Most common cause: sending the wrong player id. Send GKLocalPlayer.teamPlayerID (form T:_…) as playerId — this is the id GameKit signs over. gamePlayerID (A:_…) and the deprecated playerID fail even on an otherwise-perfect assertion. Also confirm a fresh assertion (within the ±5-min window) and a bundle id in the app's trusted allow-list.
invalid_auth_code401Google Play Games server auth code failed verification (invalid/expired code, token exchange failure, or player lookup failure). Opaque by design.Send a fresh single-use serverAuthCode from requestServerSideAccess using the game's web/server client id.
provider_not_configured401The gaming provider isn't configured/enabled for this application.Enable and configure the provider under Applications → [App] → OAuth → Native gaming identity.
environment_required400The request lacks the environment context needed to resolve provider config.Ensure the application key resolves to a valid environment.

Resource Conflict Errors​

CodeHTTP StatusDescriptionResolution
slug_already_exists409A resource with this slug already exists in the projectChoose a different slug
reserved_slug400The slug is reserved and can't be usedChoose a different slug
workflow_key_exists409A workflow with this key already existsUse a unique workflow key
topic_key_exists409A topic with this key already existsUse a unique topic key
feature_not_available403The feature isn't available on your current planUpgrade your plan
email_unverified403The account's email isn't verified yetVerify your account email before retrying

Operation Failures​

CodeHTTP StatusDescriptionResolution
send_failed500Message failed to sendRetry the request
provider_error502External provider (SES, Twilio, APNs) errorRetry; check provider status if persistent
rendering_error400Template rendering failedCheck template variables match the data provided
recipient_suppressed400 / 422Recipient is on the suppression listRemove from the suppression list or skip

Workflow Errors​

CodeHTTP StatusDescriptionResolution
invalid_status_transition400The requested workflow status change isn't allowed from the current statusCheck the current status; valid values are draft, active, paused, archived
workflow_not_active400The workflow can't be triggered because it isn't in the active statusActivate the workflow (PATCH /v1/workflows/{key}/status with {"status":"active"}) before triggering
step_keys_mismatch400The step_keys in a reorder request don't match the workflow's current stepsSend the exact set of existing step_keys in the desired order
execution_not_cancellable400The workflow execution is already in a terminal state and can't be cancelledNo action needed — the execution has finished

Internal Errors​

CodeHTTP StatusDescriptionResolution
internal_error500Unexpected server errorRetry; contact support if persistent
service_unavailable503Service temporarily unavailableRetry with exponential backoff
timeout504Request timed outRetry the request

Handling Errors​

Retry Strategy​

For transient errors (429, 500, 502, 503, 504), implement exponential backoff:

# Example: retry with backoff
for i in 1 2 4 8 16; do
response=$(curl -s -w "\n%{http_code}" -X POST https://api.zyphr.dev/v1/emails \
-H "X-API-Key: zy_live_your_key" \
-H "Content-Type: application/json" \
-d '{"to": "user@example.com", "subject": "Hello", "html": "<p>Hi!</p>"}')

status=$(echo "$response" | tail -1)
if [ "$status" -lt 400 ]; then
echo "Success"
break
fi
echo "Retrying in ${i}s..."
sleep $i
done

Idempotency​

Use the Idempotency-Key header to safely retry requests without creating duplicates:

curl -X POST https://api.zyphr.dev/v1/emails \
-H "X-API-Key: zy_live_your_key" \
-H "Idempotency-Key: unique-request-id-123" \
-H "Content-Type: application/json" \
-d '{"to": "user@example.com", "subject": "Hello", "html": "<p>Hi!</p>"}'