All docs

Errors

Error response format, HTTP status codes, and typed error codes in the senderZ API.

Every senderZ API response follows a consistent envelope format. Understanding this format makes error handling predictable across all endpoints.

Response Envelope

All API responses use this JSON structure:

{
  "data": {},
  "error": "Human-readable error message",
  "code": "MACHINE_READABLE_ERROR_CODE"
}

| Field | Present When | Description | |-------|-------------|-------------| | data | Success responses | The response payload. Shape varies by endpoint. | | error | Error responses | A plain-English explanation of what went wrong. Safe to display to end users. | | code | Error responses | A stable, machine-readable error code. Use this for programmatic error handling. |

On success, error and code are absent. On error, data is absent.

Success Example

{
  "data": {
    "message_id": "01JMSG123ABC",
    "status": "queued"
  }
}

Error Example

{
  "error": "The recipient has opted out of messages from your account.",
  "code": "RECIPIENT_OPTED_OUT"
}

HTTP Status Codes

senderZ uses standard HTTP status codes. Here is what each one means in context:

Success Codes

| Status | Meaning | When You See It | |--------|---------|----------------| | 200 OK | Request succeeded | GET requests, updates, deletions | | 201 Created | Resource created | POST requests that create new records (contacts, API keys, templates) | | 202 Accepted | Queued for processing | Message sends and campaign launches — the message is enqueued but not yet delivered |

Client Error Codes

| Status | Meaning | When You See It | |--------|---------|----------------| | 400 Bad Request | Invalid input | Missing required fields, malformed JSON, invalid phone number format | | 401 Unauthorized | Auth failed | Missing or invalid API key, expired Clerk JWT | | 403 Forbidden | Access denied | API key is deactivated, tenant is suspended, or accessing another tenant's data | | 404 Not Found | Resource missing | The ID does not exist or belongs to a different tenant | | 409 Conflict | Duplicate | Creating a contact with a phone number that already exists | | 429 Too Many Requests | Rate limited | You exceeded your plan's rate limit. Check the Retry-After header. |

Server Error Codes

| Status | Meaning | When You See It | |--------|---------|----------------| | 500 Internal Server Error | Server failure | Unexpected error. These are logged and investigated. If persistent, contact support. | | 503 Service Unavailable | Temporary outage | The iMessage engine or a downstream service is temporarily unreachable. Retry after a short delay. |


Typed Error Codes

Every error response includes a code field with a stable string identifier. These codes never change between API versions, making them safe to match against in your code.

Authentication Errors

| Code | HTTP Status | Description | |------|-------------|-------------| | INVALID_API_KEY | 401 | API key is missing, malformed, or has been revoked | | KEY_INACTIVE | 403 | API key exists but has been deactivated | | TRIAL_EXPIRED | 403 | Your 14-day trial has ended and no subscription is active. Subscribe to restore access. | | TENANT_SUSPENDED | 403 | Your account has been suspended by the operator |

Validation Errors

| Code | HTTP Status | Description | |------|-------------|-------------| | VALIDATION_ERROR | 400 | One or more request fields are invalid. The error message describes which field and why. | | INVALID_PHONE_NUMBER | 400 | Phone number is not in valid E.164 format (must start with + and contain 10-15 digits) | | INVALID_TEMPLATE | 400 | The referenced template name does not exist in your tenant | | MISSING_TEMPLATE_VAR | 400 | A required {{variable}} in the template has no matching value in the data object |

Messaging Errors

| Code | HTTP Status | Description | |------|-------------|-------------| | RECIPIENT_OPTED_OUT | 400 | The recipient has sent STOP and opted out. You cannot message them until they send START. | | NO_PHONE_AVAILABLE | 503 | No phone is available to send this message. All assigned phones may be at their daily limit or offline. | | DELIVERY_FAILED | 500 | The message was sent but delivery to the recipient failed. Check the message status endpoint for details. | | QUIET_HOURS | 200 | The message was accepted but will be delivered after quiet hours end (8 AM recipient local time). This is not an error — the message is queued. |

Quota Errors

| Code | HTTP Status | Description | |------|-------------|-------------| | RATE_LIMIT_EXCEEDED | 429 | You are sending requests faster than your plan allows. Wait for the Retry-After period. | | QUOTA_EXCEEDED | 429 | You have reached your monthly API call limit or daily new-contact limit. | | NEW_CONTACT_LIMIT | 429 | You have reached your daily new-contact limit. Messages to existing contacts are still allowed. |

Resource Errors

| Code | HTTP Status | Description | |------|-------------|-------------| | NOT_FOUND | 404 | The requested resource does not exist | | CONTACT_NOT_FOUND | 404 | The contact ID does not exist in your tenant | | CAMPAIGN_NOT_FOUND | 404 | The campaign ID does not exist in your tenant | | DUPLICATE_CONTACT | 409 | A contact with this phone number already exists. The response includes the existing contact's ID. |

Detection Errors

| Code | HTTP Status | Description | |------|-------------|-------------| | DETECTION_UNAVAILABLE | 503 | The iMessage detection service is temporarily unreachable. Retry after a short delay. |


Handling Errors in Code

Use the code field for reliable error handling — never match against the human-readable error string, which may change.

const res = await fetch('https://api.senderz.com/v1/messages', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sz_live_YOUR_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: '+15551234567',
    channel: 'auto',
    body: 'Hello!',
  }),
})

const result = await res.json()

if (!res.ok) {
  switch (result.code) {
    case 'RECIPIENT_OPTED_OUT':
      // Remove this number from your send list
      break
    case 'TRIAL_EXPIRED':
      // Redirect user to subscribe
      break
    case 'RATE_LIMIT_EXCEEDED':
      // Wait and retry
      const retryAfter = res.headers.get('Retry-After')
      break
    case 'NEW_CONTACT_LIMIT':
      // Queue for tomorrow or upgrade plan
      break
    default:
      console.error(`API error: ${result.code} — ${result.error}`)
  }
}

Retry Strategy

Not all errors should be retried. Here is a quick guide:

| Error Type | Retry? | Strategy | |-----------|--------|----------| | 400 validation errors | No | Fix the request body | | 401 / 403 auth errors | No | Check your API key or subscription status | | 404 not found | No | Verify the resource ID | | 409 conflict | No | Use the existing resource | | 429 rate limit | Yes | Wait for Retry-After seconds, then retry | | 500 server error | Yes | Retry with exponential backoff (1s, 2s, 4s), max 3 attempts | | 503 service unavailable | Yes | Retry with exponential backoff (2s, 4s, 8s), max 5 attempts |


Frequently Asked Questions

What does TRIAL_EXPIRED mean? Your 14-day free trial has ended and you have not subscribed to a plan. All API requests will return this error until you choose a plan (Starter, Growth, or Scale) from the developer portal.

Why do I get RECIPIENT_OPTED_OUT when I know the number is valid? The recipient has previously sent a STOP keyword to your tenant's phone number. senderZ automatically blocks all future messages to opted-out numbers. The recipient must send START to re-subscribe.

How do I know which field caused a VALIDATION_ERROR? The error field in the response describes exactly which field is invalid and why. For example: "The 'to' field must be a valid E.164 phone number starting with +".

What is the difference between QUOTA_EXCEEDED and NEW_CONTACT_LIMIT? QUOTA_EXCEEDED means you hit your monthly API call ceiling. NEW_CONTACT_LIMIT means you reached your daily limit for messaging phone numbers you have never contacted before. Messages to existing contacts are always unlimited.

Can I get more details about a DELIVERY_FAILED error? Yes. Use GET /v1/messages/:id to retrieve the full message record, which includes a failure_reason field with carrier-level error details when available.