Error Handling & Retry
Error Handling & Retry
This guide covers error handling strategies for both job processing failures and webhook delivery issues.
Job processing errors
When a job fails during processing, the webhook payload (or manual status check) includes an error field with details.
Failed job payload
{ "id": "550e8400-e29b-41d4-a716-446655440000", "status": "failed", "created_at": "2025-01-01T12:00:00Z", "completed_at": "2025-01-01T12:15:00Z", "error": "Unable to extract required fields from document" }
Common error scenarios
Document issues:
- Document is corrupted or unreadable
- OCR processing failed due to poor image quality
- Processing exceeded maximum time limit
Schema issues:
- Schema validation failed
- Required fields couldn't be extracted
- Invalid schema format
Submission issues (HTTP errors on POST /v1/process, not job failures):
- Invalid API key (
401) - The API key could not be checked because our database did not answer (
503, withRetry-After: 5) — retry after the header says; a bad key is always401 - Submissions per minute, pages in flight, or the monthly page allowance exceeded (
429, withRetry-After) — retry after the header says - Service unavailable (
5xx) — retry the request with backoff - Plan or credits do not cover the request, or the API key is at its monthly spend limit (
402), file type not on the plan (415), document over the 2,000-page / 500 MB hard maximum (413), or anidempotency_keyreused with a different file or options (409) — fix the request, retrying changes nothing
Handling job failures
A failed job is final. DeepRead already retries transient infrastructure errors internally — with backoff, for up to 48 hours — and while that happens the job simply reports queued or processing; you never see a failure that a retry could have fixed. Resubmitting the same document unchanged produces the same error; fix the cause it describes instead (an unreadable file, an unsupported layout, a schema that doesn't match the document).
Retry logic belongs at the submission step, where a 429 or 5xx response to POST /v1/process is worth retrying. Send an idempotency_key with every submission you might retry: a retry with the same key returns the job the first request created, with 200, instead of creating and charging a second one. The key is up to 255 characters and unique per account (case and surrounding whitespace ignored); the same key with a different file or options is refused with 409.
#!/usr/bin/env bash# Submit a document, retrying only on transient failures (429 / 5xx).set -euo pipefail API_KEY="${DEEPREAD_API_KEY:?set DEEPREAD_API_KEY}"FILE="invoice.pdf"MAX_RETRIES=3# One key per submission, sent on every retry: a retry that lands after a# timeout returns the job the first attempt created instead of a second one.IDEMPOTENCY_KEY="$(uuidgen)" for attempt in $(seq 1 "$MAX_RETRIES"); do # Write the body to a file so we can read the status code separately. status=$(curl -sS -o /tmp/deepread-response.json -w '%{http_code}' \ -X POST https://api.deepread.tech/v1/process \ -H "X-API-Key: $API_KEY" \ -F "file=@$FILE" \ -F "pipeline=extract" \ -F "webhook_url=https://your-app.com/webhooks/deepread" \ -F "idempotency_key=$IDEMPOTENCY_KEY") if [ "$status" = "200" ]; then cat /tmp/deepread-response.json exit 0 fi # 4xx other than 429 is a client error — retrying will not help. if [ "$status" != "429" ] && [ "$status" -lt 500 ]; then echo "Request failed with $status (not retryable):" >&2 cat /tmp/deepread-response.json >&2 exit 1 fi backoff=$((2 ** (attempt - 1))) echo "Got $status, retrying in ${backoff}s (attempt $attempt/$MAX_RETRIES)..." >&2 sleep "$backoff"done echo "Failed after $MAX_RETRIES attempts" >&2exit 1Fallback to manual status checking
If webhooks are delayed or missed, use manual status checking as a fallback. You can also check the Dashboard Jobs page to see all job statuses and error messages at a glance.
#!/usr/bin/env bash# Poll a job as a fallback when a webhook is delayed or missed.set -euo pipefail API_KEY="${DEEPREAD_API_KEY:?set DEEPREAD_API_KEY}"JOB_ID="550e8400-e29b-41d4-a716-446655440000"MAX_WAIT=300INTERVAL=5 elapsed=0while [ "$elapsed" -lt "$MAX_WAIT" ]; do job=$(curl -sS "https://api.deepread.tech/v1/jobs/$JOB_ID" \ -H "X-API-Key: $API_KEY") status=$(printf '%s' "$job" | jq -r '.status') case "$status" in completed) printf '%s\n' "$job" exit 0 ;; failed) echo "Job failed: $(printf '%s' "$job" | jq -r '.error // "unknown error"')" >&2 exit 1 ;; esac sleep "$INTERVAL" elapsed=$((elapsed + INTERVAL)) # Back off up to 30s so a long document doesn't hammer the API. INTERVAL=$(( INTERVAL * 2 > 30 ? 30 : INTERVAL * 2 ))done echo "Job did not complete within ${MAX_WAIT}s" >&2exit 1Webhook delivery errors
How DeepRead retries
Each webhook is attempted up to 3 times, with a 30 second timeout per attempt and exponential backoff between them (1s, then 2s):
| Your response | What DeepRead does |
|---|---|
2xx | Success — delivery is recorded, no further attempts |
4xx | Permanent failure — not retried. Treated as "this endpoint rejected the payload" |
5xx | Retried until the 3 attempts are used up |
| Timeout / connection error | Retried until the 3 attempts are used up |
After the final attempt the outcome is stored on the job itself, so you can
audit it: webhook.delivered, webhook.delivered_at and webhook.error on the
GET /v1/jobs/{job_id} response.
Retries are not durable across a much longer outage — three attempts span a few seconds, not hours. If your endpoint is down longer than that, the delivery is lost and polling is the recovery path.
Fallback behavior (recommended)
- Poll
GET /v1/jobs/{job_id}if you haven’t received a webhook within your SLO - Treat webhook payloads as notifications and fetch the canonical job result from the API
Handling webhook retries in your endpoint
Because a delivery can be attempted up to 3 times, your endpoint must be
resilient to duplicates — a slow first attempt that times out on our side may
still have been processed on yours. Deduplicate on the job id, the event and
review.version together, so review.* deliveries that follow job.completed
are not dropped as duplicates:
import json # In production, store this in a database/Redis (not in-memory) so the# deduplication survives a restart and is shared across instances.processed: set[tuple[str, str, int]] = set() def handle_deepread_webhook_once(raw_body: bytes) -> None: payload = json.loads(raw_body.decode("utf-8")) review = payload.get("review") or {} delivery = (payload["id"], payload["event"], review.get("version", 0)) # Deduplicate on (id, event, review.version) so handling is safe to repeat: # a delivery is attempted up to 3 times, and a first attempt that timed out # on our side may still have been processed on yours. review.* events for # the same job arrive after job.completed and must not be dropped. if delivery in processed: return processed.add(delivery) print(f"Processing {delivery[1]} for job {delivery[0]}")When to return error status codes
Return 200 OK when:
- You successfully received the webhook (even if your own processing fails later)
- The job was already processed (idempotency)
- You want to stop retries for a specific delivery
Return 5xx when:
- Your service is temporarily unavailable and you want the remaining attempts (503)
- Your endpoint hit a transient error that a retry could clear
Avoid 4xx — including 401/403 — on requests that are ours. A 4xx is
treated as a permanent rejection and stops retrying immediately, so an auth
check that wrongly rejects our request costs you the delivery entirely.
Authenticate deliveries by verifying the X-DeepRead-Signature header (see
Handling Results); a request whose signature does
not verify is not ours and can be rejected. After rotating your secret, deploy the
new one straight away — deliveries sign with it from the next one on, and a stale
secret would fail every verification. Whatever the signature says, confirm what
you act on by fetching GET /v1/jobs/{job_id} with your own API key.
Monitoring and alerting
Set up monitoring for job failure rates, webhook delivery failures, processing times, and rate limit hits (429 responses).