Safravo Logosafravo.com

Error Reference

Safravo API error shapes, HTTP status codes, and production error handling patterns.

Safravo uses conventional HTTP status codes. 2xx means success, 4xx means your request has a problem, 5xx means something went wrong on our side.


Error shape

Every error response follows the same JSON envelope, regardless of which endpoint triggered it:

{
  "error": {
    "code": "forbidden",
    "message": "Your API key does not have the write:messages scope.",
    "statusCode": 403,
    "request_id": "req-01HT9ABC",
    "docs": "https://docs.safravo.com/api/errors#forbidden"
  }
}
FieldTypeDescription
codestringMachine-readable error identifier
messagestringHuman-readable explanation
statusCodenumberHTTP status code
request_idstringUnique ID for this request — include when contacting support
docsstringDirect link to this error in the documentation

HTTP status codes

CodeMeaningCommon cause
200OKSuccessful GET or PATCH
201CreatedResource successfully created (POST)
400Bad RequestMissing required field or invalid value
401UnauthorizedMissing or malformed X-API-Key header
403ForbiddenAPI key lacks the required scope
404Not FoundResource ID does not exist in your workspace
409ConflictDuplicate contact (same phone or email already exists)
422Unprocessable EntityValid request but operation cannot be completed (e.g. channel offline)
429Too Many RequestsRate limit exceeded — respect the Retry-After header
500Internal Server ErrorUnexpected error on Safravo's side

Error codes

codeStatusDescription
unauthorized401No valid API key found in the request
invalid_api_key401Key is revoked, expired, or malformed
forbidden403Key does not have the required scope
not_found404Resource not found in your workspace
conflict409Duplicate contact — phone or email already exists
validation_error400Request body failed schema validation
channel_unavailable422The channel is not connected or has been disconnected
template_not_found404Template name not found or not approved
rate_limit_exceeded429Too many requests in the current time window
internal_error500Unexpected server-side error

Handling errors in code

import axios, { AxiosError } from 'axios';

try {
  const { data } = await client.post('/v1/messages', payload);
  console.log('Sent:', data.message_id);
} catch (err) {
  const e = err as AxiosError<{ error: { code: string; message: string; request_id: string } }>;

  if (e.response) {
    const { code, message, request_id } = e.response.data.error;
    console.error(`[${code}] ${message} (req: ${request_id})`);

    switch (e.response.status) {
      case 401:
        // Re-check your API key in Settings → Developers
        break;
      case 403:
        // Add the missing scope when creating the key
        break;
      case 409:
        // Contact already exists — search by phone instead
        break;
      case 429: {
        const retryAfter = e.response.headers['retry-after'];
        await new Promise(r => setTimeout(r, Number(retryAfter) * 1000));
        // retry...
        break;
      }
      case 500:
        // Log and alert your team — include request_id when contacting support
        break;
    }
  }
}
import time
import httpx

try:
    resp = client.post("/v1/messages", json=payload)
    resp.raise_for_status()
    print("Sent:", resp.json()["message_id"])

except httpx.HTTPStatusError as exc:
    error = exc.response.json().get("error", {})
    code = error.get("code")
    request_id = error.get("request_id")
    print(f"[{code}] {error.get('message')} (req: {request_id})")

    if exc.response.status_code == 429:
        retry_after = int(exc.response.headers.get("retry-after", 5))
        time.sleep(retry_after)
        # retry...

    elif exc.response.status_code >= 500:
        raise
use Illuminate\Http\Client\RequestException;

try {
    $response = Http::withHeaders(['X-API-Key' => env('SAFRAVO_API_KEY')])
        ->post('https://api.safravo.com/v1/messages', $payload)
        ->throw();

    $messageId = $response->json('message_id');

} catch (RequestException $e) {
    $error     = $e->response->json('error');
    $code      = $error['code'];
    $requestId = $error['request_id'];

    Log::error("[safravo:{$code}] {$error['message']}", ['request_id' => $requestId]);

    match ($e->response->status()) {
        429 => sleep((int) $e->response->header('Retry-After')),
        409 => null,  // duplicate — safe to ignore or fetch existing
        default => throw $e,
    };
}
type APIError struct {
    Code      string `json:"code"`
    Message   string `json:"message"`
    RequestID string `json:"request_id"`
}
type ErrorResponse struct {
    Error APIError `json:"error"`
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
    panic(err)
}
defer resp.Body.Close()

if resp.StatusCode >= 400 {
    var errResp ErrorResponse
    json.NewDecoder(resp.Body).Decode(&errResp)

    fmt.Printf("[%s] %s (req: %s)\n",
        errResp.Error.Code,
        errResp.Error.Message,
        errResp.Error.RequestID)

    if resp.StatusCode == 429 {
        retryAfter := resp.Header.Get("Retry-After")
        // parse and sleep
    }
}
response = http.request(request)

unless response.is_a?(Net::HTTPSuccess)
  error = JSON.parse(response.body)['error']
  puts "[#{error['code']}] #{error['message']} (req: #{error['request_id']})"

  case response.code.to_i
  when 429
    sleep(response['retry-after'].to_i)
    # retry...
  when 409
    # duplicate — look up by phone
  when 500..599
    raise "Safravo internal error: #{error['request_id']}"
  end
end

Rate limits

EndpointLimit
POST /v1/messages60 req/min
POST /v1/messages/templates20 req/min
GET /v1/contacts60 req/min
POST /v1/contacts30 req/min
PATCH /v1/contacts/:id30 req/min
GET /v1/templates30 req/min
GET /v1/templates/:id60 req/min

When a rate limit is exceeded, a 429 response is returned with a Retry-After header indicating how many seconds to wait.

For production bulk sends, implement exponential backoff with jitter — start at 1 second, double each retry, and add ±20% random jitter to prevent thundering-herd spikes when multiple workers hit limits simultaneously.


Common integration pitfalls

"Message is queued but never delivered"

POST /v1/messages returns 201 with "status": "queued" as soon as your request is validated. This does not mean Meta has delivered the message.

How to diagnose: Poll GET /v1/messages/:id. If the message failed asynchronously, status will be failed and failure_reason will contain the exact error from the channel provider. Subscribe to message.status_updated webhooks for real-time failure notifications.

"Template name does not exist in the translation" (error 132001)

You are sending a template but Meta is rejecting it.

How to fix:

  • Verify the language code matches exactly what was used when the template was created in Meta Business Manager. en and en_US are different.
  • Confirm the template status is APPROVED via GET /v1/templates.

"Required parameter is missing" (error 131008)

You are sending a template with a Dynamic URL or OTP COPY_CODE button but not including the button component.

How to fix: Check the send_example from GET /v1/templates/:id. If it includes a button component, you must include it in your request. See Templates → BUTTONS.


Contacting support

Always include the request_id when reporting an issue. You can find it in:

  • The error.request_id field in the JSON response body
  • The X-Correlation-ID response header

Contact [email protected] or open a ticket from the dashboard.

On this page