Error Handling & Troubleshooting
Handle errors gracefully and debug issues effectively with this comprehensive guide.
Error Response Format
All API errors follow a consistent JSON structure:
{
"errorCode": "ENVELOPE_NOT_FOUND",
"message": "Envelope abc123 not found",
"requestId": "req_xyz789"
}
| Field | Description |
|---|---|
errorCode | Machine-readable error identifier |
message | Human-readable description |
requestId | Unique ID for support inquiries |
HTTP Status Codes
| Status | Meaning | Common Causes |
|---|---|---|
200 | Success | Request completed |
201 | Created | Resource created |
204 | No Content | Deletion successful |
400 | Bad Request | Invalid input, validation failed |
401 | Unauthorized | Invalid or expired token |
403 | Forbidden | Insufficient permissions |
404 | Not Found | Resource doesn't exist |
409 | Conflict | State conflict (e.g., already signed) |
422 | Unprocessable | Valid syntax but semantic error |
429 | Rate Limited | Too many requests |
500 | Server Error | Internal error (contact support) |
503 | Unavailable | Temporary outage |
Error Code Reference
Authentication Errors
| Error Code | HTTP Status | Cause | Solution |
|---|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid Authorization header | Include valid Bearer token |
TOKEN_EXPIRED | 401 | Access token has expired | Refresh your token |
INVALID_TOKEN | 401 | Malformed or revoked token | Request new token |
INSUFFICIENT_SCOPE | 403 | Token lacks required scopes | Request token with correct scopes |
Envelope Errors
| Error Code | HTTP Status | Cause | Solution |
|---|---|---|---|
ENVELOPE_NOT_FOUND | 404 | Envelope ID doesn't exist or wrong account | Verify envelope ID and account |
ENVELOPE_CANNOT_BE_MODIFIED | 400 | Envelope is sent/completed/voided | Can only modify drafts |
ENVELOPE_ALREADY_SENT | 409 | Attempting to send already-sent envelope | Check envelope status first |
INVALID_ENVELOPE_STATUS | 400 | Invalid status transition | Follow valid status flow |
DOCUMENTS_REQUIRED | 400 | No documents in envelope | Add at least one document |
RECIPIENTS_REQUIRED | 400 | No recipients defined | Add at least one recipient |
Document Errors
| Error Code | HTTP Status | Cause | Solution |
|---|---|---|---|
DOCUMENT_NOT_FOUND | 404 | Document ID doesn't exist | Verify document ID |
INVALID_DOCUMENT_FORMAT | 400 | Unsupported file type | Use PDF format |
DOCUMENT_TOO_LARGE | 400 | File exceeds 25MB limit | Compress or split document |
INVALID_BASE64 | 400 | Malformed base64 encoding | Check encoding |
CORRUPTED_DOCUMENT | 422 | PDF cannot be processed | Re-export PDF |
Recipient Errors
| Error Code | HTTP Status | Cause | Solution |
|---|---|---|---|
RECIPIENT_NOT_FOUND | 404 | Recipient ID doesn't exist | Verify recipient ID |
INVALID_EMAIL | 400 | Email format invalid | Use valid email address |
DUPLICATE_RECIPIENT | 400 | Same email added twice | Use unique emails or clientUserId |
RECIPIENT_ALREADY_SIGNED | 409 | Recipient has completed signing | Cannot modify after signing |
View/Signing URL Errors
| Error Code | HTTP Status | Cause | Solution |
|---|---|---|---|
VIEW_URL_EXPIRED | 400 | Signing URL expired (5 min TTL) | Generate new URL |
RECIPIENT_NOT_EMBEDDED | 400 | No clientUserId set | Add clientUserId to recipient |
INVALID_RETURN_URL | 400 | returnUrl is malformed | Use valid HTTPS URL |
Rate Limiting Errors
| Error Code | HTTP Status | Cause | Solution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | 429 | Too many requests | Implement backoff and retry |
Handling Errors in Code
Basic Error Handler
- cURL
- JavaScript
- Python
response=$(curl -s -w "\n%{http_code}" -X POST \
"https://api.propper.ai/restapi/v2.1/accounts/$ACCOUNT_ID/envelopes" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"emailSubject":"Test"}')
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -ge 400 ]; then
error_code=$(echo "$body" | jq -r '.errorCode')
message=$(echo "$body" | jq -r '.message')
echo "Error $http_code: $error_code - $message"
exit 1
fi
echo "Success: $body"
class PropperApiError extends Error {
constructor(status, errorCode, message, requestId) {
super(message);
this.name = 'PropperApiError';
this.status = status;
this.errorCode = errorCode;
this.requestId = requestId;
}
}
const apiRequest = async (url, options = {}) => {
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error = await response.json();
throw new PropperApiError(
response.status,
error.errorCode,
error.message,
error.requestId
);
}
return response.json();
};
// Usage with error handling
try {
const envelope = await apiRequest(
`${BASE_URL}/accounts/${accountId}/envelopes`,
{
method: 'POST',
body: JSON.stringify(envelopeData),
}
);
console.log('Envelope created:', envelope.envelopeId);
} catch (error) {
if (error instanceof PropperApiError) {
switch (error.errorCode) {
case 'UNAUTHORIZED':
case 'TOKEN_EXPIRED':
await refreshToken();
// Retry request
break;
case 'RATE_LIMIT_EXCEEDED':
await sleep(getRetryAfter(error));
// Retry request
break;
case 'ENVELOPE_NOT_FOUND':
console.error('Envelope does not exist');
break;
default:
console.error(`API Error: ${error.errorCode} - ${error.message}`);
console.error(`Request ID for support: ${error.requestId}`);
}
} else {
console.error('Network error:', error);
}
}
import requests
from typing import Optional
class PropperApiError(Exception):
def __init__(self, status: int, error_code: str, message: str, request_id: Optional[str] = None):
super().__init__(message)
self.status = status
self.error_code = error_code
self.request_id = request_id
def api_request(url: str, method: str = "GET", **kwargs) -> dict:
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
**kwargs.pop("headers", {}),
}
response = requests.request(method, url, headers=headers, **kwargs)
if not response.ok:
error = response.json()
raise PropperApiError(
status=response.status_code,
error_code=error.get("errorCode", "UNKNOWN"),
message=error.get("message", "Unknown error"),
request_id=error.get("requestId"),
)
return response.json()
# Usage with error handling
try:
envelope = api_request(
f"{BASE_URL}/accounts/{account_id}/envelopes",
method="POST",
json=envelope_data,
)
print(f"Envelope created: {envelope['envelopeId']}")
except PropperApiError as e:
if e.error_code in ("UNAUTHORIZED", "TOKEN_EXPIRED"):
refresh_token()
# Retry request
elif e.error_code == "RATE_LIMIT_EXCEEDED":
time.sleep(get_retry_after(e))
# Retry request
elif e.error_code == "ENVELOPE_NOT_FOUND":
print("Envelope does not exist")
else:
print(f"API Error: {e.error_code} - {e}")
print(f"Request ID for support: {e.request_id}")
except requests.RequestException as e:
print(f"Network error: {e}")
Retry Strategies
Exponential Backoff
For transient errors (429, 503, 500), implement exponential backoff:
- JavaScript
- Python
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const withRetry = async (fn, maxRetries = 3, baseDelay = 1000) => {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// Only retry on transient errors
const retryable = [429, 500, 502, 503, 504];
if (!retryable.includes(error.status)) {
throw error;
}
// Check for Retry-After header
const retryAfter = error.retryAfter || (baseDelay * Math.pow(2, attempt));
console.log(`Attempt ${attempt + 1} failed, retrying in ${retryAfter}ms...`);
await sleep(retryAfter);
}
}
throw lastError;
};
// Usage
const envelope = await withRetry(() =>
apiRequest(`${BASE_URL}/accounts/${accountId}/envelopes/${envelopeId}`)
);
import time
from functools import wraps
def with_retry(max_retries: int = 3, base_delay: float = 1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except PropperApiError as e:
last_error = e
# Only retry on transient errors
retryable = {429, 500, 502, 503, 504}
if e.status not in retryable:
raise
# Calculate delay with exponential backoff
delay = base_delay * (2 ** attempt)
print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
time.sleep(delay)
raise last_error
return wrapper
return decorator
# Usage
@with_retry(max_retries=3)
def get_envelope(envelope_id: str) -> dict:
return api_request(f"{BASE_URL}/accounts/{account_id}/envelopes/{envelope_id}")
envelope = get_envelope("abc123")
Rate Limiting Best Practices
- Check
Retry-Afterheader - Use server-suggested delay - Implement request queuing - Don't burst requests
- Cache responses - Reduce redundant calls
- Use webhooks - Instead of polling for status
// Rate limiter with queue
class RateLimiter {
constructor(requestsPerSecond = 10) {
this.interval = 1000 / requestsPerSecond;
this.lastRequest = 0;
this.queue = [];
this.processing = false;
}
async request(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
this.processQueue();
});
}
async processQueue() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequest;
if (timeSinceLastRequest < this.interval) {
await sleep(this.interval - timeSinceLastRequest);
}
const { fn, resolve, reject } = this.queue.shift();
this.lastRequest = Date.now();
try {
resolve(await fn());
} catch (error) {
reject(error);
}
}
this.processing = false;
}
}
// Usage
const limiter = new RateLimiter(10); // 10 requests per second
const envelopes = await Promise.all(
envelopeIds.map(id =>
limiter.request(() => apiRequest(`${BASE_URL}/envelopes/${id}`))
)
);
Debugging Checklist
When encountering errors, check these common issues:
Authentication Issues
- Is the token included in the Authorization header?
- Is the token format correct? (
Bearer <token>) - Has the token expired? (Default: 1 hour)
- Are you using the correct base URL? (
https://api.propper.ai) - Does the token have the required scopes?
Request Issues
- Is the Content-Type header set to
application/json? - Is the request body valid JSON?
- Are all required fields present?
- Are IDs in the correct format (UUIDs)?
- Is the account ID correct?
Envelope Issues
- Does the envelope exist? (Check with GET first)
- Is the envelope in the correct state for this operation?
- Are there documents attached?
- Are there recipients defined?
- For embedded signing, is
clientUserIdset?
Common Mistakes and Fixes
Mistake 1: Using Wrong Account ID
// Wrong - using DocuSign account format
const accountId = '12345678';
// Correct - using Propper organization UUID
const accountId = '550e8400-e29b-41d4-a716-446655440000';
Mistake 2: Missing clientUserId for Embedded Signing
// Wrong - no clientUserId, can't get signing URL
const recipients = {
signers: [{
email: 'signer@example.com',
name: 'John',
recipientId: '1',
}],
};
// Correct - clientUserId enables embedded signing
const recipients = {
signers: [{
email: 'signer@example.com',
name: 'John',
recipientId: '1',
clientUserId: 'your-user-id-123', // Required for embedded
}],
};
Mistake 3: Not Handling Token Expiry
// Wrong - token expires, all requests fail
const accessToken = await getToken();
// ... hours later ...
await apiRequest(url); // 401 Unauthorized
// Correct - check expiry and refresh
const getValidToken = async () => {
if (!tokenCache || tokenCache.expiresAt < Date.now()) {
tokenCache = await refreshToken();
}
return tokenCache.token;
};
Mistake 4: Modifying Sent Envelopes
// Wrong - can't add documents to sent envelope
await apiRequest(`${url}/envelopes/${envelopeId}/documents`, {
method: 'POST',
body: JSON.stringify(newDocument),
});
// Error: ENVELOPE_CANNOT_BE_MODIFIED
// Correct - check status first, void if needed
const envelope = await apiRequest(`${url}/envelopes/${envelopeId}`);
if (envelope.status !== 'created') {
// Void and create new envelope, or use correction workflow
}
Mistake 5: Polling Instead of Webhooks
// Wrong - polling wastes API calls
const waitForCompletion = async (envelopeId) => {
while (true) {
const envelope = await apiRequest(`${url}/envelopes/${envelopeId}`);
if (envelope.status === 'completed') return envelope;
await sleep(5000); // Wastes ~720 API calls per hour
}
};
// Correct - use webhooks
app.post('/webhooks/sign', (req, res) => {
if (req.body.type === 'document.completed') {
processCompletedEnvelope(req.body.data.document.id);
}
res.status(200).send();
});
Logging Best Practices
Log enough context for debugging without exposing sensitive data:
const logger = {
info: (msg, context) => console.log(JSON.stringify({ level: 'info', msg, ...context })),
error: (msg, context) => console.error(JSON.stringify({ level: 'error', msg, ...context })),
};
const apiRequest = async (url, options) => {
const requestId = crypto.randomUUID();
logger.info('API request started', {
requestId,
method: options.method || 'GET',
url: url.replace(/accounts\/[^/]+/, 'accounts/***'), // Mask account ID
});
try {
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
logger.error('API request failed', {
requestId,
status: response.status,
errorCode: data.errorCode,
// Don't log full message - may contain PII
propperRequestId: data.requestId,
});
throw new PropperApiError(response.status, data.errorCode, data.message, data.requestId);
}
logger.info('API request succeeded', {
requestId,
status: response.status,
// Log only IDs, not full response
envelopeId: data.envelopeId,
});
return data;
} catch (error) {
logger.error('API request error', {
requestId,
error: error.message,
// Include request ID for support
propperRequestId: error.requestId,
});
throw error;
}
};
Getting Support
When contacting support, include:
- Request ID - From the error response
- Timestamp - When the error occurred (UTC)
- Endpoint - Full URL path (mask sensitive data)
- HTTP Status - Status code received
- Error Code - The
errorCodefrom response - Steps to reproduce - What you were trying to do
Subject: API Error - ENVELOPE_NOT_FOUND
Request ID: req_xyz789abc123
Timestamp: 2024-01-15T10:30:00Z
Endpoint: POST /restapi/v2.1/accounts/***/envelopes/***/views/recipient
HTTP Status: 404
Error Code: ENVELOPE_NOT_FOUND
Steps:
1. Created envelope with POST /envelopes (success, got ID abc123)
2. Immediately called POST /envelopes/abc123/views/recipient
3. Got 404 ENVELOPE_NOT_FOUND
Expected: Signing URL returned
Actual: 404 error
Next Steps
- API Reference - Full endpoint documentation
- Rate Limits - Understand request limits
- Webhooks Guide - Avoid polling with events