Developer API
Send a document, get structured fields back
The ParseForMe API is one call to send a document and one webhook to receive the extracted fields. It is the same pipeline the dashboard uses — same document types, same confidence scores, same token cost — reachable from Zapier, Make, n8n, Pipedream or your own code.
- Base URL
https://api.parseforme.com/v1- Auth
Authorization: Bearer pfm_live_…- Machine-readable
- /v1/openapi.json
Authentication
Every request carries an API key as a bearer token. Keys are created in the dashboard under Settings → API keys by an owner or admin, shown once, and can be revoked at any time — revocation takes effect immediately. A workspace may hold up to 10 active keys, and a key acts as a member of that one workspace: it can create, read and export documents and manage webhook endpoints, and it can never manage members, billing or other keys.
curl -s https://api.parseforme.com/v1/me \
-H "Authorization: Bearer pfm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{"workspaceId":"…","workspaceName":"Acme","keyName":"Zapier","balance":420}Key format, and scanning for leaks
A key is the literal prefix pfm_live_ followed by 43 base64url characters — 52 characters carrying 256 bits of entropy. The shape is fixed and published so you can add it to your own secret scanning:
\bpfm_live_[A-Za-z0-9_-]{43}\bKeys work only on /v1/*. Every authentication failure returns the same opaque 401, whatever was wrong with the key — a response that distinguished “unknown” from “revoked” would be a probing oracle.
Errors
Every error, on every endpoint, is the same JSON envelope. Branch on code, show message to a human, and quote requestId when you contact support — it is also returned as the X-Request-Id header on every response, successful or not.
{ "error": { "code": "INSUFFICIENT_BALANCE",
"message": "This workspace has no tokens left.",
"requestId": "01J9…" } }| Status | Code | What it means |
|---|---|---|
| 400 | INVALID_SOURCE | Neither a file part nor a sourceUrl — or both. Send exactly one. |
| 400 | INVALID_IDEMPOTENCY_KEY | The Idempotency-Key header is not ^[A-Za-z0-9_\-:.]{1,128}$. |
| 400 | SOURCE_URL_REJECTED | The egress guard refused the sourceUrl before connecting — not https, not port 443, or it resolved to a private or link-local address (BLOCKED_ADDRESS). The reason code is in the message; the URL is never echoed back. |
| 400 | SOURCE_FETCH_FAILED | The sourceUrl was allowed but could not be fetched. The upstream status is reported; the body never is. |
| 400 | EMPTY_FILE | The uploaded part carried no bytes. |
| 400 | VALIDATION_ERROR | A JSON body failed validation; the message names the fields. On POST /v1/webhooks that includes an authHeader name that is reserved — host, content-type, content-length, transfer-encoding, connection or anything starting x-parseforme-. |
| 400 | WEBHOOK_URL_REJECTED | The webhook URL failed the egress policy: https on port 443, no credentials, a public hostname rather than an IP literal. The policy runs again at every delivery and on every /test, so a URL that was fine when registered can be refused later. |
| 400 | WEBHOOKS_DISABLED | Outbound webhooks are not configured on the server answering — every /v1/webhooks route says so until they are. |
| 401 | — | Missing, malformed, unknown or revoked key. Always identical. |
| 402 | INSUFFICIENT_BALANCE | The workspace has no tokens left. Top up and retry. |
| 404 | WEBHOOK_NOT_FOUND | No endpoint with that id in this workspace. Another workspace’s id is a 404 too, never a 403. |
| 409 | WEBHOOK_LIMIT / WEBHOOK_URL_EXISTS | Ten endpoints already, or that URL is already registered in this workspace. Delete one first. |
| 413 | UPLOAD_TOO_LARGE / SOURCE_TOO_LARGE | Over 20 MB — the declared body, or the file behind a sourceUrl. A body that under-declares its length is cut off mid-stream and answers PAYLOAD_TOO_LARGE instead. |
| 415 | UNSUPPORTED_TYPE / SOURCE_UNSUPPORTED_TYPE | The bytes are not one of the accepted types below. The type is read from the bytes, not from the Content-Type you declare. |
| 429 | RATE_LIMITED / API_KEY_DAILY_CAP | A per-minute limit, or the daily document cap. Honour Retry-After. |
| 503 | UPLOAD_BUSY | Too many uploads in flight. Retry after the Retry-After: 5 it sends. |
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/me | Who this key belongs to, and the token balance. The connection test. |
| GET | /v1/meta | Operational facts you may want to allowlist — the webhook egress IPs. |
| GET | /v1/document-types | The nine document kinds and the fields each one extracts — the field map for your mapping step. |
| POST | /v1/documents | Send a document in, as multipart file upload or as a JSON sourceUrl. Returns 202 with the document. |
| GET | /v1/documents | List documents newest first, filtered by status, kind or since. |
| GET | /v1/documents/{id} | One document. The result field is present once status is parsed. |
| GET | /v1/documents/{id}/export | A short-lived download URL for the parsed document as CSV, XLSX or QBO. |
| POST | /v1/webhooks | Register an HTTPS endpoint for document.parsed and document.failed. Returns the signing secret once. |
| GET | /v1/webhooks | List the registered endpoints. Secrets are never returned again. |
| DELETE | /v1/webhooks/{id} | Remove an endpoint. Deliveries stop immediately. |
| POST | /v1/webhooks/{id}/rotate | Issue a new signing secret. The old one stays valid for 24 hours. |
| POST | /v1/webhooks/{id}/test | Send a synthetic event to the endpoint and report what it answered. |
| GET | /v1/openapi.json | The machine-readable description of everything above. No key needed to read it. |
Asking the API about itself
Two endpoints exist so you never have to hard-code what ParseForMe knows. GET /v1/document-types is the one to build a mapping step against: it returns the nine document kinds with their field descriptors, which is the same list the dashboard shows you in review. Reading it beats mapping from one document you happened to look at.
curl -s https://api.parseforme.com/v1/document-types \
-H "Authorization: Bearer pfm_live_…"
# one entry per kind — resume, invoice, bank_statement, receipt, purchase_order,
# shipping_doc, utility_bill, payslip, generic. The invoice entry in full:
{
"items": [
{
"kind": "invoice",
"schemaVersion": "invoice_v1",
"fields": ["invoiceNumber", "issueDate", "dueDate", "currency", "subtotal",
"taxAmount", "total", "paymentTerms", "bankDetails",
"vendor.name", "vendor.address", "vendor.taxId",
"customer.name", "customer.address",
"lineItems.description", "lineItems.quantity",
"lineItems.unitPrice", "lineItems.amount", "lineItems.taxRate"],
"lineItemPath": "lineItems"
}
]
}Those fields are the dotted paths parsed values arrive under in result.fields, each with a matching entry in result.confidence. lineItemPath names the array a row-per-line export explodes — null for a kind that is one row per document.
GET /v1/meta reports the addresses webhook deliveries come from, so a firewall rule can be generated rather than transcribed:
curl -s https://api.parseforme.com/v1/meta \
-H "Authorization: Bearer pfm_live_…"
{"webhookEgressIps":["46.62.162.196"],"apiKeysEnabled":true}Sending a document
POST /v1/documents answers 202 with the document. It takes two request forms, and which one you use depends on whether your platform holds the bytes or a link to them. Accepted types are PDF, DOCX, PNG, JPEG, TIFF and WebP, up to 20 MB. Omit kind and the document type is detected.
Multipart — you have the file
curl -s -X POST https://api.parseforme.com/v1/documents \
-H "Authorization: Bearer pfm_live_…" \
-F "[email protected]" \
-F "kind=invoice"JSON — you have an https URL
ParseForMe fetches the URL itself, over https only, through an egress guard that pins the address it resolved. This is the Zapier path, where a trigger hands you a link rather than a file.
curl -s -X POST https://api.parseforme.com/v1/documents \
-H "Authorization: Bearer pfm_live_…" \
-H "Content-Type: application/json" \
-d '{"sourceUrl":"https://example.com/invoice-1042.pdf","filename":"invoice-1042.pdf"}'The document
result is present once status is parsed. Every extracted value has a matching entry in confidence — route on that when you want a person to look before the data is committed.
{
"id": "8f14e45f-ceea-467a-9575-1f0d0a0a0a0a",
"status": "parsed",
"kind": "invoice",
"detectedKind": "invoice",
"pages": 2,
"filename": "invoice-1042.pdf",
"failureReason": null,
"createdAt": "2026-09-02T09:14:00Z",
"result": {
"schemaVersion": "invoice_v1",
"fields": { },
"confidence": { }
}
}That is the whole object, and it has no updatedAt. createdAt is the only timestamp a document carries, which is why it is also what the list orders and filters by. Poll on status rather than on a modification time.
Statuses run uploaded, ingesting, ready, queued, processing and then parsed. The terminal unhappy ones are failed, rejected (refused at intake) and infected (the malware scan caught something).
Idempotency
Automations replay. Send an Idempotency-Key header — up to 128 characters of letters, digits and _ - : . — built from something stable in your trigger, and a repeated call returns the SAME document instead of parsing and charging twice. The key is stored with the document, not in a cache, so the guarantee survives a restart.
Waiting inline
Add ?wait=1..25 and the response holds for up to that many seconds for a terminal status. Useful when a scenario has nowhere to receive a webhook; if it returns still processing, fall back to the webhook or to polling rather than looping.
POST https://api.parseforme.com/v1/documents?wait=25Reading documents back
GET /v1/documents returns documents newest first by createdAt, each with a stable id, which makes it both a polling trigger and the backstop for a missed webhook. Filter with status, kind, since (ISO 8601) and limit (1–100, default 20).
The response is items and nothing else: there is no cursor field, so ask for a page with limit and move the window with since. And since filters createdAt, not the parse time — the two are the same column deliberately, so nothing can slip behind your watermark, but it does mean a document uploaded last week and parsed today will not resurface in a status=parsed&since= poll. Keep the window wide enough to cover a slow parse, and deduplicate on id.
GET /v1/documents?status=parsed&since=2026-09-02T08:00:00Z&limit=50
Host: api.parseforme.com
Authorization: Bearer pfm_live_…
{ "items": [ { "id": "8f14e45f-…", "status": "parsed", "kind": "invoice" } ] }GET /v1/documents/{id}/export returns a short-lived download URL for the parsed document as csv, xlsx or qbo, optionally against one of your saved templates with templateId. Add redirect=1 to be sent straight to the file instead. format=qbo needs a templateId; without one it answers 400 QBO_NEEDS_TEMPLATE.
{
"url": "https://…",
"expiresAt": "2026-09-02T09:25:00Z",
"format": "xlsx",
"filename": "invoice-1042.xlsx"
}Webhooks
Parsing is asynchronous, so the fast path to your data is a webhook. Register an endpoint with POST /v1/webhooks for document.parsed and document.failed. The URL has to be plain https on port 443 with a public hostname — no credentials in it, no IP literal — or it answers 400 WEBHOOK_URL_REJECTED; the same URL twice answers 409 WEBHOOK_URL_EXISTS, and a workspace may hold up to 10 endpoints. The response is the endpoint exactly as the list will show it, plus the signing secret (pfm_whsec_…) — returned once and never again.
curl -s -X POST https://api.parseforme.com/v1/webhooks \
-H "Authorization: Bearer pfm_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/parseforme",
"events": ["document.parsed", "document.failed"],
"includeResult": true,
"authHeader": { "name": "X-Token", "value": "a-long-random-string" }
}'
{
"id": "1f8c2b7e-5d0a-4c3f-9e1b-7a6d5c4b3a2f",
"url": "https://example.com/hooks/parseforme",
"events": [
"document.parsed",
"document.failed"
],
"includeResult": true,
"secretHint": "k3Qz",
"authHeaderName": "X-Token",
"consecutiveFailures": 0,
"disabledAt": null,
"createdAt": "2026-09-02T09:10:00Z",
"secret": "pfm_whsec_…"
}includeResult decides whether the extracted fields ride along or you fetch them yourself. authHeader is an optional static header of your choosing, sent on every delivery and stored encrypted — the name comes back in listings as authHeaderName, the value never does. host, content-type, content-length, transfer-encoding, connection and any name starting x-parseforme- are refused.
Every registration — from the dashboard or with a key — emails the workspace owners and admins. That is a security notice, not a preference: a key can register an endpoint, so a stolen key could add a destination for every future document.parsed, and the mail is what makes that visible the same minute.
What a delivery looks like
data.document is the same object GET /v1/documents/{id} returns; data.result is present only with includeResult. The envelope id is also the X-ParseForMe-Delivery header, so your idempotency key sits inside the signed bytes. A body that would pass 1 MB is sent without the result and with data.resultOmitted set to "too_large" — fetch the document in that case.
POST /hooks/parseforme
Content-Type: application/json
X-ParseForMe-Event: document.parsed
X-ParseForMe-Delivery: 7c1f0d2e-…
X-ParseForMe-Signature: t=1788400000,v1=9f86d0…
{
"id": "7c1f0d2e-…",
"type": "document.parsed",
"createdAt": "2026-09-02T09:15:00Z",
"data": {
"document": { "id": "8f14e45f-…", "status": "parsed", "kind": "invoice", "pages": 2 },
"result": { "schemaVersion": "invoice_v1", "fields": { }, "confidence": { } }
}
}Verifying the signature
The signature is an HMAC-SHA256 over the timestamp, a dot, and the RAW request body. Verify before you parse the JSON — a re-serialised body will not match — compare in constant time, and reject anything more than 300 seconds old; every retry is re-signed with a fresh timestamp, so the tight window costs nothing. While a secret is rotating the header carries two v1 entries, current secret first, and either matching is a valid delivery.
const crypto = require('node:crypto');
// Header: X-ParseForMe-Signature: t=<unix seconds>,v1=<hex hmac-sha256>
// Signed payload is t + "." + the RAW request body (parse JSON afterwards).
function verifyParseForMe(rawBody, header, secret) {
const parts = header.split(',').map((p) => p.trim());
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
// Two v1 entries appear while a secret is rotating — either may match.
const sent = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!t || sent.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5-minute tolerance
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
return sent.some(
(sig) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
}Retries, and being switched off
A 2xx within 10 seconds is success — acknowledge first, do the work afterwards. A 5xx, a 408, 425 or 429, or no answer at all is retried with exponential backoff, up to 12 times over about 13 hours, each carrying the same X-ParseForMe-Delivery. Any other answer — a 3xx (redirects are never followed, so register the final URL), a 401, a 404 — counts as one failure and is not retried. Answer 410 and the endpoint is disabled immediately; 50 consecutive failures disable it too, and either way the workspace owners and admins get an email. A 2xx resets the count.
Delivery is at-most-once and unordered. The event is queued after the parse commits, so a crash in that window loses it rather than duplicating it — keep GET /v1/documents?since= as the backstop, and refetch the document for its current state rather than trusting event order. Every delivery comes from 46.62.162.196, which GET /v1/meta also reports, so you can allowlist it rather than hard-code it.
Rotating the secret
POST /v1/webhooks/{id}/rotate returns a new secret once, plus previousSecretExpiresAt: the old secret keeps signing until then, 24 hours later, and deliveries in that window carry both v1 entries. Deploy the new secret at leisure, then drop the old one.
curl -s -X POST https://api.parseforme.com/v1/webhooks/1f8c…/rotate \
-H "Authorization: Bearer pfm_live_…"
{
"id": "1f8c2b7e-5d0a-4c3f-9e1b-7a6d5c4b3a2f",
"secret": "pfm_whsec_…",
"previousSecretExpiresAt": "2026-09-03T09:10:00Z"
}Testing an endpoint
POST /v1/webhooks/{id}/test sends one synthetic document.parsed, signed and delivered exactly like a real one, and reports whether the receiver answered 2xx and with what status — 0 when nothing answered at all. The event carries test: true inside the signed body and the nil UUID as its document id, so a receiver can filter it without pattern-matching, and it is never one of your documents. A refusal is a successful test, not an error; a test never counts towards the 50 failures; a workspace may send 5 a minute.
curl -s -X POST https://api.parseforme.com/v1/webhooks/1f8c…/test \
-H "Authorization: Bearer pfm_live_…"
{"delivered":true,"status":200}
# what the endpoint received:
{
"id": "3b9d5e1a-…",
"type": "document.parsed",
"createdAt": "2026-09-02T09:12:00Z",
"test": true,
"data": {
"document": { "id": "00000000-0000-0000-0000-000000000000", "status": "parsed",
"kind": "invoice", "filename": "sample-invoice.pdf" },
"result": { "schemaVersion": "invoice_v1", "fields": { "invoiceNumber": "INV-0001" },
"confidence": { "invoiceNumber": 0.99 } }
}
}Listing and removing endpoints
A key can register an endpoint, so a key can also leave one behind. List what is registered before you assume nothing is. secretHint is the last four characters of the current secret — enough to tell two apart, never enough to sign with. It is null when the stored secret can no longer be read because the server’s encryption key changed: nothing can be signed for that endpoint, so delete it and register it again. consecutiveFailures and disabledAt are the delivery state described above.
curl -s https://api.parseforme.com/v1/webhooks \
-H "Authorization: Bearer pfm_live_…"
{
"items": [
{
"id": "1f8c2b7e-5d0a-4c3f-9e1b-7a6d5c4b3a2f",
"url": "https://example.com/hooks/parseforme",
"events": [
"document.parsed",
"document.failed"
],
"includeResult": true,
"secretHint": "k3Qz",
"authHeaderName": "X-Token",
"consecutiveFailures": 0,
"disabledAt": null,
"createdAt": "2026-09-02T09:10:00Z"
}
]
}Deleting one stops its deliveries immediately and answers 204 with no body:
curl -s -X DELETE https://api.parseforme.com/v1/webhooks/1f8c… \
-H "Authorization: Bearer pfm_live_…" -o /dev/null -w '%{http_code}\n'
204Limits
Limits are counted per key, not per workspace, so one integration cannot exhaust another. A key gets 60 requests a minute, of which 10 may create documents, and 500 document creations a day. Webhook test deliveries are the exception: 5 a minute for the whole workspace, because each one sends a real request to your endpoint.
A 429 carries Retry-After alongside the IETF RateLimit and RateLimit-Policy headers (there are no X-RateLimit-* headers). RateLimit-Policy reports the window the breach came from — w=60 for the two per-minute buckets, w=86400 for the daily one — and its q= is the limit that actually applied, so read the number from the header rather than hard-coding the ones on this page. Honour Retry-After: a tight retry loop is what turns a brief limit into a long one. A daily Retry-After is capped at an hour, so a platform that sleeps on it comes back the same day.
There is a second ceiling that is not an HTTP status. A key may put 2,000 pages through OCR in any trailing 24 hours — it clears as the oldest pages age out, not at a fixed hour. Past that the upload is still accepted, but the parse is refused before any tokens are held and the document ends failed with a failureReason naming the page limit. A document.failed webhook IS sent for it when the parse was started through the API, which is every /v1 upload; re-parsing that document from the dashboard answers 409 on the spot and sends nothing. Keep polling as the backstop either way. It exists for the realistic accident: a key pasted into a shared automation template, or a loop. Revoking the key is still the immediate stop.
Parsing spends tokens per page exactly as it does in the dashboard, so a runaway automation spends real balance: the 402 is the floor, not a warning.
Platform guides
Step-by-step setup for the four platforms this API was built for, each naming the exact module, field and header to use.
- ZapierSend files to ParseForMe with Webhooks by Zapier, and get the parsed fields back into any Zap through a Catch Hook.
- MakeUpload files from a Make scenario with the HTTP module, and receive parsed fields on a Custom webhook.
- n8nPost binary data straight from an n8n HTTP Request node, and receive parsed fields on a Webhook node — self-hosted or cloud.
- PipedreamCall ParseForMe from a Node.js code step, and start a workflow from an HTTP source when a document is parsed.