Errors & limits
Every error uses one envelope: a stable type for programmatic handling, a specific code, a human-readable message, and the request_id.
{
"error": {
"type": "invalid_request_error",
"code": "unsupported_file_type",
"message": "Supported types: PDF, JPEG, PNG, HEIC, HTML.",
"request_id": "3f1c2a9e-7b4d-4c1e-9a2f-0d5e8b6c4a17"
}
}Every response, success or error, carries an X-Request-Id header (a UUID), readable from browsers too. Error bodies repeat it as error.request_id. Include it when you contact support; we log it with every error.
Status codes
| type | Status | When |
|---|---|---|
| — | 200 | Successful GETs and PATCH; POST with wait=true that finished in time. |
| — | 202 | Create or retry accepted; extraction continues in the background. |
invalid_request_error | 400, 405, 413, 415, 422 | Fix the request; retrying it unchanged fails again. |
authentication_error | 401, 403 | Missing, unknown, revoked, or read-only key. |
quota_exceeded | 402 | Monthly extraction quota used up. Reads keep working. |
not_found | 404 | Unknown id, missing document, or unknown path. |
rate_limit_error | 429 | Per-key rate limit. Respect Retry-After. |
api_error | 409, 500, 502 | Our side or a conflict. Safe to retry with backoff. |
Error codes
Branch on code for specific handling and on type as a fallback. New codes may be added; treat unknown ones by their type.
| code | Status | When |
|---|---|---|
missing_file | 400 | No file field, or the file is empty. |
invalid_body | 400 | Body could not be parsed (multipart or JSON). |
invalid_base64 | 400 | file_base64 is not valid base64. |
invalid_parameter | 400 | A query or body parameter is out of range, malformed, or a file_url host is not allowed. The message names the parameter. |
invalid_idempotency_key | 400 | Idempotency-Key header is empty or longer than 255 characters. |
invoice_not_complete | 400 | PATCH paid or an approval request on an invoice that is not complete. |
already_processing | 400 | Retry on an invoice whose extraction is running. Poll it instead. |
state_changed | 409 | The invoice changed state while a retry was being accepted. Fetch it and try again. |
reextract_rate_limited | 429 | Too many forced re-extractions today. Wait for Retry-After. |
already_complete | 400 | Retry on a complete invoice without {"force": true}. |
approval_pending | 400 | An approval request for this invoice is already waiting on the approver. |
missing_api_key | 401 | No Authorization header, or it is not Bearer ijk_…. |
invalid_api_key | 401 | Unknown or revoked key. |
monthly_quota_reached | 402 | The account has used its monthly extraction quota. See Plans and quotas. |
insufficient_scope | 403 | A read-only key called a write endpoint. |
invoice_not_found | 404 | Unknown id, or an id on another account. |
no_document | 404 | The invoice has no stored source document (file download, retry). |
route_not_found | 404 | No such path. |
method_not_allowed | 405 | Wrong HTTP method for a known path. |
idempotency_in_flight | 409 | A request with the same Idempotency-Key is still processing. Retry shortly. |
file_too_large | 413 | Over 15 MB. |
unsupported_file_type | 415 | Not PDF, JPEG, PNG, HEIC, or HTML. |
file_url_unreachable | 422 | file_url could not be fetched (error status, timeout, no body, or too many redirects). |
rate_limited | 429 | Over 60 requests per minute for this key. Wait Retry-After seconds. |
internal | 500 | Server error. Safe to retry with backoff. |
approval_send_failed | 502 | The approval email could not be sent. Safe to retry. |
Rate limits
60 requests per minute per API key, counted over a sliding 60-second window. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (unix seconds when the oldest request in the window expires). 429 responses also include Retry-After in seconds. 401s do not count. Rate limits are separate from the monthly extraction quota.
Plans and quotas
| Plan | Invoices / month | Price |
|---|---|---|
| Free | 10 | $0 |
| Pro | 200 | $29/mo or $290/yr |
| Business | 1,000 | $99/mo or $990/yr |
The quota counts extractions across every channel (app, email, API), including each invoice produced by batch-scan splitting. It resets at the start of each calendar month (UTC). Check it with GET /v1/me. Upgrade in the app under Settings, Plan; over 1,000 a month, email support@invoicejet.ai.
At the limit, POST /v1/invoices and retries return 402 monthly_quota_reached with the numbers and an upgrade link; reads keep working. Invoices you upload in the app or email in while you are over the limit are stored with status held instead of being extracted, and are processed when you upgrade or when the quota resets on the 1st; find them with GET /v1/invoices?status=held. An account can have up to 200 invoices waiting at once.
{
"error": {
"type": "quota_exceeded",
"code": "monthly_quota_reached",
"message": "Monthly quota reached (200 of 200).",
"limit": 200,
"used": 200,
"upgrade_url": "https://app.invoicejet.ai/app/settings"
}
}Usage is a ledger: extractions that fail are refunded, and deleting an invoice does not give its extraction back. Retrying a failed invoice with POST /v1/invoices/:id/extract uses one extraction; the retry checks quota up front and returns 402 when you are over the limit.
Idempotency & retries
Make POST /v1/invoices safe to retry by sending an Idempotency-Key header (any unique string up to 255 characters; a UUID works well). Retrying with the same key within 24 hours replays the original response (marked with an Idempotency-Replayed: true header) instead of ingesting the document again. If the original request is still processing, the retry gets 409 idempotency_in_flight; wait a moment and try again. Without the header, a retried create ingests twice (duplicate detection flags exact vendor + invoice number + amount matches, but sending a key is the safer path). An empty or over-long key returns 400 invalid_idempotency_key. GETs and PATCH retry freely.
import { randomUUID } from "node:crypto";
// Generate the key once per document and reuse it on every retry.
const idempotencyKey = randomUUID();
await client.invoices.extract({
fileUrl: "https://example.com/invoices/june.pdf",
idempotencyKey,
});