Developer reference · Base URL https://app.contacerta.cc
API Documentation
Submit documents programmatically, have them parsed by ContaCerta's extraction pipeline, and receive the structured results in your own system. Available on the **Pro** and **Business** plans. All endpoints return JSON and live under /api/v1; the ingestion endpoint takes raw file bytes as the request body.
1. Authentication
Every request authenticates with an organization-scoped API key. Create and revoke keys under **Settings → API** (organization admins and owners only). The plaintext key is shown once at creation — store it securely; ContaCerta keeps only a hash and can never show it again.
Send the key as a bearer token:
Authorization: Bearer cc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThe header X-Api-Key: cc_live_… is also accepted.
| Condition | Response |
|---|---|
| Missing, malformed, unknown, or revoked key | 401 with a generic body (no distinction between cases) |
| Valid key on a **free** plan | 403 { "code": "plan_required" } |
| Valid key on a paid plan | request proceeds |
Keys are never logged. Treat a key as a secret equivalent to a password.
2. Rate limits
Every API response carries the caller's current limit state:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute for your plan |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | Epoch seconds when the window resets |
When you exceed the limit the response is 429 with a Retry-After header (seconds). Rejected requests do not consume window slots, so a client that retries on 429 settles at the limit rather than starving.
| Plan | Requests / minute | Concurrent in-flight documents |
|---|---|---|
| Pro | 60 | 25 |
| Business | 300 | 100 |
"In-flight" counts documents still pending or processing. Submitting past that cap returns 429 { "code": "in_flight_limit" } with Retry-After; capacity frees as documents reach a terminal state.
3. Submit a document
POST /api/v1/documentsSends the raw file bytes as the request body.
Headers
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer cc_live_… |
Content-Type | yes | One of the accepted types below |
Content-Length | yes | Rejected if absent or above 25 MB, before the body is read |
X-Filename | no | Original filename, stored as metadata (sanitized) |
**Accepted content types:** application/pdf, image/jpeg, image/png, image/heic, image/heif, message/rfc822 (.eml). The declared type must match the file's actual bytes (except .eml, which has no signature). Maximum size: **25 MB**.
Success — 202 Accepted
{ "documentId": "d41f…", "status": "pending" }The document now flows through the same pipeline as email and dashboard uploads: it appears in your ContaCerta organization and counts toward your plan allowance.
Errors
| Status | code | Cause |
|---|---|---|
401 | — | Invalid/missing/revoked key |
403 | plan_required | Free plan |
403 | READ_ONLY_ENTITY | Organization is read-only on its plan |
429 | in_flight_limit | Too many documents pending/processing |
429 | — | Per-minute rate limit exceeded |
411 | length_required | Content-Length missing |
413 | payload_too_large | Over 25 MB |
415 | unsupported_type | Content type not accepted |
415 | content_mismatch | Declared type ≠ actual bytes |
400 | empty_body / bad_body | No or unreadable body |
402 | — | Hard ceiling: usage passed 5× the included allowance |
500 | storage_error | Transient storage failure — safe to retry |
4. Read a document back
GET /api/v1/documents/{documentId}Returns the current status and, once parsing finishes, the extracted result.
{
"documentId": "d41f…",
"status": "ready",
"failureReason": null,
"duplicateStatus": null,
"result": { "documentType": "invoice", "supplier": { "nif": "…", "name": "…" }, "…": "…" }
}status—pending→processing→ready|failed.result— the reconciled extraction payload;nulluntilstatusisready.failureReason— populated only whenstatusisfailed.duplicateStatus—"suspected"when ContaCerta flagged this as a likely duplicate of an existing document (still parsed, but not treated as clean), otherwisenull.
A document that belongs to another organization returns 404 not_found — identical to a genuinely missing id (no existence leak).
5. Check usage
GET /api/v1/usage{
"periodStart": "2026-07-01T00:00:00.000Z",
"periodEnd": "2026-07-31T23:59:59.999Z",
"used": 1840,
"included": 2000,
"overageCount": 0,
"plan": "business"
}included— documents included in your plan this period.used— documents consumed across all channels (API, email, upload) for the whole billing account.overageCount— documents billed as overage this period (max(0, used − included)).
6. Webhooks
Configure a single endpoint URL under **Settings → API**. ContaCerta calls it when a submitted document reaches a terminal state.
**Requirements:** the URL must be public **HTTPS** on the default port. Private, loopback, link-local, and cloud-metadata addresses are rejected at save time and re-checked at delivery. Redirects are never followed (a 3xx is treated as a failed delivery).
Events
type | When | result present |
|---|---|---|
document.ready | Parsing succeeded | yes |
document.failed | Parsing failed | no |
Payload
{
"id": "evt_…",
"type": "document.ready",
"createdAt": "2026-07-07T12:00:00.000Z",
"data": {
"documentId": "d41f…",
"status": "ready",
"duplicateStatus": null,
"result": { "documentType": "invoice", "…": "…" }
}
}On document.failed, data carries failureReason and no result.
Verifying the signature
Each delivery includes an X-Webhook-Signature header:
X-Webhook-Signature: t=1751889600,v1=3ba2…hext— Unix timestamp (seconds) of the signing.v1— hex HMAC-SHA256 of"{t}.{rawBody}"using your endpoint's signing secret (shown once when you save or rotate the endpoint). During a secret rotation, twov1=values are sent — accept the delivery if either verifies.
Reject deliveries whose t is more than **300 seconds** from your clock to prevent replay. Example (Node.js):
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const candidates = header
.split(",")
.filter((p) => p.startsWith("v1="))
.map((p) => p.slice(3));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return candidates.some((sig) => {
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}Always verify against the **raw request body** before parsing the JSON.
Delivery and retries
Failed deliveries (non-2xx, timeout, or redirect) are retried with bounded exponential backoff. Each attempt is recorded and visible under **Settings → API → Recent deliveries**. Include the payload id in your idempotency handling so a redelivery isn't processed twice.
7. Billing and overage
API documents draw from the same monthly allowance as email and upload (Pro: 200, Business: 2,000). Past the allowance, documents are not blocked — each additional document on any channel bills as metered overage at **€0.10**, added to your Stripe invoice. A hard ceiling stops intake at 5× the allowance as a runaway safeguard.
8. Quickstart
# Submit a PDF
curl -sS -X POST https://app.contacerta.cc/api/v1/documents \
-H "Authorization: Bearer $CONTACERTA_API_KEY" \
-H "Content-Type: application/pdf" \
-H "X-Filename: invoice-2026-07.pdf" \
--data-binary @invoice-2026-07.pdf
# → {"documentId":"d41f…","status":"pending"}
# Poll for the result (or receive it via webhook)
curl -sS https://app.contacerta.cc/api/v1/documents/d41f… \
-H "Authorization: Bearer $CONTACERTA_API_KEY"