Reference · v1

InvoRec API

Last updated

Invoice parsing, programmatically. A REST API for turning invoice PDFs and images into structured data — JSON in, JSON out, async by default, with signed webhooks for delivery.

Business plan only. API access is included on the Business plan. See pricing.

Base URL

HTTPS https://api.invorec.com/v1

Every request must be made over HTTPS and authenticated with a bearer token. Browse the topics and endpoints in the sidebar; request and response examples appear on the right.

Conventions

  • All timestamps are integer Unix seconds (UTC).
  • Monetary amounts are integers in the smallest currency unit (e.g. 1250 for €12.50).
  • Object IDs are type-prefixed: invoices inv_…, schemas sch_…, events evt_….
  • Collections return at most 100 items per page. See pagination.
Getting started

Authentication

Authenticate every request with a bearer token issued from the dashboard. Treat live keys like passwords.

Provide the key in the Authorization header. Keys come in two flavors:

  • invr_live_… — production traffic against real data.
  • invr_test_… — sandboxed traffic. Same surface, fixtures only, never billed.
Never embed live keys in frontend code. Proxy uploads through your backend and keep secrets server-side.
Getting started

Errors

InvoRec uses conventional HTTP status codes. Every error returns a JSON body with a stable code string and a human-readable message.

Branch on code — it is stable across versions. The message is for humans and may change.

Getting started

Rate limits

API access requires the Business plan. Default limits are 60 requests/minute and 1,000 invoice uploads/hour per workspace, enforced per-key and per-IP.

Free, Starter and Growth are dashboard-only. Upgrade to Business to issue API keys and call the API.

Every response carries the current quota state in X-RateLimit-* headers; a 429 includes Retry-After.

Getting started

Idempotency

All mutating endpoints accept an Idempotency-Key header. Replays return the original response within a 24-hour window — safe to retry on timeout.

Choose a unique string per logical operation (UUIDs work well). Reusing the same key with a different body returns 409 idempotency_conflict.

Getting started

Pagination

List endpoints return cursor-paginated results. Cursors are opaque tokens; never construct them by hand.

Pass limit (default 25, max 100) and the cursor from the previous response. Every list response includes has_more and next_cursor.

Getting started

Versioning

The current stable surface is v1. Versions are dated; the version is part of the URL and backwards-incompatible changes ship as a new dated version.

Pin your client to a known version. The header InvoRec-Version: 2026-04-08 overrides the workspace default per request.

Non-breaking changes

  • Adding new endpoints, fields, enum values or optional parameters.
  • Adding new event types and new error code values.
Webhooks

Webhooks overview

InvoRec delivers asynchronous results by POSTing signed JSON to your endpoints. Use this in place of polling.

  • At-least-once. Endpoints must be idempotent — the same evt_… can arrive twice.
  • Retries. Transient failures are retried with exponential backoff for up to 24 hours.
  • Order. Events for the same invoice are delivered in order; across invoices they may interleave.

Event types

  • invoice.processed
  • invoice.failed
Webhooks

Signature verification

Every webhook carries an InvoRec-Signature header. Verify it before trusting the body — anyone can POST to your URL.

The header has two comma-separated values: t=<unix_seconds> and v1=<hex>, an HMAC-SHA256 of "<t>.<raw_body>" keyed with your endpoint secret. Reject timestamps drifting more than ±300 seconds.

Verify against the raw request body, not re-serialised JSON — pretty-printing changes the bytes and the signature won't match.
Webhooks

Retry policy

A delivery succeeds on any 2xx within 15 seconds. Failures retry on exponential backoff; persistent failures disable the endpoint.

  • Backoff schedule: 1m, 5m, 15m, 1h, 6h, 24h, then stop.
  • Acknowledge fast (return 200 immediately) and process asynchronously.
  • Deduplicate by event id — it is stable across retries.
Webhooks

GET List webhook endpoints

Returns all of the organization's webhook endpoints. Secrets are never included.

GET /v1/webhooks
Webhooks

POST Create a webhook endpoint

Registers an HTTPS endpoint for the given `events`. The response includes the signing `secret` (`whsec_...`) exactly once - capture it now; it cannot be retrieved later.

POST /v1/webhooks
Webhooks

POST Send a test event

Fires a synthetic `invoice.processed` event at the endpoint's URL with a fixed sample payload (invoice `inv_test_...`, "Test Vendor", $100.00, dated 2026-01-01 - not a real invoice) and returns the raw delivery outcome. Always 200: a non-2xx, unreachable, or SSRF-blocked target surfaces as `success:false`, not an API error. Works on disabled / auto-disabled endpoints (the point is to verify a URL before re-enabling). Persists no delivery row and schedules no retry.

POST /v1/webhooks/{id}/test

Parameters

id string · path required

Webhook endpoint id (`whk_<32hex>`).

Webhooks

POST Rotate the signing secret

Generates a new `whsec_` secret and returns it exactly once. The previous secret stops signing immediately, so existing handlers will fail verification until updated.

POST /v1/webhooks/{id}/rotate-secret

Parameters

id string · path required

Webhook endpoint id (`whk_<32hex>`).

Webhooks

GET Get a webhook endpoint

Returns a single endpoint by id. No secret.

GET /v1/webhooks/{id}

Parameters

id string · path required

Webhook endpoint id (`whk_<32hex>`).

Webhooks

PATCH Update a webhook endpoint

Partial update (tri-state: omit = unchanged, value = set). Flipping `enabled` from false to true clears `auto_disabled` atomically, re-arming a disabled endpoint.

PATCH /v1/webhooks/{id}

Parameters

id string · path required

Webhook endpoint id (`whk_<32hex>`).

Webhooks

DEL Delete a webhook endpoint

Soft-deletes the endpoint. Returns 204.

DEL /v1/webhooks/{id}

Parameters

id string · path required

Webhook endpoint id (`whk_<32hex>`).

Invoices

GET List invoices

Lists invoices newest-first with cursor pagination. Follow `next_cursor` (null when no more pages) to page forward.

GET /v1/invoices

Parameters

cursor string · query optional

Opaque pagination cursor (base64url). Pass back the `next_cursor` from the previous page verbatim; do not construct or parse it.

limit int32 · query optional

Page size, 1-100 (default 25).

status enum · query optional

Filter by status.

created_after string · query optional

Only invoices created at/after this ISO-8601 timestamp.

created_before string · query optional

Only invoices created at/before this ISO-8601 timestamp.

Invoices

POST Upload an invoice

Uploads a document (PDF/PNG/JPEG, max 25MB) for asynchronous processing and returns 202 with the invoice in `processing` status; poll the invoice or subscribe to the `invoice.processed` webhook for completion. **Idempotency** (optional `Idempotency-Key` header): keys are retained 24 hours and only 2xx responses are cached. Replaying the same key with the same body returns the cached response; the same key with a different body returns 409 `idempotency_conflict`. Replays still consume a rate-limit slot. Note: a replayed response is numerically equivalent but NOT byte-for-byte identical (decimal scale is not preserved), so compare fields, not raw bytes. `schema_id` (optional form field) selects a custom extraction schema by its `sch_<32hex>` id; omit it to use the organization's default schema.

POST /v1/invoices

Parameters

Idempotency-Key string · header optional

Optional idempotency key. 24h TTL, 2xx-only caching. Same key + different body returns 409.

Invoices

POST Reprocess an invoice

Re-runs extraction on an existing invoice, optionally with a different schema (`{"schema_id": "sch_..."}` body). Returns 202; the invoice returns to `processing`. The invoice **id is stable** across reprocess - always reference an invoice by id. Its `created_at` may shift slightly when the underlying row is recreated on completion, so never key off timestamps. Honours the same `Idempotency-Key` semantics as upload.

POST /v1/invoices/{id}/reprocess

Parameters

id string · path required

Invoice id (`inv_<32hex>`).

Idempotency-Key string · header optional

Optional idempotency key. 24h TTL, 2xx-only caching. Same key + different body returns 409.

Invoices

GET Get an invoice

Returns a single invoice by id.

GET /v1/invoices/{id}

Parameters

id string · path required

Invoice id (`inv_<32hex>`).

Invoices

PATCH Correct invoice fields

Partially updates extracted fields. Tri-state JSON merge: omit a key to leave it unchanged, send `null` to clear it, send a value to set it. Line items are not editable here.

PATCH /v1/invoices/{id}

Parameters

id string · path required

Invoice id (`inv_<32hex>`).

Invoices

DEL Delete an invoice

Soft-deletes an invoice. Returns 204.

DEL /v1/invoices/{id}

Parameters

id string · path required

Invoice id (`inv_<32hex>`).

Invoices

GET Download the original file

Returns a 302 redirect to a short-lived presigned URL for the original uploaded document. The URL expires after 5 minutes; do not cache it.

GET /v1/invoices/{id}/file

Parameters

id string · path required

Invoice id (`inv_<32hex>`).

Schemas

GET List extraction schemas

Returns all active extraction schemas for the organization in a single page (count is plan-bounded, so `next_cursor` is always null).

GET /v1/schemas
Schemas

GET Get an extraction schema

Returns a single schema by id.

GET /v1/schemas/{id}

Parameters

id string · path required

Schema id (`sch_<32hex>`).

Credits

GET Get credit balance

Returns the organization's current credit balance: subscription credits, top-up credits, and their total. The same `remaining` value is echoed on every API response via the `X-Credits-Remaining` header.

GET /v1/credits
# Available everywhere — no SDK required export INVOREC_KEY="invr_live_8a3f...c91" curl https://api.invorec.com/v1/invoices \ -H "Authorization: Bearer $INVOREC_KEY" \ -F "file=@invoice.pdf"
curl https://api.invorec.com/v1/invoices \ -H "Authorization: Bearer invr_live_8a3f...c91"
{ "error": { "code": "invalid_api_key", "message": "The provided key has been revoked." } }
{ "error": { "code": "unprocessable_invoice", "message": "The uploaded file is not a recognisable invoice.", "param": "file", "request_id": "req_01HFAB7Z5K8C4M2N7Y9P3R6V0X" } }
# 400 invalid_request_error # 401 authentication_required · invalid_api_key # 403 insufficient_scope # 404 resource_not_found # 409 idempotency_conflict # 422 unprocessable_invoice · file_too_large # 429 rate_limited # 500 internal_error · (always safe to retry)
HTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 42 X-RateLimit-Reset: 1716638460
HTTP/1.1 429 Too Many Requests Retry-After: 3 { "error": { "code": "rate_limited", "message": "Slow down — retry in 3s." } }
curl https://api.invorec.com/v1/invoices \ -H "Authorization: Bearer $INVOREC_KEY" \ -H "Idempotency-Key: req_2026_05_25_a1b2" \ -F "file=@invoice.pdf"
{ "id": "inv_01HFAB6ZQ3K7X9N2T4M5Y8R0PD", "status": "processing", "_meta": { "idempotent_replay": true } }
curl 'https://api.invorec.com/v1/invoices?limit=100' \ -H "Authorization: Bearer $INVOREC_KEY" # take next_cursor from the response, then: curl 'https://api.invorec.com/v1/invoices?cursor=eyJpZCI6...&limit=100' \ -H "Authorization: Bearer $INVOREC_KEY"
{ "object": "list", "data": [/* 100 invoices */], "has_more": true, "next_cursor": "eyJpZCI6Imludl8wMUhGQUI..." }
curl https://api.invorec.com/v1/invoices \ -H "Authorization: Bearer $INVOREC_KEY" \ -H "InvoRec-Version: 2026-04-08"
{ "id": "evt_01HFAB7Z5K8C4M2N7Y9P3R6V0X", "object": "event", "type": "invoice.processed", "created": 1716638400, "data": { /* full invoice object */ } }
{ "id": "evt_01HFAB7Z5K8C4M2N7Y9P3R6V0X", "object": "event", "type": "invoice.failed", "created": 1716638400, "data": { /* full invoice object */ } }
import crypto from "node:crypto"; export function verifyWebhook(req, rawBody) { const [t, v1] = req.headers["invorec-signature"] .split(",").map(p => p.split("=")[1]); if (Math.abs(Date.now()/1000 - +t) > 300) throw new Error("timestamp drift"); const expected = crypto .createHmac("sha256", process.env.SECRET) .update(`${t}.${rawBody}`).digest("hex"); if (!crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(v1))) throw new Error("invalid signature"); }
app.post("/webhooks/invorec", async (req, res) => { res.json({ received: true }); // ack immediately if (await seen(req.body.id)) return; // dedupe await handle(req.body); });
curl https://api.invorec.com/v1/webhooks \ -H "Authorization: Bearer $INVOREC_KEY"
{ "data": [ { "id": "string", "url": "string", "events": [ "string" ], "description": "string", "enabled": true, "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z", "last_delivery_at": "2026-01-01T12:00:00Z", "last_delivery_status": "string" } ], "next_cursor": "string" }
curl -X POST https://api.invorec.com/v1/webhooks \ -H "Authorization: Bearer $INVOREC_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/invorec", "events": [ "invoice.processed" ], "description": "Production billing sync" }'
{ "id": "string", "url": "string", "events": [ "string" ], "description": "string", "enabled": true, "secret": "whsec_3q2Lk9xV8mHf7sQ2pZr4tWnB6yCdEfG", # HMAC signing secret, `whsec_<base64url-32-bytes>`. Returned exactly once (create + rotate-secret); cannot be retrieved later. "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z", "last_delivery_at": "2026-01-01T12:00:00Z", "last_delivery_status": "string" }
curl -X POST https://api.invorec.com/v1/webhooks/{id}/test \ -H "Authorization: Bearer $INVOREC_KEY"
{ "success": true, "status_code": 0, "latency_ms": 0 }
curl -X POST https://api.invorec.com/v1/webhooks/{id}/rotate-secret \ -H "Authorization: Bearer $INVOREC_KEY"
{ "id": "string", "url": "string", "events": [ "string" ], "description": "string", "enabled": true, "secret": "whsec_3q2Lk9xV8mHf7sQ2pZr4tWnB6yCdEfG", # HMAC signing secret, `whsec_<base64url-32-bytes>`. Returned exactly once (create + rotate-secret); cannot be retrieved later. "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z", "last_delivery_at": "2026-01-01T12:00:00Z", "last_delivery_status": "string" }
curl https://api.invorec.com/v1/webhooks/{id} \ -H "Authorization: Bearer $INVOREC_KEY"
{ "id": "string", "url": "string", "events": [ "string" ], "description": "string", "enabled": true, "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z", "last_delivery_at": "2026-01-01T12:00:00Z", "last_delivery_status": "string" }
curl -X PATCH https://api.invorec.com/v1/webhooks/{id} \ -H "Authorization: Bearer $INVOREC_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": { "present": true }, "events": { "present": true }, "description": { "present": true }, "enabled": { "present": true } }'
{ "id": "string", "url": "string", "events": [ "string" ], "description": "string", "enabled": true, "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z", "last_delivery_at": "2026-01-01T12:00:00Z", "last_delivery_status": "string" }
curl -X DELETE https://api.invorec.com/v1/webhooks/{id} \ -H "Authorization: Bearer $INVOREC_KEY"
# empty body
curl https://api.invorec.com/v1/invoices?cursor=…&limit=…&status=…&created_after=…&created_before=… \ -H "Authorization: Bearer $INVOREC_KEY"
{ "data": [ { "id": "inv_4f3c2a1b9d8e7f6a5b4c3d2e1f0a9b8c", # Opaque invoice identifier. Stable across reprocess; use this, never timestamps, as the durable reference. "status": "completed", # Processing state. Note the cross-domain naming: the invoice status is `completed`, while the webhook event for the same transition is `invoice.processed`. "invoice_number": "string", "vendor_name": "string", "vendor_email": "string", "vendor_phone": "string", "vendor_tax_id": "string", "vendor_address": "string", "customer_name": "string", "customer_email": "string", "customer_phone": "string", "customer_tax_id": "string", "customer_address": "string", "invoice_date": "2026-01-01", "due_date": "2026-01-01", "subtotal_amount": 0, "tax_amount": 0, "total_amount": 0, "tax_rate": 0, "discount_amount": 0, "discount_percent": 0, "currency": "string", "payment_terms": "string", "payment_method": "string", "payment_status": "string", "notes": "string", "extraction_confidence": 0, "extraction_schema_id": "string", "extraction_schema_name": "string", "custom_fields": [object Object], "line_items": [ { "description": "string", "quantity": 0, "unit_price": 0, "total_price": 0, "tax_rate": 0, "tax_amount": 0, "discount_percent": 0, "discount_amount": 0 } ], "error": { "message": "string" }, "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z" } ], "next_cursor": "string" }
curl -X POST https://api.invorec.com/v1/invoices \ -H "Authorization: Bearer $INVOREC_KEY" \ -F "file=…" \ -F "schema_id=…"
[object Object]
curl -X POST https://api.invorec.com/v1/invoices/{id}/reprocess \ -H "Authorization: Bearer $INVOREC_KEY" \ -H "Content-Type: application/json" \ -d '"string"'
[object Object]
curl https://api.invorec.com/v1/invoices/{id} \ -H "Authorization: Bearer $INVOREC_KEY"
{ "id": "inv_4f3c2a1b9d8e7f6a5b4c3d2e1f0a9b8c", # Opaque invoice identifier. Stable across reprocess; use this, never timestamps, as the durable reference. "status": "completed", # Processing state. Note the cross-domain naming: the invoice status is `completed`, while the webhook event for the same transition is `invoice.processed`. "invoice_number": "string", "vendor_name": "string", "vendor_email": "string", "vendor_phone": "string", "vendor_tax_id": "string", "vendor_address": "string", "customer_name": "string", "customer_email": "string", "customer_phone": "string", "customer_tax_id": "string", "customer_address": "string", "invoice_date": "2026-01-01", "due_date": "2026-01-01", "subtotal_amount": 0, "tax_amount": 0, "total_amount": 0, "tax_rate": 0, "discount_amount": 0, "discount_percent": 0, "currency": "string", "payment_terms": "string", "payment_method": "string", "payment_status": "string", "notes": "string", "extraction_confidence": 0, "extraction_schema_id": "string", "extraction_schema_name": "string", "custom_fields": [object Object], "line_items": [ { "description": "string", "quantity": 0, "unit_price": 0, "total_price": 0, "tax_rate": 0, "tax_amount": 0, "discount_percent": 0, "discount_amount": 0 } ], "error": { "message": "string" }, "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z" }
curl -X PATCH https://api.invorec.com/v1/invoices/{id} \ -H "Authorization: Bearer $INVOREC_KEY" \ -H "Content-Type: application/json" \ -d '{ "invoice_number": { "present": true }, "invoice_date": { "present": true }, "due_date": { "present": true }, "vendor_name": { "present": true }, "vendor_email": { "present": true }, "vendor_phone": { "present": true }, "vendor_tax_id": { "present": true }, "vendor_address": { "present": true }, "customer_name": { "present": true }, "customer_email": { "present": true }, "customer_phone": { "present": true }, "customer_tax_id": { "present": true }, "customer_address": { "present": true }, "subtotal_amount": { "present": true }, "tax_amount": { "present": true }, "total_amount": { "present": true }, "tax_rate": { "present": true }, "discount_amount": { "present": true }, "discount_percent": { "present": true }, "currency": { "present": true }, "payment_terms": { "present": true }, "payment_method": { "present": true }, "payment_status": { "present": true }, "notes": { "present": true }, "custom_fields": { "present": true } }'
{ "id": "inv_4f3c2a1b9d8e7f6a5b4c3d2e1f0a9b8c", # Opaque invoice identifier. Stable across reprocess; use this, never timestamps, as the durable reference. "status": "completed", # Processing state. Note the cross-domain naming: the invoice status is `completed`, while the webhook event for the same transition is `invoice.processed`. "invoice_number": "string", "vendor_name": "string", "vendor_email": "string", "vendor_phone": "string", "vendor_tax_id": "string", "vendor_address": "string", "customer_name": "string", "customer_email": "string", "customer_phone": "string", "customer_tax_id": "string", "customer_address": "string", "invoice_date": "2026-01-01", "due_date": "2026-01-01", "subtotal_amount": 0, "tax_amount": 0, "total_amount": 0, "tax_rate": 0, "discount_amount": 0, "discount_percent": 0, "currency": "string", "payment_terms": "string", "payment_method": "string", "payment_status": "string", "notes": "string", "extraction_confidence": 0, "extraction_schema_id": "string", "extraction_schema_name": "string", "custom_fields": [object Object], "line_items": [ { "description": "string", "quantity": 0, "unit_price": 0, "total_price": 0, "tax_rate": 0, "tax_amount": 0, "discount_percent": 0, "discount_amount": 0 } ], "error": { "message": "string" }, "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z" }
curl -X DELETE https://api.invorec.com/v1/invoices/{id} \ -H "Authorization: Bearer $INVOREC_KEY"
# empty body
curl https://api.invorec.com/v1/invoices/{id}/file \ -H "Authorization: Bearer $INVOREC_KEY"
curl https://api.invorec.com/v1/schemas \ -H "Authorization: Bearer $INVOREC_KEY"
{ "data": [ # The organization's active extraction schemas. { "id": "string", "name": "string", "description": "string", "is_default": true, "definition": [object Object], "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z" } ], "next_cursor": "string" # Always null for schemas - included for shape consistency with other list endpoints. Schemas are bounded by plan limits (max 50 on the Business tier), so the full set is returned in one page. }
curl https://api.invorec.com/v1/schemas/{id} \ -H "Authorization: Bearer $INVOREC_KEY"
{ "id": "string", "name": "string", "description": "string", "is_default": true, "definition": [object Object], "created_at": "2026-01-01T12:00:00Z", "updated_at": "2026-01-01T12:00:00Z" }
curl https://api.invorec.com/v1/credits \ -H "Authorization: Bearer $INVOREC_KEY"
{ "subscription": 450, # Plan-granted credits that reset at the next billing period. "top_up": 1000, # Separately purchased credits that never expire. "remaining": 1450, # Total spendable credits (subscription + top-up); the gate every upload checks against page count. "plan_monthly_allotment": 5000, # The plan's per-cycle subscription credit grant. Compute usage as allotment minus subscription. "plan": "business" # The organization's current subscription plan. }