Production applications must handle errors gracefully. This guide covers all DoorFlow API error codes, common issues, and retry strategies.
HTTP Status Codes
The DoorFlow API uses standard HTTP status codes to indicate success or failure.
Success Codes
| Code | Status | Meaning |
| 200 | OK | Request succeeded, response contains data |
| 201 | Created | Resource created successfully |
| 204 | No Content | Request succeeded, no response body |
Client Error Codes (4xx)
| Code | Status | Meaning | Action |
| 400 | Bad Request | Malformed request | Fix request format |
| 401 | Unauthorized | Invalid/expired token | Refresh token or re-authenticate |
| 403 | Forbidden | Insufficient permissions | Check OAuth scopes |
| 404 | Not Found | Resource doesn't exist | Verify resource ID |
| 422 | Unprocessable Entity | Validation error | Fix request data |
| 429 | Too Many Requests | Rate limit exceeded | Implement backoff |
Server Error Codes (5xx)
| Code | Status | Meaning | Action |
| 500 | Internal Server Error | Server error | Retry with backoff |
| 502 | Bad Gateway | Upstream error | Retry with backoff |
| 503 | Service Unavailable | Temporary outage | Retry with backoff |
| 504 | Gateway Timeout | Request timeout | Retry with backoff |
Error Response Format
All errors return JSON with this structure:
{
"error": "error_code",
"error_description": "Human-readable description",
"errors": {
"field_name": ["Specific validation error"]
}
}
Example validation error:
{
"error": "validation_failed",
"error_description": "Validation failed",
"errors": {
"email": ["has already been taken"],
"first_name": ["can't be blank"]
}
}
Common Errors and Solutions
401 Unauthorized
Error response:
{
"error": "invalid_token",
"error_description": "The access token is invalid or has expired"
}
Causes
Authorization header
Solutions:
async function handleTokenExpiration(error, refreshToken) {
if (error.status === 401) {
console.log('Token expired, refreshing...');
// Refresh the token
const newTokens = await refreshAccessToken(refreshToken);
// Store new tokens
await saveTokens(newTokens);
// Retry the original request with new token
return retryWithNewToken(newTokens.access_token);
}
throw error;
}
Best practice: Implement automatic token refresh on first 401 error, retry once, then fail if still unauthorized.
403 Forbidden
Error response:
{
"error": "insufficient_scope",
"error_description": "The access token does not have sufficient scope to perform this action"
}
Causes
Solutions:
function handle403Error(error, endpoint) {
if (error.status === 403) {
// Map endpoints to required scopes
const scopeRequirements = {
'POST /api/3/people': 'account.person',
'POST /api/3/people/{id}/credentials': 'account.person',
'POST /api/3/channels/{id}/admit': 'account.channel.admit',
};
const requiredScope = scopeRequirements[endpoint];
throw new Error(
`Missing required OAuth scope: ${requiredScope}. ` +
`Please update your OAuth application and have users re-authorize.`
);
}
}
Best practice: Check scopes before making requests, provide clear error messages to users.
404 Not Found
Error response:
{
"error": "not_found",
"error_description": "The requested resource was not found"
}
Causes
Solutions:
async function getPerson(personId) {
try {
const response = await fetch(`/api/3/people/${personId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.status === 404) {
throw new Error(`Person ${personId} not found. They may have been deleted.`);
}
return await response.json();
} catch (error) {
// Log for debugging
console.error(`Failed to fetch person ${personId}:`, error);
// Return graceful fallback
return null;
}
}
Best practice: Validate resource IDs exist before operations. Handle deletions gracefully.
422 Unprocessable Entity (Validation Errors)
Error response:
{
"error": "validation_failed",
"error_description": "Validation failed",
"errors": {
"email": ["has already been taken"],
"first_name": ["can't be blank"],
"card_number": ["is invalid"]
}
}
Causes
Common validation errors:
| Field | Error | Solution |
email |
"has already been taken" | Use different email or find existing person |
email |
"is invalid" | Validate email format |
first_name |
"can't be blank" | Provide first name |
value (credential) |
"has already been taken" | Card/PIN already issued |
group_ids |
"is invalid" | Group ID doesn't exist |
channel_ids |
"can't be blank" | Provide at least one channel |
Handling validation errors:
async function createPerson(data) {
try {
const response = await fetch('/api/3/people', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.status === 422) {
const error = await response.json();
// Extract validation errors
const validationErrors = error.errors || {};
// Display user-friendly messages
if (validationErrors.email?.includes('has already been taken')) {
throw new Error(`Email ${data.email} is already in use. Please use a different email.`);
}
if (validationErrors.first_name?.includes("can't be blank")) {
throw new Error('First name is required.');
}
// Generic validation error
throw new Error(`Validation failed: ${JSON.stringify(validationErrors)}`);
}
return await response.json();
} catch (error) {
console.error('Failed to create person:', error);
throw error;
}
}
Best practice: Validate data client-side before sending to API. Parse validation errors to show user-friendly messages.
429 Too Many Requests (Rate Limiting)
Error response:
{
"error": "rate_limit_exceeded",
"error_description": "API rate limit exceeded. Maximum 120 requests per minute."
}
Rate limits
Response headers:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704070800
Handling rate limits:
async function callAPIWithRateLimit(url, options) {
try {
const response = await fetch(url, options);
if (response.status === 429) {
// Get reset time from header
const resetTime = parseInt(response.headers.get('X-RateLimit-Reset'));
const waitTime = (resetTime * 1000) - Date.now();
console.log(`Rate limited. Waiting ${waitTime}ms until reset...`);
// Wait until rate limit resets
await new Promise(resolve => setTimeout(resolve, waitTime));
// Retry request
return await fetch(url, options);
}
return response;
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
Best practices
if (remaining < 100) {
console.warn(Warning: Only ${remaining} requests remaining this minute);
}
}
2. **Implement exponential backoff**:
```javascript
async function callAPIWithBackoff(url, options, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
if (response.status === 429) {
const backoffTime = Math.pow(2, i) * 1000; // 1s, 2s, 4s
console.log(`Rate limited. Retry ${i+1}/${maxRetries} after ${backoffTime}ms`);
await new Promise(resolve => setTimeout(resolve, backoffTime));
continue;
}
return response;
} catch (error) {
if (i === maxRetries - 1) throw error;
}
}
}
- Batch operations when possible: ```javascript // Bad: 100 separate requests for (const person of people) { await createPerson(person); }
// Better: Batch with delays async function batchCreatePeople(people, batchSize = 10) { for (let i = 0; i < people.length; i += batchSize) { const batch = people.slice(i, i + batchSize);
await Promise.all(batch.map(person => createPerson(person)));
// Delay between batches to avoid rate limit
await new Promise(resolve => setTimeout(resolve, 1000));
} }
---
### 500/502/503/504 Server Errors
**Error response:**
```json
{
"error": "internal_server_error",
"error_description": "An internal server error occurred"
}
Causes
Handling server errors:
async function callAPIWithRetry(url, options, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Retry on server errors
if (response.status >= 500 && response.status < 600) {
if (attempt < maxRetries) {
const backoffTime = Math.pow(2, attempt) * 1000; // Exponential backoff
console.log(`Server error (${response.status}). Retry ${attempt}/${maxRetries} after ${backoffTime}ms`);
await new Promise(resolve => setTimeout(resolve, backoffTime));
continue;
}
throw new Error(`Server error after ${maxRetries} retries: ${response.status}`);
}
return response;
} catch (error) {
if (attempt === maxRetries) {
throw new Error(`Request failed after ${maxRetries} retries: ${error.message}`);
}
// Retry on network errors too
const backoffTime = Math.pow(2, attempt) * 1000;
console.log(`Network error. Retry ${attempt}/${maxRetries} after ${backoffTime}ms`);
await new Promise(resolve => setTimeout(resolve, backoffTime));
}
}
}
Best practice: Always retry server errors with exponential backoff. Log errors for monitoring.
Complete Error Handler
Here's a production-ready error handler that handles all scenarios:
class DoorFlowAPIError extends Error {
constructor(message, status, response) {
super(message);
this.name = 'DoorFlowAPIError';
this.status = status;
this.response = response;
}
}
async function callDoorFlowAPI(endpoint, options = {}, retryConfig = {}) {
const {
maxRetries = 3,
retryOnServerError = true,
retryOnRateLimit = true,
} = retryConfig;
const url = `https://api.doorflow.com${endpoint}`;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${getAccessToken()}`,
'Content-Type': 'application/json',
...options.headers,
},
});
// Check rate limit headers
const remaining = response.headers.get('X-RateLimit-Remaining');
if (remaining && parseInt(remaining) < 50) {
console.warn(`Rate limit warning: ${remaining} requests remaining`);
}
// Handle specific status codes
if (response.status === 401) {
// Token expired - refresh and retry
console.log('Token expired, refreshing...');
await refreshToken();
if (attempt < maxRetries) {
continue; // Retry with new token
}
}
if (response.status === 403) {
const error = await response.json();
throw new DoorFlowAPIError(
`Insufficient permissions: ${error.error_description}`,
403,
error
);
}
if (response.status === 404) {
const error = await response.json();
throw new DoorFlowAPIError(
`Resource not found: ${endpoint}`,
404,
error
);
}
if (response.status === 422) {
const error = await response.json();
const validationMessages = Object.entries(error.errors || {})
.map(([field, messages]) => `${field}: ${messages.join(', ')}`)
.join('; ');
throw new DoorFlowAPIError(
`Validation failed: ${validationMessages}`,
422,
error
);
}
if (response.status === 429 && retryOnRateLimit) {
const resetTime = parseInt(response.headers.get('X-RateLimit-Reset'));
const waitTime = (resetTime * 1000) - Date.now();
if (attempt < maxRetries) {
console.log(`Rate limited. Waiting ${Math.round(waitTime/1000)}s...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
}
if (response.status >= 500 && retryOnServerError) {
if (attempt < maxRetries) {
const backoffTime = Math.pow(2, attempt) * 1000;
console.log(`Server error. Retry ${attempt}/${maxRetries} after ${backoffTime}ms`);
await new Promise(resolve => setTimeout(resolve, backoffTime));
continue;
}
}
// Success or unhandled error
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new DoorFlowAPIError(
error.error_description || `HTTP ${response.status}`,
response.status,
error
);
}
// Success - return parsed response
if (response.status === 204) {
return null; // No content
}
return await response.json();
} catch (error) {
// Network errors or other exceptions
if (attempt === maxRetries) {
console.error('Request failed after all retries:', error);
throw error;
}
if (error instanceof DoorFlowAPIError) {
throw error; // Don't retry client errors (4xx)
}
// Retry network errors
const backoffTime = Math.pow(2, attempt) * 1000;
console.log(`Network error. Retry ${attempt}/${maxRetries} after ${backoffTime}ms`);
await new Promise(resolve => setTimeout(resolve, backoffTime));
}
}
}
// Usage
try {
const person = await callDoorFlowAPI('/api/3/people/12345', {
method: 'GET',
});
console.log('[OK] Person retrieved:', person);
} catch (error) {
if (error instanceof DoorFlowAPIError) {
if (error.status === 404) {
console.log('Person not found - they may have been deleted');
} else if (error.status === 403) {
console.log('Missing required permissions');
} else {
console.error('API error:', error.message);
}
} else {
console.error('Unexpected error:', error);
}
}
Idempotency
Some operations should be safe to retry. Use idempotency strategies:
Check Before Create
async function createPersonIdempotent(personData) {
// Check if person already exists
const existingPeople = await callDoorFlowAPI(
`/api/3/people?email=${encodeURIComponent(personData.email)}`
);
if (existingPeople.people.length > 0) {
console.log('Person already exists, returning existing record');
return existingPeople.people[0];
}
// Create new person
return await callDoorFlowAPI('/api/3/people', {
method: 'POST',
body: JSON.stringify(personData),
});
}
Update Instead of Create
async function upsertPerson(personData) {
try {
// Try to create
return await createPerson(personData);
} catch (error) {
if (error.status === 422 && error.response.errors.email?.includes('has already been taken')) {
// Find existing person and update
const existingPeople = await callDoorFlowAPI(
`/api/3/people?email=${encodeURIComponent(personData.email)}`
);
const existingPerson = existingPeople.people[0];
return await callDoorFlowAPI(`/api/3/people/${existingPerson.id}`, {
method: 'PUT',
body: JSON.stringify(personData),
});
}
throw error;
}
}
Logging and Monitoring
Implement comprehensive logging for debugging:
function logAPICall(method, endpoint, status, duration, error = null) {
const logEntry = {
timestamp: new Date().toISOString(),
method,
endpoint,
status,
duration_ms: duration,
error: error ? error.message : null,
};
// Log to console
console.log(JSON.stringify(logEntry));
// Send to monitoring service
// monitoringService.trackAPICall(logEntry);
// Alert on errors
if (status >= 500) {
// alertService.notify('DoorFlow API server error', logEntry);
}
}
// Usage
async function callAPIWithLogging(endpoint, options) {
const startTime = Date.now();
try {
const response = await callDoorFlowAPI(endpoint, options);
const duration = Date.now() - startTime;
logAPICall(options.method || 'GET', endpoint, 200, duration);
return response;
} catch (error) {
const duration = Date.now() - startTime;
logAPICall(
options.method || 'GET',
endpoint,
error.status || 0,
duration,
error
);
throw error;
}
}
Testing Error Scenarios
Test how your app handles errors:
// Mock DoorFlow API responses for testing
const mockAPIResponses = {
'/api/3/people/12345': {
status: 404,
body: { error: 'not_found', error_description: 'Person not found' },
},
'/api/3/people': {
status: 422,
body: {
error: 'validation_failed',
errors: { email: ['has already been taken'] },
},
},
};
// Test error handling
async function testErrorHandling() {
// Test 404
try {
await getPerson(99999);
console.error('Should have thrown 404 error');
} catch (error) {
console.log('[OK] 404 handled correctly');
}
// Test validation error
try {
await createPerson({ first_name: 'John', email: 'duplicate@example.com' });
console.error('Should have thrown validation error');
} catch (error) {
console.log('[OK] Validation error handled correctly');
}
// Test rate limit
// ... (make 1001 requests to trigger rate limit)
console.log('All error scenarios tested successfully');
}
Error Handling Checklist
Before going to production:
- Implement token refresh on 401 errors
- Check OAuth scopes before API calls (prevent 403)
- Validate data client-side (reduce 422 errors)
- Implement exponential backoff for retries
- Handle rate limiting gracefully
- Log all errors with context
- Monitor error rates
- Set up alerts for high error rates
- Test all error scenarios
- Provide user-friendly error messages
Quick Reference
Retry these errors
Don't retry these errors
Exponential backoff formula:
const backoffTime = Math.pow(2, attempt) * 1000;
// Attempt 1: 2s
// Attempt 2: 4s
// Attempt 3: 8s
Next Steps
- [OAuth Setup] - Implement token refresh
- [Common Workflows] - See error handling in context
- [Events and Audit Trail] - Monitor API usage
- API Reference - Check error responses for each endpoint