SeguraDoc API
Transform untrusted PDFs into safe, inert, OCR-searchable documents over a JSON HTTP API.
Overview
SeguraDoc processes an untrusted PDF inside an isolated sandbox and returns:
- A safe reconstructed PDF rebuilt from page snapshots — no active PDF behavior (JavaScript, embedded files, launch actions, forms) survives the round-trip.
- OCR text, per page, for image-based PDFs (Mistral
ministral-14bvision model). - An AI-generated summary of the document content (Mistral
mistral-small). - A structured parse of the original file — headings, tables, links, form fields, annotations, embedded objects — with a list of security signals found along the way.
Sandbox sanitization, OCR, AI summary, and the structured parse are all included in the per-page price — no separate AI metering, no token counting on your end. See AI features and Structured parse for the details.
The value is deterministic transformation, not probabilistic detection. SeguraDoc does not promise to identify malware — it removes the active-content surface that malware lives in.
Base URL
https://api.seguradoc.comContent type
All request bodies and responses are application/json, except file uploads which use multipart/form-data and downloads which return application/pdf.
Authentication
Every request to /v1/ (except /v1/health and the /v1/x402/* family) requires a bearer token in the Authorization header:
Authorization: Bearer sk_live_…Issuing a key
Log into the API Keys page in your dashboard and click New API key. The raw value (starting with sk_live_ for production or sk_test_ for test) is shown exactly once. Store it in your secret manager — we only persist its SHA-256 hash.
Revoking a key
Click Revoke on a key in the dashboard. Subsequent requests using that key return 401 Unauthorized within seconds. Rotation = revoke + create new.
Auth scope
Each key is scoped to one organization. All charges, document jobs, and ledger entries created with that key belong to its org. There are no read-only or limited-scope key tiers in v1.
Quickstart
End-to-end: check balance → upload a PDF → poll for completion → download the safe PDF.
export KEY="sk_live_…"
# 1. Verify the key works
curl -s -H "Authorization: Bearer $KEY" \
https://api.seguradoc.com/v1/balance
# 2. Upload a PDF
JOB=$(curl -s -X POST \
-H "Authorization: Bearer $KEY" \
-F '[email protected]' \
https://api.seguradoc.com/v1/documents)
ID=$(echo "$JOB" | jq -r .id)
# 3. Poll status (also: subscribe to a webhook — see Webhooks below)
while true; do
STATUS=$(curl -s -H "Authorization: Bearer $KEY" \
https://api.seguradoc.com/v1/documents/$ID | jq -r .status)
echo "Status: $STATUS"
[ "$STATUS" = "completed" ] && break
[ "$STATUS" = "failed" ] && exit 1
sleep 2
done
# 4. Download the safe PDF
curl -s -H "Authorization: Bearer $KEY" \
-o sanitized.pdf \
https://api.seguradoc.com/v1/documents/$ID/safe-pdfbashPricing & balance
PDF processing is a per-page rate + per-document fee, both billed in USD. Includes sandboxed sanitization, OCR (when needed), an AI summary, and the structured parse — no separate metering.
$0.01 per page + $0.02 per document
Bundles everything: sandbox, OCR (Mistral ministral-14b), summary (Mistral mistral-small), structured parse, 24h output retention.
Examples:
- 1-page PDF:
$0.03 - 10-page PDF:
$0.12 - 100-page PDF:
$1.02
Reservation flow
On every upload, we count pages with PyMuPDF, compute the estimated cost, and atomically reserve that amount from your organization balance. If the balance is insufficient, you get 402 insufficient_balance immediately — the job is never created.
After processing completes, the actual cost is computed from the sandbox-reported page count. If it differs from the reservation, we write an adjustment entry:
- Overage refunded: actual < reserved → the difference is returned to your balance (
reservation_releaseledger entry). - Additional charge: actual > reserved → balance debited the difference (
adjustmentledger entry). - Job fails: full refund (
reservation_releasewithfull_release).
Document generation
The document generator is priced separately and flatly: $0.20 per create and $0.20 per revision, whatever the format and however long the document. It is not part of the per-page rate. It draws on the same organization balance this page documents, and every run shows up on /v1/usage.
A run is priced, held, then settled. Because the rate is flat, settling a successful run moves no money and writes no second row — so a completed generation is a single reservation debit carrying resource.type: "generator_run". A failed one is followed by a reservation_release credit for the whole amount.
The generator has no /v1/ surface yet — it is dashboard-only, so an agent holding a bearer token cannot invoke it. Its charges are still visible over the API.
Topping up
Add USD balance from the Usage & Balance page. Packs: $5, $10, $25, $50, $100. Payable by credit card (Stripe) or cryptocurrency (BlockBee). What you pay is what you get.
Errors
Errors return a JSON body with a stable error.code and a human-readable error.message. Code your retries against error.code, not the message.
{
"error": {
"code": "insufficient_balance",
"message": "Your account does not have enough processing balance for this document.",
"required_micro_usd": 120000,
"available_micro_usd": 50000
}
}json| code | HTTP | when |
|---|---|---|
| invalid_file | 400 | Missing field, unreadable, bad magic bytes |
| unsupported_file_type | 400 | Extension is not .pdf |
| invalid_operation | 400 | Unknown value for the operation parameter |
| file_too_large | 413 | Above API_MAX_UPLOAD_BYTES (default 100 MB) |
| page_limit_exceeded | 400 | Above API_MAX_PAGE_COUNT (default 500) |
| insufficient_balance | 402 | Balance < reservation amount |
| not_found | 404 | Document doesn’t exist or belongs to another org |
| no_parse | 404 | Document completed but carries no structured parse |
| no_summary | 404 | Document completed but carries no AI summary |
| not_ready | 409 | Output requested before status reached completed |
| gone | 410 | Outputs have been retention-deleted |
| rate_limited | 429 | Rate cap exceeded — counted per account when the caller is signed in, per client IP otherwise |
| upload_failed | 500 | Disk write error on our side |
| processing_failed | 500 | Unexpected failure queuing the job |
The one exception: 401
A bad or revoked bearer token is rejected before a view runs, so it does not carry an error.code. The body is the framework's own shape:
{"detail": "Invalid or revoked API key."}jsonBranch on the 401 status for this one rather than on a code. Everything else on /v1/ uses the envelope above. The generator uses a third envelope of its own — see its error codes.
Rate limits
The bearer-authenticated /v1/ endpoints are not rate-limited in v1. Heavy users should reach out so we can add per-key throttles cooperatively rather than reactively.
The unauthenticated x402 family is limited, per client IP:
GET /v1/x402/bands— 60 / minPOST /v1/x402/quote— 10 / minPOST /v1/x402/documents— 10 / min
The generator quote, create and revise routes are limited to 6 / min. Those are session-authenticated, so the count is per account, not per address — an office behind one NAT address does not share a budget.
Exceeded: 429 rate_limited with retry_after_seconds in the body. If our cache is unreachable the limiter fails open rather than blocking traffic.
AI features
What's included
Every completed document carries two AI-generated outputs alongside the safe PDF. Both run on Mistralmodels hosted on Mistral's European infrastructure (EU data residency for the AI portion of the pipeline).
OCR
Model: ministral-14b-latest
Fires automatically when a page has no extractable text (image-only PDFs, scans). Returns per-page text alongside the safe PDF. Skipped for text-native PDFs — no charge difference.
Document summary
Model: mistral-small-latest
Fires for every job with extractable text. ~5-bullet HTML summary covering the document's subject, key entities, and structure. Up to 12k tokens of context.
Pricing
Both AI calls are included in the $0.01/page + $0.02/document rate — see Pricing & balance. You do not see Mistral token counts and we do not pass through Mistral's rate limits to you.
Data handling
Page snapshots and extracted text are sent to Mistral's API during OCR and summarization. Original PDFs never leave your organization's isolated sandbox. Mistral does not retain prompts for training on their EU endpoints (their published policy at time of writing — verify if this matters to you).
When AI runs vs. doesn't
- OCR fires only when extracted text is < 50 characters (essentially: image-only PDFs). Text-native PDFs skip OCR entirely.
- Summary fires whenever the document has any extractable text. Empty / corrupt PDFs get
summary: nullin the response — no error, just nothing to summarize. - Failed AI calls don't fail the whole job. The safe PDF still ships; the missing AI field is left
null.
OCR — image-based PDFs
Scanned documents, photos saved as PDFs, and other image-only files get OCR'd page-by-page through Mistral's vision model. The output is plain text — formatting like headings and tables is preserved best-effort but not guaranteed structured.
Fetch the OCR result via GET /v1/documents/{id}/ocr. Each page comes back as a {page: N, text: "…"} object so you can match content back to page positions.
OCR uses image input on Mistral's API. Pages are rendered to PNG in our sandbox before transmission — Mistral never sees the original PDF bytes.
Document summary
Every completed job that has any extractable text (whether native or via OCR) gets a Mistral-generated summary. Output is short HTML — typically a one-sentence overview followed by a 3–5 bullet list. We sanitize the output to remove code fences, markdown wrappers, and any stray tags.
Three places to read it:
- Inline — the
summaryfield in theGET /v1/documents/{id}response. - Dedicated endpoint —
GET /v1/documents/{id}/summaryif you only need the summary text and not the rest of the document metadata. - x402 path —
GET /v1/x402/documents/{id}/summary?token=…for x402-paid jobs.
Shape of the summary value:
{
"html": "<p>Invoice from Acme Corp dated 2026-05-11.</p><ul><li><strong>Total: $1,240.00</strong></li><li>Net 30 terms</li><li>Three line items</li></ul>",
"generated_at": "2026-05-11T12:00:07Z",
"model": "mistral-small-latest"
}jsonRendering safely
The html field is HTML from Mistral's output, already passed through our sanitizer. It contains only <p>, <ul>, <li>, and <strong>tags. We treat it as untrusted on our side — you should too. Render with your framework's safe-HTML primitive or strip tags if you want plain text:
// React (safe — DOMPurify is overkill here but recommended)
import DOMPurify from 'isomorphic-dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(doc.summary.html) }} />
// Strip to plain text
const text = doc.summary.html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();javascriptLimitations
- Truncated to ~12k tokens of input. Documents longer than that get summaries based on the first portion only.
- No structured output schema in v1 — if you need fields like
amount,vendor,due_date, extract them yourself from the OCR text. A structured-extraction endpoint is on the roadmap. - English-optimized prompt. Non-English documents are summarized but the summary may revert to English even when the source isn't.
Structured parse
What you get back
Alongside the safe PDF, every completed document carries a structured parse: what the file says, how it is laid out, and what it tries to do when opened. Read it at GET /v1/documents/{id}/parse. It is included in the per-page price.
The parse runs against the file you uploaded, not the safe rebuild. That is deliberate: sanitization strips the links, actions and embedded objects, so parsing the output would report a clean document every time and tell you nothing about what arrived.
Document level
document— page count, pages actually parsed, PDF version, producer and creator strings, and theis_encrypted/is_linearized/has_acroform/has_xfaflags. Files that declare their own page labels also get apage_labelsarray.outline— the bookmark tree flattened, each entry withlevel(1–6),title, targetpage, adest_kindofinternal/uri/launch/other, and theuriwhen there is one.form_fields— name, resolved type,requiredandreadonly, whether a value is present and how long it is (never the value itself), theaction_kindandaction_targetattached to the field, and its box.annotations— everything that is not a form widget: subtype, whether an action hangs off it, the action kind, the URI for link annotations, the box, the length of the note text, and the raw flag bits.embedded_objects— attachment names, declared sizes and MIME types (never their bytes), plus counts of document-level JavaScript entries, embedded fonts, form XObjects and streams that point at an external file, and the kind of the document's open action.stats— how long the parse took, how big the payload is, and how many elements it describes.
Page level
Each page in pages.items carries its size and rotation, the extracted text with a text_truncated flag and a true char_count, and six arrays: blocks (text and image regions with boxes and line counts), headings (lines that stand out from the page's own body size, with an inferred level), tables (box, row and column counts, header row, and a preview of the first rows), links (kind, URI, box, and the anchor text sitting under the box), images (box, pixel size, colorspace, bit depth, soft mask), and fonts. A flags object then reports the five hidden-text findings for that page.
Which operations produce it
operation=full — the default — produces the document-level blocks and the per-page structures. operation=parse produces the document-level blocks only: a standalone parse job renders no pages, so pages.items comes back empty. If you want both halves, upload with full.
A parse that fails never fails the job. You still get the safe PDF and the snapshots you paid for; the parse route answers 404 no_parse instead.
Limits and truncation
The parse is bounded so that a hostile file cannot bill you for an afternoon of CPU. It stops at 300 pages, a 150-second budget, or a payload of about 8 MB, whichever comes first. When it stops early, truncated is true and truncation_reason is one of page_cap, time_budget or size_budget.
Within a page, the arrays are capped too — 200 blocks, 50 headings, 8 tables, 100 links, 50 images, 30 font names, 20 000 characters of text — and document-wide at 500 form fields, 1 000 annotations, 500 outline entries and 100 embedded files. Counts reported outside those arrays are the true totals, not the truncated ones.
Signals & risk
Read this before you wire risk.level into anything.
The score is a heuristic that flags signals worth reviewing. It is not a malware verdict. A high score is a document a person should look at; a clean score is a document in which these particular checks found nothing, which is not the same as a document that is safe. Do not make it the sole basis for accepting or rejecting a file.
The parse reports what it noticed as a list of coded signals. The set is closed — a detector cannot invent a code or pick its own severity:
| code | severity | raised when |
|---|---|---|
| active_content.javascript | high | JavaScript in the document name tree, an open action, an annotation, or a form field |
| active_content.launch_action | high | An action that asks the reader to run an external program |
| active_content.open_action | medium | The document does something other than go to a page when opened |
| active_content.additional_actions | medium | An /AA entry on the catalog, a page, or a field — actions on focus, blur, page open |
| active_content.embedded_file | medium | The document carries attachments |
| hidden_text.invisible_render_mode | high | Text drawn in render mode 3, which paints nothing |
| hidden_text.covered_by_image | high | Text painted over by an image drawn after it |
| hidden_text.offpage | medium | Text placed outside the page box |
| hidden_text.low_contrast | medium | Text the same colour as the fill behind it |
| hidden_text.tiny | low | Text set below 1.5pt |
| url.homoglyph | high | A host name mixing scripts — Latin and Cyrillic in one label |
| url.obfuscated | medium | A dangerous scheme, or an authority hiding the real host behind an @ |
| url.link_text_mismatch | medium | The anchor text names one domain, the link goes to another |
| url.shortener | low | A known link shortener, which hides the destination |
| form.suspicious_action | high | A field that runs JavaScript, imports data, or submits off-site |
| form.xfa_present | medium | The AcroForm carries an XFA packet |
| structure.external_stream | medium | A stream whose content lives in another file |
| structure.encrypted | info | The document is encrypted |
| structure.page_too_complex | info | A page carried too many drawing operations to inspect for tables |
Shape of a signal
{
"code": "url.link_text_mismatch",
"severity": "medium",
"count": 2,
"pages": [1, 4],
"sample": "\"acme.com\" links to payments-acme.example"
}jsoncount is how many times the signal fired across the document. pages lists up to 20 pages it fired on and is empty for document-level findings. sample is one short illustrative string. At most 50 signals are listed, highest severity first — but counts is computed from everything found, so the totals stay true even when the list is cut.
How the score is built
Each distinct code scores once, however many times it fired: high 30, medium 15, low 5, info 0. The total is capped at 100. risk.level is then read off the score:
clean— score 0low— 1 to 14medium— 15 to 44high— 45 and above
counts groups the same findings by family — active_content, hidden_text, link_anomaly, form_anomaly, structure_anomaly — so you can route on a category without unpacking the signal list.
Note what the scoring implies: a document with one JavaScript action and a document with four hundred both score 30 for it. The count is in the signal; the score answers "how many kinds of thing are wrong here", not "how much".
Documents
/v1/documentsUpload a PDF for processing. Reserves balance immediately; processing happens asynchronously.
Headers
Authorizationstring · requiredBody (multipart/form-data)
filebinary · requiredoperationstringfull. One of: full, sanitize, render, extract_links, parse. Stay on full unless you know you want a single stage — it is the only one that produces every output, the structured parse included.pdf_passwordstringResponse 202
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"page_count": 12,
"estimated_cost": {"amount": "0.140000", "currency": "USD"},
"created_at": "2026-05-11T12:00:00Z"
}jsonExample
curl -X POST https://api.seguradoc.com/v1/documents \
-H "Authorization: Bearer $KEY" \
-F '[email protected]' \
-F 'operation=full'bash/v1/documents/{id}Fetch the current status, final cost, and output URLs of a document job.
Headers
Authorizationstring · requiredResponse 200
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"source": "api",
"page_count": 12,
"cost": {"amount": "0.140000", "currency": "USD"},
"outputs": {
"safe_pdf_url": "https://api.seguradoc.com/v1/documents/550e…/safe-pdf",
"ocr_text_url": "https://api.seguradoc.com/v1/documents/550e…/ocr"
},
"summary": {
"html": "<p>Invoice from Acme Corp dated 2026-05-11.</p><ul><li><strong>Total: $1,240.00</strong></li><li>Net 30 terms</li></ul>",
"generated_at": "2026-05-11T12:00:07Z",
"model": "mistral-small-latest"
},
"created_at": "2026-05-11T12:00:00Z",
"started_at": "2026-05-11T12:00:01Z",
"completed_at": "2026-05-11T12:00:08Z",
"error_message": null
}jsonPossible statuses: pending, queued, processing, completed, failed. outputs and summary are populated only when status == "completed". summary is null for documents that had no extractable text.
outputs carries the two file URLs only. The structured parse and the standalone summary have their own routes under the same document id.
/v1/documents/{id}/safe-pdfStream the sanitized PDF. The original file is removed after the configured original-retention window (default 1h); the safe PDF lives for the output-retention window (default 24h).
Headers
Authorizationstring · requiredResponse 200
application/pdf body. Content-Disposition: attachment; filename="sanitized_{original_filename}".
409 not_ready if status is not yet completed. 410 gone if outputs have aged out per the org retention policy.
/v1/documents/{id}/ocrReturn per-page extracted text. Image-based PDFs go through Mistral OCR; text-native PDFs use the sandbox extraction.
Headers
Authorizationstring · requiredResponse 200
{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"pages": [
{"page": 1, "text": "Invoice\nDate: 2026-05-11\n…"},
{"page": 2, "text": "Line items\n…"}
]
}json/v1/documents/{id}/parseThe structured parse of the original upload: layout, text structure, links, form fields, annotations, embedded objects, and the security signals found along the way.
Headers
Authorizationstring · requiredQuery
from_pageintegerto_pageintegerOnly pages is windowed — every document-level block comes back whole on each request, so walking a long document repeats them. An unreadable value for either parameter falls back to the first window rather than erroring.
Response 200
{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"parse_version": 1,
"truncated": false,
"truncation_reason": null,
"risk": {"level": "medium", "score": 20},
"signals": [
{"code": "url.link_text_mismatch", "severity": "medium", "count": 2,
"pages": [1, 4], "sample": "\"acme.com\" links to payments-acme.example"},
{"code": "hidden_text.tiny", "severity": "low", "count": 1,
"pages": [1], "sample": "terms apply"}
],
"counts": {
"active_content": 0, "hidden_text": 1, "link_anomaly": 2,
"form_anomaly": 0, "structure_anomaly": 0
},
"document": {
"page_count": 12, "pages_parsed": 12, "pdf_version": "1.7",
"is_encrypted": false, "is_linearized": true,
"has_acroform": false, "has_xfa": false,
"producer": "Acme Billing 4.2", "creator": "Acme Billing"
},
"outline": [
{"level": 1, "title": "Invoice", "page": 1, "dest_kind": "internal", "uri": null}
],
"form_fields": [],
"annotations": [
{"page": 1, "subtype": "Link", "has_action": true, "action_kind": "uri",
"uri": "https://payments-acme.example/pay",
"bbox": [72.0, 690.2, 220.4, 704.0],
"content_length": 0, "flags": 4}
],
"embedded_objects": {
"embedded_files": [], "attachment_count": 0, "javascript_entries": 0,
"open_action_kind": "none", "embedded_font_count": 6,
"xobject_form_count": 1, "external_streams": 0
},
"stats": {"parse_seconds": 3.4, "payload_bytes": 214883, "elements": 1042},
"pages": {
"from_page": 1,
"to_page": 25,
"has_more": false,
"items": [
{
"page_number": 1,
"width": 595.28, "height": 841.89, "rotation": 0,
"text": "Invoice\nDate: 2026-05-11\n…",
"text_truncated": false,
"char_count": 1840,
"blocks": [
{"kind": "text", "bbox": [72.0, 84.0, 523.3, 120.5], "lines": 2, "chars": 41}
],
"headings": [
{"text": "Invoice", "level": 1, "size": 24.0, "bold": true,
"bbox": [72.0, 84.0, 180.6, 112.0]}
],
"tables": [
{"index": 0, "bbox": [72.0, 300.0, 523.3, 480.0], "rows": 6, "cols": 4,
"header": ["Item", "Qty", "Unit", "Total"],
"rows_preview": [["Widget", "2", "120.00", "240.00"]]}
],
"links": [
{"index": 0, "kind": "uri", "uri": "https://payments-acme.example/pay",
"bbox": [72.0, 690.2, 220.4, 704.0], "anchor_text": "acme.com"}
],
"images": [
{"bbox": [72.0, 60.0, 160.0, 100.0], "width": 440, "height": 200,
"colorspace": "DeviceRGB", "bpc": 8, "has_smask": false}
],
"fonts": ["Helvetica-Bold", "Helvetica"],
"flags": {
"has_invisible_text": false, "has_offpage_text": false,
"has_tiny_text": true, "has_text_over_image": false,
"has_low_contrast_text": false
}
}
]
},
"created_at": "2026-05-11T12:00:09Z"
}jsonFailures
404 not_found— no such document, or it belongs to another organization.409 not_ready— status has not reachedcompleted.410 gone— outputs have been retention-deleted.404 no_parse— the document completed but carries no parse: the file was uploaded with an operation that does not parse, or the parse itself failed and was skipped rather than failing the job.
parse_version is stamped on every row. It is 1 today. When the shape changes the number moves, and rows written by an older sandbox keep the version they were written under — so check it before you assume a field exists.
All strings in the payload are stripped of NUL and control characters before storage. Every bbox is [x0, y0, x1, y1] in points. Watch the origin: the page-level boxes — blocks, headings, tables, links, images — are rendering coordinates with the origin at the top left, while the boxes on annotations and form_fields are the raw PDF /Rect, origin at the bottom left. Flip one against the page height before comparing it with the other.
/v1/documents/{id}/summaryAI-generated document summary as HTML. Convenience endpoint for callers that only need the summary and don't want to fetch the full document metadata.
Headers
Authorizationstring · requiredResponse 200
{
"document_id": "550e8400-e29b-41d4-a716-446655440000",
"html": "<p>Invoice from Acme Corp dated 2026-05-11.</p><ul><li><strong>Total: $1,240.00</strong></li></ul>",
"generated_at": "2026-05-11T12:00:07Z",
"model": "mistral-small-latest"
}json409 not_ready if status is not completed. 404 no_summary if the document had no extractable text. 410 gone if outputs have been retention-deleted.
Account
/v1/balanceCurrent organization balance, current rate, and an estimated-pages-remaining figure.
Headers
Authorizationstring · requiredResponse 200
{
"balance": {"amount": "12.400000", "currency": "USD"},
"estimated_pages_remaining": 1238,
"rate": {
"per_page": {"amount": "0.010000", "currency": "USD", "unit": "page"},
"per_document": {"amount": "0.020000", "currency": "USD", "unit": "document"}
}
}jsonEvery money value on /v1/ is a {amount, currency} object with the amount as a string to six decimal places. estimated_pages_remaining is a conservative display figure — it assumes every page also pays a per-document fee — so do not budget against it.
/v1/usageAppend-only ledger of every balance change — top-ups, charges, refunds, reservations, adjustments. Newest first.
Headers
Authorizationstring · requiredQuery
limitintegercursorstringnext_cursor from the previous response. Omit for the first page.Response 200
{
"data": [
{
"id": "0193c…",
"entry_type": "document_processing_charge",
"document_id": "550e8400-…",
"resource": {"type": null, "id": null, "parent_id": null},
"pages_processed": 12,
"amount": {"amount": "-0.140000", "currency": "USD"},
"description": "Processing charge for job 550e8400-…",
"source": "api",
"created_at": "2026-05-11T12:00:08Z"
},
{
"id": "0193b…",
"entry_type": "reservation",
"document_id": null,
"resource": {
"type": "generator_run",
"id": "7c1f…",
"parent_id": "b9a2…"
},
"pages_processed": 0,
"amount": {"amount": "-0.200000", "currency": "USD"},
"description": "Reservation for generator run 7c1f… (create)",
"source": "generator",
"created_at": "2026-05-11T11:41:02Z"
}
],
"next_cursor": "MjAyNi0wNS0xMVQxMTo0MTowMlp8MDE5M2I…"
}jsonPaging is by cursor, not offset, so entries written while you walk the ledger do not shift rows across page boundaries. Keep requesting with the next_cursor you were handed until it comes back null — that is the last page.
document_id is set for PDF jobs. resource covers everything else: type and id name what the entry paid for, and parent_id is the object you can link a reader to — a generator run's template, say. All three are null on a plain PDF entry.
Entry types: balance_topup, document_processing_charge, reservation, reservation_release, adjustment, refund, x402_payment, legacy_balance_migration. A ninth, generation_charge, is declared in the schema and reserved for a future per-format generator rate — nothing writes it today, so treat an unknown entry_type as a row to display rather than an error.
/v1/healthLiveness probe. Returns 200 if the API and database are reachable, 503 otherwise. No authentication required.
Response 200
{"status": "ok", "database": "ok"}jsonWebhooks
Overview
Instead of polling /v1/documents/{id}, you can register a URL that we will POST to whenever one of your jobs reaches a terminal state.
Configure webhooks from the Webhooks page in your dashboard. Each webhook gets a signing secret (shown once on creation, format whsec_…) used to verify that incoming deliveries actually came from us.
Events:
document.completed— job finished successfullydocument.failed— job errored
Subscribe to a subset of events by listing them in the webhook config, or subscribe to all by leaving that field empty.
Payload shape
Every delivery is a JSON POST with three custom headers:
POST <your-url> HTTP/1.1
Content-Type: application/json
User-Agent: SeguraDoc-Webhook/1.0
SeguraDoc-Event: document.completed
SeguraDoc-Delivery: 1f93e8c6-2b4a-4f3a-b1e8-9f3c0d8b7a5e # dedupe key
SeguraDoc-Signature: t=1715432100,v1=abc123…{
"event": "document.completed",
"delivery_id": "1f93e8c6-2b4a-4f3a-b1e8-9f3c0d8b7a5e",
"document": {
"id": "550e8400-…",
"status": "completed",
"page_count": 12,
"safe_pdf_url": "https://api.seguradoc.com/v1/documents/550e…/safe-pdf",
"ocr_text_url": "https://api.seguradoc.com/v1/documents/550e…/ocr"
}
}jsonFor document.failed, the document object carries error (≤500 chars) instead of the output URLs.
Acceptance contract
Your endpoint must respond with any 2xx status within 10 seconds. The response body is logged for debugging but otherwise ignored. Anything else triggers a retry.
Idempotency
Retries reuse the same SeguraDoc-DeliveryUUID. Dedupe against it so a re-sent delivery doesn't double-process the same logical event.
Verifying signatures
Signatures are HMAC-SHA256(secret, "{t}.{raw_body}"), formatted as t=<unix-seconds>,v1=<hex-digest>. Reject signatures with a timestamp older than 5 minutes — that prevents replay attacks.
Node.js
import crypto from 'node:crypto';
export function verifySeguraDocSignature(req, secret) {
const header = req.header('SeguraDoc-Signature');
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${req.rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
);
}javascriptPython
import hmac, hashlib, time
def verify_seguradoc_signature(header: str, raw_body: bytes, secret: str) -> bool:
try:
parts = dict(p.split('=', 1) for p in header.split(','))
t = int(parts['t'])
sig = parts['v1']
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig)pythonGo
func verifySeguraDocSignature(header string, rawBody []byte, secret string) bool {
var t int64
var v1 string
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 { continue }
switch kv[0] {
case "t": fmt.Sscanf(kv[1], "%d", &t)
case "v1": v1 = kv[1]
}
}
if math.Abs(float64(time.Now().Unix()-t)) > 300 { return false }
h := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(h, "%d.", t)
h.Write(rawBody)
return hmac.Equal(h.Sum(nil), []byte(v1))
}goRetry policy
A non-2xx response (or no response within 10s) schedules a retry:
- 1st retry: 6 minutes after the failure
- 2nd retry: 12 minutes after the 1st retry
- 3rd retry: 24 minutes after the 2nd retry
After 4 failed attempts total, the delivery is marked terminal. After 5 consecutive failed deliveries, the webhook is auto-disabled and won't fire for new events until you re-enable it from the dashboard.
Terminal deliveries can be replayed manually from the deliveries modal in the dashboard. Replays carry the same SeguraDoc-Delivery UUID as the original — so your dedupe logic still applies.
x402 — per-request payments
Overview
x402 lets AI agents pay for a single PDF sanitization request without creating an account or pre-loading a balance. It implements the Coinbase x402 protocol — HTTP 402 responses with on-chain USDC settlement on Base mainnet.
The flow:
- Agent calls
POST /v1/x402/documentswith noX-Paymentheader. - Server returns
402 Payment Requiredwith anacceptsarray describing each price band. - Agent picks a band, signs an EIP-3009 transfer authorization for the band's exact USDC amount, base64-encodes the payment payload.
- Agent retries the same POST with
X-Payment: <base64>and the file body. - Server verifies the authorization, counts pages, settles the payment on-chain, queues the job, returns
202with anaccess_token. - Agent polls
GET /v1/x402/documents/{id}?token=…for completion.
/v1/x402/bandsPublic — discover the current price bands without triggering a 402.
Response 200
{
"x402Version": 1,
"network": "base",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"asset_decimals": 6,
"pay_to": "0xYourPaymentAddress",
"bands": [
{"name": "small", "max_pages": 10, "price_atomic": "200000", "price_display": "0.200000", "description": "Up to 10 pages — $0.20"},
{"name": "medium", "max_pages": 50, "price_atomic": "750000", "price_display": "0.750000", "description": "Up to 50 pages — $0.75"},
{"name": "large", "max_pages": 150, "price_atomic": "2000000", "price_display": "2.000000", "description": "Up to 150 pages — $2.00"}
]
}json/v1/x402/quotePage-count an uploaded PDF and return the smallest band that fits — without payment or job creation. Use this before signing an x402 authorization so you know which band amount to commit to.
Body (multipart/form-data)
filebinary · requiredResponse 200
{
"page_count": 7,
"band": {
"name": "small",
"max_pages": 10,
"price_atomic": "200000",
"price_display": "0.200000",
"description": "Up to 10 pages — $0.20"
},
"currency": "USDC",
"network": "base"
}jsonReturns 400 if the document exceeds the largest band. Rate-limited at 10 requests/min per IP.
/v1/x402/documentsPay-per-request PDF processing. Without X-Payment header, returns 402 with price requirements. With a valid X-Payment, processes the document and returns an access_token for subsequent reads.
Headers
X-Paymentstring · requiredBody (multipart/form-data)
filebinary · requiredResponse 402 (no payment)
{
"x402Version": 1,
"accepts": [
{
"scheme": "exact",
"network": "base",
"maxAmountRequired": "200000",
"resource": "https://api.seguradoc.com/v1/x402/documents",
"description": "Up to 10 pages — $0.20",
"mimeType": "application/json",
"payTo": "0xYourPaymentAddress",
"maxTimeoutSeconds": 60,
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"extra": {"name": "USD Coin", "version": "2", "band": "small", "maxPages": 10}
}
],
"error": "X-Payment header required to access this endpoint."
}jsonResponse 202 (paid + processing)
{
"id": "550e8400-…",
"status": "pending",
"page_count": 7,
"band": "small",
"amount_paid": {
"atomic": "200000",
"display": "0.200000",
"currency": "USDC",
"network": "base",
"transaction": "0x4f3a…"
},
"access_token": "0x_token_…",
"outputs": {
"status_url": "https://api.seguradoc.com/v1/x402/documents/550e…?token=…",
"safe_pdf_url": "https://api.seguradoc.com/v1/x402/documents/550e…/safe-pdf?token=…",
"ocr_text_url": "https://api.seguradoc.com/v1/x402/documents/550e…/ocr?token=…"
},
"created_at": "2026-05-11T12:00:00Z"
}jsonIf the actual page count exceeds the paid band's max_pages, the request fails with 400 page_limit_exceeded without settling — your signed authorization expires unused. Use /v1/x402/quote first to avoid this.
On-chain settlements are final — there is no refund path if processing fails after settlement. Rate-limited at 10 requests/min per IP.
Document generator
Overview
Not part of the bearer-token API.
These routes are session-authenticated — they answer the cookie your browser gets when you sign in, and reject an sk_live_ key. An agent holding an API key cannot drive the generator. They are documented here because they spend the same organization balance and land on the same /v1/usage ledger the rest of this page describes.
The generator writes a document from a prompt and renders it. Ask for one, and you get back a template — the document — and a run, one attempt at producing it. Revising a template starts another run against the same template and bumps its revision.
Work is asynchronous. Create and revise answer 202: the row exists and the money is held, but the file does not exist yet. Poll the run.
Response envelope
Every generator route answers with its own envelope, not the /v1/ one:
// success
{"status": "success", "data": { … }}
// failure
{
"status": "error",
"error": {"code": "insufficient_balance", "message": "…"},
"error_message": "…"
}jsonerror_message is a plain-string copy of error.message, kept for one release so an older client build does not render an object. Read error.code. Machine-readable detail — required_micro_usd, active_run_id — rides inside error alongside the code.
Money shape
Generator money is a superset of the /v1/ shape, carrying the integer you should compute against as well as the string you should display:
{"micro_usd": 200000, "display": "0.20", "currency": "USD"}jsonRules worth knowing up front
- One active run per template. The database enforces it, not a read-then-write check, so a second request loses cleanly with
409 generation_in_progressand the id of the run that holds the template. - Price is flat: $0.20 to create, $0.20 to revise, whatever the format. Held before the work is queued, charged on success, refunded in full on failure.
- A stuck run refunds itself. A run that is still not finished after 20 minutes is failed and refunded by a sweeper, so a broker outage does not strand your money.
- Formats:
docxandodt.odsis declared and rendered but switched off — ask for it and you get400 unsupported_format. Do not hard-code the list; read capabilities. - Ownership is invisible. Somebody else's template is a
404, never a403.
/generator/capabilities/Formats, styles, limits and prices — everything a create form needs to build itself. Read this rather than hard-coding the lists.
Response 200
{
"status": "success",
"data": {
"formats": [
{"id": "docx", "label": "Word document", "extension": "docx", "enabled": true},
{"id": "odt", "label": "OpenDocument text", "extension": "odt", "enabled": true},
{"id": "ods", "label": "OpenDocument spreadsheet", "extension": "ods", "enabled": false}
],
"styles": [
{"id": "formal", "label": "Formal"},
{"id": "professional", "label": "Professional"},
{"id": "modern", "label": "Modern"},
{"id": "casual", "label": "Casual"},
{"id": "minimalist", "label": "Minimalist"},
{"id": "corporate", "label": "Corporate"},
{"id": "creative", "label": "Creative"},
{"id": "academic", "label": "Academic"},
{"id": "classic", "label": "Classic"}
],
"limits": {"prompt_characters": 4000, "active_runs_per_template": 1},
"pricing": {
"create": {"micro_usd": 200000, "display": "0.20", "currency": "USD"},
"revise": {"micro_usd": 200000, "display": "0.20", "currency": "USD"}
}
}
}jsonA format with enabled: false is listed so you can show it greyed out. Posting it is a 400.
/generator/quote/Price one prospective run against the current balance. Creates nothing and holds nothing.
Body (application/json)
operationstring · requiredcreate, revise.formatstring · requireddocx or odt today.Response 200
{
"status": "success",
"data": {
"amount": {"micro_usd": 200000, "display": "0.20", "currency": "USD"},
"available_balance":{"micro_usd": 1240000, "display": "1.24", "currency": "USD"},
"balance_after": {"micro_usd": 1040000, "display": "1.04", "currency": "USD"},
"can_afford": true
}
}jsonbalance_after is floored at zero — a balance cannot go negative, so a run you cannot afford projects to 0.00 rather than to a negative figure. Read can_afford, not the sign of anything.
Failures: 400 invalid_request (body is not a JSON object, or the operation is neither create nor revise), 400 unsupported_format, 429 rate_limited.
/templates/generate/Create a template from a prompt and queue its first run. Revisions go to the runs route instead — this one does not accept a template id.
Body (application/json)
promptstring · requiredtypestringdocx.stylestringprofessional.idempotency_keystringResponse 202
{
"status": "success",
"data": {
"template_id": "b9a2c1de-…",
"run_id": "7c1f0a44-…",
"run_status": "pending",
"revision": 1,
"reserved_amount": {"micro_usd": 200000, "display": "0.20", "currency": "USD"},
"balance_after_reservation": {"micro_usd": 1040000, "display": "1.04", "currency": "USD"}
}
}jsonThe title is seeded from the first 50 characters of the prompt and replaced by the model's own title when the run completes.
Idempotency
Send a key you generate per attempt. Repeating a request with the same key returns 200 — not 202 — with the original run's payload and idempotent_replay: true added. Nothing is charged twice. Reusing a key with a different prompt is 409 idempotency_conflict; rotate the key rather than retrying.
Failures
400—prompt_required,prompt_too_long,unsupported_format,unsupported_style,invalid_request.402 insufficient_balance, carryingrequired_micro_usdandavailable_micro_usd. Nothing is left behind — the template and prompt roll back with it, so a top-up and a retry start clean.409 idempotency_conflict.429 rate_limited— 6 per minute.500 internal_error.
/templates/{template_id}/runs/Revise an existing template. Same 202 body as create, with the next revision number.
Body (application/json)
promptstring · requiredidempotency_keystringSending type or style here is 400 invalid_request: a revision inherits both from the template it revises. To change either, create a new template.
Revision numbers come from the highest run the template has ever had, not from its current revision — so a failed attempt does not hand its number out twice.
Failures
404 template_not_found— no such template, or it is not yours, or it is deleted.409 generation_in_progress, carryingactive_run_id. Poll that run, then try again.- Everything create can answer:
400prompt and request errors,402 insufficient_balance,409 idempotency_conflict,429 rate_limited,500 internal_error.
/generator/runs/{run_id}/Poll one run. This is what a progress bar reads.
Response 200
{
"status": "success",
"data": {
"id": "7c1f0a44-…",
"template_id": "b9a2c1de-…",
"operation": "create",
"revision": 1,
"status": "rendering",
"progress": {"stage": "rendering", "percent": 80},
"billing": {
"state": "reserved",
"reserved_micro_usd": 200000,
"charged_micro_usd": 0,
"refunded_micro_usd": 0,
"reserved": {"micro_usd": 200000, "display": "0.20", "currency": "USD"}
},
"error": null,
"created_at": "2026-05-11T11:41:02.101Z",
"started_at": "2026-05-11T11:41:03.400Z",
"completed_at": null
}
}jsonStatuses: pending, generating, rendering, completed, failed, cancelled. The first three are the active ones — a template with a run in any of them refuses a second.
progress.percent is fixed per stage — 0, 40, 80, 100 — not derived from elapsed time. It therefore never runs backwards on a retry and never sits at 97% telling you a lie about how long is left.
billing.state is one of none, reserved, charged, refunded. error is null until something goes wrong, then {code, message}.
On a failed run the code is always generation_failed — it does not narrow further, so branch on the run's status and show the message. The message is one of four fixed sentences, written for a reader: the description could not be turned into a document, no matching section was found for a revision, rendering failed, or generation stopped before it finished. The underlying exception is kept on the server and never serialised.
Failure: 404 run_not_found.
/templates/The caller's templates, newest first. One entry per card in a grid.
Query
pageintegerper_pageintegerResponse 200
{
"status": "success",
"data": {
"templates": [
{
"id": "b9a2c1de-…",
"title": "Consulting agreement",
"type": "docx",
"format": "docx",
"style": "professional",
"status": "completed",
"current_revision": 2,
"active_run": null,
"thumbnail_url": "/templates/b9a2c1de-…/preview/page/1/?v=9f3c0d8b",
"created_at": "2026-05-11T11:41:02.101Z",
"completed_at": "2026-05-11T11:41:44.900Z",
"has_file": true
}
],
"page": 1,
"per_page": 20,
"total_pages": 3,
"total_count": 47,
"has_next": true,
"has_previous": false
}
}jsonTemplate status is pending, generating, completed or failed — a coarser set than a run's. active_run is {id, status} while something is running and null otherwise. type and format carry the same value; format is the one to read.
/templates/{template_id}One template in full: its artifacts, preview pages, every run against it, and the prompts that produced them.
Response 200
{
"status": "success",
"data": {
"id": "b9a2c1de-…",
"title": "Consulting agreement",
"type": "docx", "format": "docx", "style": "professional",
"status": "completed",
"current_revision": 2,
"active_run": null,
"pages_count": 3,
"error_message": null,
"created_at": "2026-05-11T11:41:02.101Z",
"completed_at": "2026-05-11T11:44:20.010Z",
"thumbnail_url": "/templates/b9a2c1de-…/preview/page/1/?v=9f3c0d8b",
"artifacts": [
{
"id": "3d51…",
"kind": "document",
"format": "docx",
"filename": "Consulting agreement.docx",
"size_bytes": 24188,
"download_url": "/templates/b9a2c1de-…/artifacts/3d51…/download/"
}
],
"download_url": "/templates/b9a2c1de-…/artifacts/3d51…/download/",
"preview_pages": [
{"page_number": 1, "url": "/templates/b9a2c1de-…/preview/page/1/?v=9f3c0d8b"},
{"page_number": 2, "url": "/templates/b9a2c1de-…/preview/page/2/?v=9f3c0d8b"},
{"page_number": 3, "url": "/templates/b9a2c1de-…/preview/page/3/?v=9f3c0d8b"}
],
"runs": [
{
"id": "9e77…", "revision": 2, "operation": "revise", "status": "completed",
"prompt": "Add a termination clause",
"billing": {
"state": "charged", "reserved_micro_usd": 0,
"charged_micro_usd": 200000, "refunded_micro_usd": 0,
"reserved": {"micro_usd": 0, "display": "0.00", "currency": "USD"}
},
"error": null,
"created_at": "2026-05-11T11:43:50.000Z",
"started_at": "2026-05-11T11:43:51.100Z",
"completed_at": "2026-05-11T11:44:20.010Z"
}
],
"prompt_history": [
{"id": "aa10…", "text": "A consulting agreement for a 6-month engagement",
"timestamp": "2026-05-11T11:41:02.101Z", "is_initial": true}
]
}
}jsonartifacts lists documents only — preview pages have their own block and their own route. download_url is a shortcut to the first document. runsis the revision history, newest revision first. The template's internal specification is not served.
URLs in this payload are paths, not absolute. Resolve them against the API base URL.
DELETE /templates/{template_id}
Same path, DELETE method. Removes the document, the preview pages and the intermediate render from disk, and marks the template deleted. Answers 200 with {"status": "success", "message": "Template deleted successfully"} — note that one is a message, not a data block.
The row itself stays: its runs and their ledger entries are the record of money that has already moved. Deleting while something is running is refused with 409 generation_in_progress.
Failure on both methods: 404 template_not_found.
/templates/{template_id}/artifacts/{artifact_id}/download/Download one artifact of a template. Take the URL from the template payload rather than building it.
Response 200
The file itself, typed by format: application/vnd.openxmlformats-officedocument.wordprocessingml.document for docx, application/vnd.oasis.opendocument.text for odt, application/vnd.oasis.opendocument.spreadsheet for ods, image/png for a preview page.
Content-Disposition: attachment; filename="Consulting agreement.docx";
filename*=UTF-8''Consulting%20agreement.docx
Cache-Control: private, max-age=300
ETag: "9f3c0d8b-3d51…"Caching is private on purpose: these are one customer's documents, and a shared proxy told it could cache them would hand one tenant's pages to the next. The ETag is built from the template's current preview token, so a revision invalidates every copy a browser is holding. Send If-None-Match and you get a 304 when nothing has changed.
Failures: 404 template_not_found, 404 artifact_not_found — the latter also covers a file that has gone missing under us.
/templates/{template_id}/preview/page/{page_number}/A PNG of one page of the rendered document. 1-based.
Query
vstringResponse 200
image/png, with the same private, max-age=300 and ETag handling as a download.
A page number below 1, or above the template's pages_count, is 404 artifact_not_found. Previews exist only for completed renders.
Error codes
The generator's codes are a closed set. Branch on them; treat anything outside the set as a generic failure rather than crashing.
| code | HTTP | when |
|---|---|---|
| invalid_request | 400 | Body is not a JSON object, bad pagination, or a revision that tried to change type or style |
| prompt_required | 400 | Prompt missing or whitespace only |
| prompt_too_long | 400 | Prompt over 4000 characters |
| unsupported_format | 400 | Format is unknown or switched off |
| unsupported_style | 400 | Style is not one of the nine |
| insufficient_balance | 402 | Balance does not cover the run. Carries required_micro_usd and available_micro_usd |
| template_not_found | 404 | No such template, not yours, or deleted |
| run_not_found | 404 | No such run, or not yours |
| artifact_not_found | 404 | No such artifact, or the file is gone from disk |
| generation_in_progress | 409 | A run already holds this template. Carries active_run_id |
| idempotency_conflict | 409 | The key was used for a different prompt or a different template |
| rate_limited | 429 | 6 per minute per account on quote, create and revise. Carries retry_after_seconds |
| internal_error | 500 | We could not start the generation |
| generation_failed | — | Not an HTTP failure: the code carried on a run that failed after it was accepted |
A run that fails after it was accepted produces no HTTP error — the request already returned 202. Poll the run instead: its status goes to failed, error.code reads generation_failed, and error.message says which of the four failures it was. Every one of them refunds the hold in full.
Questions or feedback? Email [email protected].
This documentation reflects the API surface as of the v1 release. Breaking changes will be announced in advance; backward-compatible additions can land at any time.