Error Handling
All error responses follow a single JSON structure. Use HTTP status codes to route your error handling logic, and the error field for human-readable context.
// Every non-2xx response returns:
{
"error": "human-readable description of what went wrong"
}Error codes
The table below covers all status codes returned by the Connect Hub. The Retry column indicates whether automatic retry is safe.
| Code | Name | Retry | Description |
|---|---|---|---|
400 | Bad Request | NO | Missing or invalid fields (e.g. phone not E.164, missing required body parameter). Fix your payload. |
401 | Unauthorized | NO | Missing or invalid x-api-key header. Check your API key — do not retry automatically. |
402 | Payment Required | NO | Insufficient account balance. Top up your Novauth account before retrying. |
403 | Forbidden | NO | The API key does not have permission to perform this action. |
404 | Not Found | NO | Resource does not exist (e.g. unknown UUID on /call/flash/{uuid}/cdr). Do not retry. |
409 | Conflict | NO | A duplicate or conflicting operation was detected (e.g. reporting webhook twice for same UUID). |
422 | Unprocessable Entity | NO | Telegram-specific: request semantically invalid (e.g. expired request_id on check-verification-status). |
429 | Too Many Requests | YES | Rate limit exceeded. Honour the Retry-After header and implement exponential backoff. |
480 | Temporarily Unavailable | YES | Callee temporarily unreachable (SIP-derived). Safe to retry after a short delay. |
482 | Loop Detected | NO | Request routing loop detected. Do not retry — investigate your integration. |
486 | Busy Here | YES | Callee is busy. Retry after a delay or fall back to another channel. |
488 | Not Acceptable Here | NO | Request parameters not acceptable (e.g. unsupported codec or format). Fix the request. |
500 | Internal Server Error | YES | Unexpected server-side failure. Retry with exponential backoff (max 3 attempts). |
502 | Bad Gateway | YES | Upstream service (carrier, gateway) returned an invalid response. Retry after a brief wait. |
503 | Service Unavailable | YES | Service temporarily overloaded or under maintenance. Honour Retry-After if present. |
603 | Decline | NO | Callee explicitly declined (SIP-derived). Do not retry — user actively rejected the call. |
Retry logic
Only retry on codes 429, 480, 486, 500, 502, 503. Use exponential backoff starting at 500 ms (double each attempt). Never auto-retry 4xx client errors — fix the root cause first.
async function callWithRetry(fn, { maxAttempts = 3, baseDelay = 500 } = {}) {
// Safe codes to retry: 429, 500, 502, 503
const RETRYABLE = new Set([429, 500, 502, 503]);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fn();
if (res.ok) return res;
const { status } = res;
// Non-retryable — surface error immediately
if (!RETRYABLE.has(status)) {
const body = await res.json().catch(() => ({}));
throw Object.assign(new Error(body.error ?? 'API error'), { status, body });
}
if (attempt === maxAttempts) {
throw Object.assign(new Error('Max retries reached'), { status });
}
// Exponential backoff: 500ms, 1000ms, 2000ms …
const delay = baseDelay * 2 ** (attempt - 1);
await new Promise(r => setTimeout(r, delay));
}
}
// Usage
const res = await callWithRetry(() =>
fetch('https://api.novauth.com/api/v1/connect-hub/call/flash', {
method: 'POST',
headers: {
'x-api-key': process.env.BETATEL_API_KEY,
'x-account-id': process.env.BETATEL_ACCOUNT_ID,
'Content-Type': 'application/json'
},
body: JSON.stringify({ callee: '+14155552671' })
})
);Idempotent operations (GET status checks) can be retried freely. For POST operations that create resources, always check whether the resource was already created before retrying — use the returned UUID to query status first.
Fallback cascade
Verify Call works in most regions but is not universal. Implement a cascade so users always receive their OTP even when the primary channel fails.
Success within a configurable timeout window (recommended: 15 s for Verify Call).async function sendWithFallback(phone, otp) {
// 1. Try Verify Call first
try {
const flash = await fetch('.../call/flash', {
method: 'POST',
headers,
body: JSON.stringify({ callee: phone })
});
if (flash.ok) return { channel: 'flash', ...(await flash.json()) };
} catch {}
// 2. Flash failed — fall back to Telegram if user has it
try {
const tg = await fetch('.../telegram', {
method: 'POST',
headers,
body: JSON.stringify({ to: phone, code: otp, ttl: 120 })
});
if (tg.ok) return { channel: 'telegram', ...(await tg.json()) };
} catch {}
// 3. Final fallback: SMS (widest global reach)
const sms = await fetch('.../sms', {
method: 'POST',
headers,
body: JSON.stringify({ to: phone, text: `Your code: ${otp}` })
});
if (!sms.ok) throw new Error('All channels failed');
return { channel: 'sms', ...(await sms.json()) };
}To disable the cascade for a specific flow, simply omit fallback try/catch blocks and surface the error directly to the user. There is no server-side cascade setting — the logic lives entirely in your integration code.
Rate limiting
Rate limits are enforced at the API gateway layer. When a limit is hit you receive a 429 Too Many Requests response. Check the Retry-After header for the exact wait time.
Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Resetconst res = await fetch(url, { method: 'POST', headers, body });
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After') ?? 1);
console.warn(`Rate limited — waiting ${retryAfter}s`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
// retry the request
}Common integration mistakes
These are the most frequent issues reported during integration. Check this list before opening a support ticket.