SeguraDoc API

Transform untrusted PDFs into safe, inert, OCR-searchable documents over a JSON HTTP API.

v1
REST
USD-denominated
x402-compatible

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-14b vision 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.com

Content 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-pdf
bash

Pricing & 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_release ledger entry).
  • Additional charge: actual > reserved → balance debited the difference (adjustment ledger entry).
  • Job fails: full refund (reservation_release with full_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
codeHTTPwhen
invalid_file400Missing field, unreadable, bad magic bytes
unsupported_file_type400Extension is not .pdf
invalid_operation400Unknown value for the operation parameter
file_too_large413Above API_MAX_UPLOAD_BYTES (default 100 MB)
page_limit_exceeded400Above API_MAX_PAGE_COUNT (default 500)
insufficient_balance402Balance < reservation amount
not_found404Document doesn’t exist or belongs to another org
no_parse404Document completed but carries no structured parse
no_summary404Document completed but carries no AI summary
not_ready409Output requested before status reached completed
gone410Outputs have been retention-deleted
rate_limited429Rate cap exceeded — counted per account when the caller is signed in, per client IP otherwise
upload_failed500Disk write error on our side
processing_failed500Unexpected 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."}
json

Branch 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 / min
  • POST /v1/x402/quote — 10 / min
  • POST /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: null in 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 summary field in the GET /v1/documents/{id} response.
  • Dedicated endpointGET /v1/documents/{id}/summary if you only need the summary text and not the rest of the document metadata.
  • x402 pathGET /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"
}
json

Rendering 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();
javascript

Limitations

  • 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 the is_encrypted / is_linearized / has_acroform / has_xfa flags. Files that declare their own page labels also get a page_labels array.
  • outline — the bookmark tree flattened, each entry with level (1–6), title, target page, a dest_kind of internal / uri / launch / other, and the uri when there is one.
  • form_fields — name, resolved type, required and readonly, whether a value is present and how long it is (never the value itself), the action_kind and action_target attached 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:

codeseverityraised when
active_content.javascripthighJavaScript in the document name tree, an open action, an annotation, or a form field
active_content.launch_actionhighAn action that asks the reader to run an external program
active_content.open_actionmediumThe document does something other than go to a page when opened
active_content.additional_actionsmediumAn /AA entry on the catalog, a page, or a field — actions on focus, blur, page open
active_content.embedded_filemediumThe document carries attachments
hidden_text.invisible_render_modehighText drawn in render mode 3, which paints nothing
hidden_text.covered_by_imagehighText painted over by an image drawn after it
hidden_text.offpagemediumText placed outside the page box
hidden_text.low_contrastmediumText the same colour as the fill behind it
hidden_text.tinylowText set below 1.5pt
url.homoglyphhighA host name mixing scripts — Latin and Cyrillic in one label
url.obfuscatedmediumA dangerous scheme, or an authority hiding the real host behind an @
url.link_text_mismatchmediumThe anchor text names one domain, the link goes to another
url.shortenerlowA known link shortener, which hides the destination
form.suspicious_actionhighA field that runs JavaScript, imports data, or submits off-site
form.xfa_presentmediumThe AcroForm carries an XFA packet
structure.external_streammediumA stream whose content lives in another file
structure.encryptedinfoThe document is encrypted
structure.page_too_complexinfoA 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"
}
json

count 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 0
  • low — 1 to 14
  • medium — 15 to 44
  • high — 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

POST/v1/documents

Upload a PDF for processing. Reserves balance immediately; processing happens asynchronously.

Headers

Authorizationstring · required
Bearer sk_live_…

Body (multipart/form-data)

filebinary · required
The PDF to process. Max 100 MB, max 500 pages.
operationstring
Default full. 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_passwordstring
Required if the PDF is encrypted.

Response 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"
}
json

Example

curl -X POST https://api.seguradoc.com/v1/documents \
  -H "Authorization: Bearer $KEY" \
  -F '[email protected]' \
  -F 'operation=full'
bash
GET/v1/documents/{id}

Fetch the current status, final cost, and output URLs of a document job.

Headers

Authorizationstring · required
Bearer sk_live_…

Response 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
}
json

Possible 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.

GET/v1/documents/{id}/safe-pdf

Stream 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 · required
Bearer sk_live_…

Response 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.

GET/v1/documents/{id}/ocr

Return per-page extracted text. Image-based PDFs go through Mistral OCR; text-native PDFs use the sandbox extraction.

Headers

Authorizationstring · required
Bearer sk_live_…

Response 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
GET/v1/documents/{id}/parse

The 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 · required
Bearer sk_live_…

Query

from_pageinteger
First page of the window. 1-based, inclusive. Default 1.
to_pageinteger
Last page of the window, inclusive. Clamped to at most 25 pages from from_page.

Only 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"
}
json

Failures

  • 404 not_found — no such document, or it belongs to another organization.
  • 409 not_ready — status has not reached completed.
  • 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.

GET/v1/documents/{id}/summary

AI-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 · required
Bearer sk_live_…

Response 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"
}
json

409 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

GET/v1/balance

Current organization balance, current rate, and an estimated-pages-remaining figure.

Headers

Authorizationstring · required
Bearer sk_live_…

Response 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"}
  }
}
json

Every 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.

GET/v1/usage

Append-only ledger of every balance change — top-ups, charges, refunds, reservations, adjustments. Newest first.

Headers

Authorizationstring · required
Bearer sk_live_…

Query

limitinteger
Max rows. Default 50, max 200.
cursorstring
Opaque next_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…"
}
json

Paging 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.

GET/v1/health

Liveness probe. Returns 200 if the API and database are reachable, 503 otherwise. No authentication required.

Response 200

{"status": "ok", "database": "ok"}
json

Webhooks

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 successfully
  • document.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"
  }
}
json

For 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),
  );
}
javascript

Python

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)
python

Go

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))
}
go

Retry 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:

  1. Agent calls POST /v1/x402/documents with no X-Payment header.
  2. Server returns 402 Payment Required with an accepts array describing each price band.
  3. Agent picks a band, signs an EIP-3009 transfer authorization for the band's exact USDC amount, base64-encodes the payment payload.
  4. Agent retries the same POST with X-Payment: <base64> and the file body.
  5. Server verifies the authorization, counts pages, settles the payment on-chain, queues the job, returns 202 with an access_token.
  6. Agent polls GET /v1/x402/documents/{id}?token=… for completion.
GET/v1/x402/bands

Public — 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
POST/v1/x402/quote

Page-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 · required
The PDF to quote. Discarded after page-counting.

Response 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"
}
json

Returns 400 if the document exceeds the largest band. Rate-limited at 10 requests/min per IP.

POST/v1/x402/documents

Pay-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 · required
Base64-encoded JSON payment payload (signed EIP-3009 transfer authorization). Without it, you get 402.

Body (multipart/form-data)

filebinary · required
The PDF to process.

Response 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."
}
json

Response 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"
}
json

If 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": "…"
}
json

error_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"}
json

Rules 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_progress and 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: docx and odt. ods is declared and rendered but switched off — ask for it and you get 400 unsupported_format. Do not hard-code the list; read capabilities.
  • Ownership is invisible. Somebody else's template is a 404, never a 403.
GET/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"}
    }
  }
}
json

A format with enabled: false is listed so you can show it greyed out. Posting it is a 400.

POST/generator/quote/

Price one prospective run against the current balance. Creates nothing and holds nothing.

Body (application/json)

operationstring · required
One of create, revise.
formatstring · required
An enabled format id — docx 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
  }
}
json

balance_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.

POST/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 · required
What the document should be. Up to 4000 characters; whitespace-only counts as missing.
typestring
Format id. Default docx.
stylestring
Style id. Default professional.
idempotency_keystring
Up to 64 characters. Optional but strongly recommended — see below.

Response 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"}
  }
}
json

The 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

  • 400prompt_required, prompt_too_long, unsupported_format, unsupported_style, invalid_request.
  • 402 insufficient_balance, carrying required_micro_usd and available_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.
POST/templates/{template_id}/runs/

Revise an existing template. Same 202 body as create, with the next revision number.

Body (application/json)

promptstring · required
The change to make. Same 4000-character limit.
idempotency_keystring
Up to 64 characters. Behaves exactly as on create.

Sending 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, carrying active_run_id. Poll that run, then try again.
  • Everything create can answer: 400 prompt and request errors, 402 insufficient_balance, 409 idempotency_conflict, 429 rate_limited, 500 internal_error.
GET/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
  }
}
json

Statuses: 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.

GET/templates/

The caller's templates, newest first. One entry per card in a grid.

Query

pageinteger
1-based. Default 1. An out-of-range page is clamped, and the response reports the page actually served.
per_pageinteger
Default 20, max 100.

Response 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
  }
}
json

Template 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.

GET/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}
    ]
  }
}
json

artifacts 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.

GET/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.

GET/templates/{template_id}/preview/page/{page_number}/

A PNG of one page of the rendered document. 1-based.

Query

vstring
The template's current preview token. Carried on the URLs in the template payload; it makes a revision's pages a different URL from the previous revision's.

Response 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.

codeHTTPwhen
invalid_request400Body is not a JSON object, bad pagination, or a revision that tried to change type or style
prompt_required400Prompt missing or whitespace only
prompt_too_long400Prompt over 4000 characters
unsupported_format400Format is unknown or switched off
unsupported_style400Style is not one of the nine
insufficient_balance402Balance does not cover the run. Carries required_micro_usd and available_micro_usd
template_not_found404No such template, not yours, or deleted
run_not_found404No such run, or not yours
artifact_not_found404No such artifact, or the file is gone from disk
generation_in_progress409A run already holds this template. Carries active_run_id
idempotency_conflict409The key was used for a different prompt or a different template
rate_limited4296 per minute per account on quote, create and revise. Carries retry_after_seconds
internal_error500We could not start the generation
generation_failedNot 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.