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"
}
}| Field | Type | Description |
|---|---|---|
code | string | Machine-readable error identifier |
message | string | Human-readable explanation |
statusCode | number | HTTP status code |
request_id | string | Unique ID for this request — include when contacting support |
docs | string | Direct link to this error in the documentation |
HTTP status codes
| Code | Meaning | Common cause |
|---|---|---|
200 | OK | Successful GET or PATCH |
201 | Created | Resource successfully created (POST) |
400 | Bad Request | Missing required field or invalid value |
401 | Unauthorized | Missing or malformed X-API-Key header |
403 | Forbidden | API key lacks the required scope |
404 | Not Found | Resource ID does not exist in your workspace |
409 | Conflict | Duplicate contact (same phone or email already exists) |
422 | Unprocessable Entity | Valid request but operation cannot be completed (e.g. channel offline) |
429 | Too Many Requests | Rate limit exceeded — respect the Retry-After header |
500 | Internal Server Error | Unexpected error on Safravo's side |
Error codes
code | Status | Description |
|---|---|---|
unauthorized | 401 | No valid API key found in the request |
invalid_api_key | 401 | Key is revoked, expired, or malformed |
forbidden | 403 | Key does not have the required scope |
not_found | 404 | Resource not found in your workspace |
conflict | 409 | Duplicate contact — phone or email already exists |
validation_error | 400 | Request body failed schema validation |
channel_unavailable | 422 | The channel is not connected or has been disconnected |
template_not_found | 404 | Template name not found or not approved |
rate_limit_exceeded | 429 | Too many requests in the current time window |
internal_error | 500 | Unexpected 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:
raiseuse 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
endRate limits
| Endpoint | Limit |
|---|---|
POST /v1/messages | 60 req/min |
POST /v1/messages/templates | 20 req/min |
GET /v1/contacts | 60 req/min |
POST /v1/contacts | 30 req/min |
PATCH /v1/contacts/:id | 30 req/min |
GET /v1/templates | 30 req/min |
GET /v1/templates/:id | 60 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
languagecode matches exactly what was used when the template was created in Meta Business Manager.enanden_USare different. - Confirm the template status is
APPROVEDviaGET /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_idfield in the JSON response body - The
X-Correlation-IDresponse header
Contact [email protected] or open a ticket from the dashboard.