SymageDocs API User Manual
The SymageDocs API lets you programmatically generate synthetic documents and identities. This manual covers authentication, endpoints, billing, and common workflows.
Python users: Consider the Python SDK (
pip install symagedocs) instead of raw HTTP. It handles authentication, retries, pagination, and polling automatically.
Quick Reference
Base URL: https://symagedocs.ai/api/v1
Auth: Authorization: Bearer sk_live_YOUR_KEY
Rate limit: Per-key, tier-based sustained rate (free: 10 req/s; pro: 30; scale: 100; enterprise: 200) with 3× short burst
Endpoints at a Glance
These are the public Bearer-authenticated endpoints under /api/v1. Webhook management is also Bearer-authenticated, under /api/v1/webhooks — see Webhooks. API-key management is session-authenticated and lives under /api/internal/... — see API Key Management.
| Method | Endpoint | Scope | Description |
|---|---|---|---|
GET | /forms | read | List available forms (filter: ?category=tax) |
GET | /forms/{id} | read | Form details with field definitions |
POST | /generate | generate | Create an async generation job |
GET | /jobs | read | List your jobs (paginated: ?limit=50&cursor=...) |
GET | /jobs/{id} | read | Job status and progress |
GET | /jobs/{id}/download/{fmt} | read | Download output (dataset, csv, or json) |
GET | /jobs/{id}/downloads | read | Presigned download URLs for all job output files |
GET | /jobs/{id}/items | read | List per-item records for a job |
GET | /jobs/{id}/items/{item_id}/download | read | Presigned URLs for one item's files |
POST | /jobs/{id}/cancel | generate | Cancel a running job; refund unspent credits |
POST | /identities | generate | Generate raw identities (JSON only, no forms) |
GET | /account/balance | account | Credit usage totals |
GET | /account/usage | account | Usage summary (filter: ?days=30) |
POST | /tabular/parse | generate | NL description to column schema (LLM-powered) |
POST | /tabular/generate | generate | Generate tabular data from column schema |
GET | /tabular/{id}/status | read | Tabular job progress and ETA |
GET | /tabular/{id}/download/{fmt} | read | Download tabular output (csv or json) |
GET | /pricing/rates | — | Get current credit rate constants (no auth) |
POST | /pricing/estimate | — | Estimate credit cost for a job (no auth) |
GET | /jobs/size-rates | — | Get calibrated bundle-size rate constants (no auth) |
POST | /jobs/size-estimate | — | Estimate compressed bundle download size (no auth) |
GET | /health | — | Liveness probe (no auth) |
Typical Workflow (Jobs)
1. GET /forms → pick a form_id
2. POST /generate {form_id, quantity} → get job_id
3. GET /jobs/{job_id} → poll until status = "completed"
4. GET /jobs/{job_id}/download/dataset → download all artifacts as a single zip
— or —
GET /jobs/{job_id}/downloads → get presigned URLs for direct S3 download
Typical Workflow (Per-item training data)
1. GET /forms → pick a form_id
2. POST /generate {form_id, quantity, output_formats: ["pdf_typed", "bio"]}
↑ optional: send `Idempotency-Key: <key>` header for retry-safety
3. GET /jobs/{job_id} → poll until status = "completed"
4. GET /jobs/{job_id}/items → enumerate items (paginated)
5. GET /jobs/{job_id}/items/{id}/download → presigned URLs per item
Typical Tabular Workflow
1. POST /tabular/parse {prompt} → get column schema
2. POST /tabular/generate {columns, qty} → get job_id (status = "processing")
3. GET /tabular/{job_id}/status → poll until status = "completed"
4. GET /tabular/{job_id}/download/csv → download results
# Tabular jobs use status = "processing" while in flight (not "pending"/"generating",
# which apply only to /jobs).
Key Scopes
| Scope | What it allows |
|---|---|
generate | Create jobs, generate identities, tabular parse/generate |
read | List forms, view jobs, download output, tabular status/download |
account | View balance and usage |
Credit Cost (per document)
| Output | Formula |
|---|---|
| Typed PDF | 20 + ceil(max(fields - 25, 0) / 25) * 2 |
| Handwritten PDF | 40 + ceil(max(fields - 25, 0) / 25) * 4 |
| Filled PDF | 12 + ceil(max(fields - 25, 0) / 25) * 1.2 |
| Tabular (CSV/JSON) | ceil(rows/50 + ceil(rows × max(cols−10, 0) / 500)) |
JSON and CSV are always free. Every dataset zip includes
ground_truth/json/andground_truth/data.csvat no extra cost. They are not selectable inoutput_formats.
Response Envelope
{"data": ..., "meta": {"request_id": "uuid", ...}}
Common Errors
| Code | Meaning |
|---|---|
| 401 | Invalid or revoked API key |
| 402 | Insufficient credits |
| 403 | Key missing required scope |
| 404 | Resource not found |
| 409 | Job not yet completed (for downloads) |
| 422 | Invalid parameter or unservable request |
| 429 | Rate limit exceeded |
Quick Start
1. Create an API Key
Log in to the SymageDocs web application and navigate to Account > API Keys. Click Create Key, give it a name, and copy the key immediately — it will not be shown again.
Your key looks like: sk_live_a1b2c3d4e5f6... (56 characters total).
2. Make Your First Request
curl https://symagedocs.ai/api/v1/forms \
-H "Authorization: Bearer sk_live_YOUR_KEY_HERE"
3. Generate a Document
# Create a generation job
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"form_id": "irs_w2_2025",
"quantity": 10,
"output_formats": ["pdf_typed"]
}'
# Poll for completion (replace JOB_ID with the job_id from above)
curl https://symagedocs.ai/api/v1/jobs/JOB_ID \
-H "Authorization: Bearer sk_live_YOUR_KEY_HERE"
# Download results when status is "completed" — every artifact in one zip
curl https://symagedocs.ai/api/v1/jobs/JOB_ID/download/dataset \
-H "Authorization: Bearer sk_live_YOUR_KEY_HERE" \
-o output.zip
Authentication
Every API request must include your key in the Authorization header:
Authorization: Bearer sk_live_YOUR_KEY_HERE
No username or password is needed — your identity is embedded in the key itself.
API Key Scopes
Keys can be created with specific scopes to limit what they can do:
| Scope | Grants access to |
|---|---|
generate | Create generation jobs, generate raw identities |
read | List forms, check job status, download outputs |
account | View credit balance and usage statistics |
By default, new keys have all three scopes. You can create restricted keys (e.g., a read-only key for monitoring) through the web UI or API.
Key Management
You manage keys through the web UI at Account > API Keys, or programmatically:
| Action | Description |
|---|---|
| Create | Generates a new key. The raw key is shown once — copy it immediately. |
| List | Shows all your keys with their prefix, name, scopes, and last-used date. Full keys are never shown. |
| Revoke | Permanently disables a key. Takes effect immediately. Cannot be undone. |
| Rotate | Creates a new key and revokes the old one in a single operation. The new key inherits the old key's name and scopes. |
Rate Limiting
The API enforces a per-key rate limit determined by the key's tier. The sustained rate is the token-bucket refill rate; a fresh client can issue up to 3× the sustained rate as an initial burst before throttling kicks in.
| Tier | Sustained | Burst |
|---|---|---|
| Free | 10 requests/second | 30 requests |
| Pro | 30 requests/second | 90 requests |
| Scale | 100 requests/second | 300 requests |
| Enterprise | 200 requests/second | 600 requests |
If you exceed your tier's limit, the API returns 429 Too Many Requests.
The following headers accompany every rate-limited (Bearer-authenticated)
response — successful or 429 — so clients can back off intelligently:
| Header | Meaning |
|---|---|
Retry-After | Seconds to wait before retrying (rounded up to at least 1) |
X-RateLimit-Limit | Your tier's burst capacity (maximum requests in the current window) |
X-RateLimit-Remaining | Remaining budget within the current window (always 0 on a 429 response) |
X-RateLimit-Reset | Unix timestamp (seconds). On a 429 response this is when the next request slot is expected to become available, rounded up to the next whole second (so the slot may open up to a second earlier than advertised); on successful responses it is a nominal one-second-forward timestamp, not a slot-availability time |
Response Format
Success Responses
All endpoints return a consistent envelope:
{
"data": { ... },
"meta": {
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"count": 5,
...
}
}
datacontains the response payload (object or array depending on the endpoint).metacontains request metadata.request_idis always present and useful for support inquiries.
Error Responses
Errors follow a standard format. Every error body carries the same information
under two keys — error (preferred) and detail (retained for older clients):
{
"error": {
"code": "unknown_form",
"message": "Unknown form: irs_w99_2024"
},
"detail": {
"code": "unknown_form",
"message": "Unknown form: irs_w99_2024",
"status": 400
}
}
Validation failures (code: "validation_error", status 422) additionally
carry error.details.errors, the per-field list of what failed.
HTTP Status Codes
| Code | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad request (rejected by the endpoint itself) |
| 401 | Authentication failed (invalid or revoked key) |
| 402 | Insufficient credits |
| 403 | Forbidden (missing required scope) |
| 404 | Resource not found |
| 409 | Conflict (e.g., downloading from an incomplete job) |
| 422 | Invalid query/body parameter, or an unservable request |
| 429 | Rate limit exceeded |
Endpoints
Forms Catalog
List Forms
GET /api/v1/forms
GET /api/v1/forms?category=tax
GET /api/v1/forms?family=irs_w2
Returns all available forms. Optionally filter by category.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
category | string | — | Filter by form category (e.g., tax, healthcare, business) |
family | string | — | Filter by form family (e.g., irs_w2, irs_f1040) |
limit | integer | — | Maximum number of forms to return (≥ 1) |
Scope required: read
Response fields per form:
| Field | Type | Description |
|---|---|---|
id | string | Unique form identifier (use this in generation requests) |
name | string | Human-readable form name |
category | string | Form category (e.g., tax, healthcare, business) |
year | integer | Tax year or form version year |
family | string | Form family (e.g., 1040, w2) |
entity_type | string | Entity type (individual, business, healthcare) |
credit_cost | integer | Credits per document (at default PDF output) |
field_count | integer | Number of fields on the form |
Example:
curl https://symagedocs.ai/api/v1/forms?category=tax \
-H "Authorization: Bearer sk_live_YOUR_KEY"
Get Form Details
GET /api/v1/forms/{form_id}
Returns full details for a specific form, including all field definitions.
Scope required: read
Additional response fields:
| Field | Type | Description |
|---|---|---|
version | string | Form definition version |
page_count | integer | Number of pages in the form |
fields | array | List of field definitions |
fields[].id | string | Field identifier |
fields[].label | string | Human-readable field label |
fields[].type | string | Field type (e.g., text, enum, ssn, currency) |
fields[].page | integer | Page number the field appears on |
fields[].semantic_concept | string | Dotted semantic concept the field carries (e.g., healthcare.patient.name, person.ssn). Present only on annotated fields. |
fields[].structural_role | string | Document structural role (e.g., answer, question, header, label). Present only when set. |
fields[].entity_role | string | Which entity the field describes (e.g., primary_subject, employer, provider). Present only when set. |
The three semantic keys (semantic_concept, structural_role, entity_role) are omitted entirely when a field has no value for them, so unannotated fields carry only id, label, type, and page. An agent can drive a fill scenario by listing a form's fields and selecting every field whose semantic_concept matches a prefix — for example, all healthcare.patient.* fields to prefill patient demographics.
Example:
curl https://symagedocs.ai/api/v1/forms/irs_w2_2025 \
-H "Authorization: Bearer sk_live_YOUR_KEY"
Generation
Create a Generation Job
POST /api/v1/generate
Creates an asynchronous generation job. The job runs in the background — poll the job status endpoint to track progress.
Scope required: generate
Request body:
Provide exactly one of form_id or form_ids (not both).
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
form_id | string | conditional | — | Single form to generate. Mutually exclusive with form_ids. |
form_ids | string[] | conditional | — | List of form IDs to generate coherently for the same identity (e.g., W-2 + 1040 with matching wages, SSN, and name). Mutually exclusive with form_id. |
quantity | integer | no | 1 | Number of identities to generate (1–10,000,000). Each identity produces one document per form. Large jobs are routed to the bulk worker and may take minutes-to-hours; poll /jobs/{job_id} for status. |
output_formats | string[] | no | ["pdf_typed"] | Output formats (see below) |
config | object | no | null | Generation configuration overrides |
seed | integer | no | null | Seed for reproducible output |
ink_color | string | no | "blue" | Ink color for handwritten output: black, blue, or red |
ink_color_distribution | object | no | null | Distribution of ink colors across documents. Keys are color names, values are integer weights that must sum to 100. Example: {"blue": 60, "black": 30, "red": 10}. Overrides ink_color when provided. |
writer_consistency | string | no | "per_document" | Writer consistency: per_document or per_field |
realism_level | string | no | "high" | Deprecated. Accepted for backward compatibility; ignored. All handwritten output is rendered at high realism. |
webhook_url | string | no | null | Deprecated. Inline webhook registration; pass a URL to receive job.completed/job.failed notifications (max 2,048 chars). Still works but will be removed in a future major version — register webhooks via POST /api/v1/webhooks instead. See Webhooks. |
Output formats:
| Format | Description |
|---|---|
pdf_typed | Filled PDF with typed text |
pdf_handwritten | Filled PDF with handwritten-style text (not available for all forms) |
pdf_filled | Completed PDF whose values sit in live, editable AcroForm widgets (fillable forms only) |
png_typed | Per-page PNG rasterizations of pdf_typed (requires pdf_typed) |
png_handwritten | Per-page PNG rasterizations of pdf_handwritten (requires pdf_handwritten) |
pdf_filleddelivers editable widgets, not a flattened render. The completed values sit in live, editable AcroForm widgets, so a downstream consumer can open the PDF and change them. It is available only for fillable forms: requestingpdf_filledfor a non-fillable form rejects the whole submission with400 code=format_unsupported_for_form, anderror.details.unsupported_form_idslists every non-fillable form in the request.pdf_filledis not a render surface: it cannot satisfy the ML-format render dependency, is never paired with a PNG, and is never degraded (adegradation_profilehas no effect on it). Filled PDFs are delivered underpdfs/filled/in the bundle.
JSON, CSV, and FUNSD are always included. Every dataset zip automatically contains
ground_truth/json/(per-document structured JSON),ground_truth/data.csv(aggregated tabular CSV), and per-page FUNSD annotations regardless of which formats you request. Do not include"json","csv", or"funsd"inoutput_formats— the API will return400 code=invalid_output_formatif you do.
Unknown formats are rejected. Any
output_formatstoken outside the documented set above is rejected with400 code=invalid_output_format, and the error lists the valid set. This includes typos and bare"png"(use"png_typed"or"png_handwritten"). Invalid tokens are never silently dropped.
PNG output requires its paired PDF.
png_typedmust be requested together withpdf_typed, andpng_handwrittenmust be requested together withpdf_handwritten. PNG always travels with its paired PDF; PNG-only output is not a supported configuration. Submittingpng_typedwithoutpdf_typed(orpng_handwrittenwithoutpdf_handwritten) is rejected by the API with400 code=missing_pdf_dependency. Cross-surface pairs do not satisfy the dependency — e.g.["png_typed", "pdf_handwritten"]is still rejected because typed PNG needs typed PDF specifically.
ML training formats (gated behind the ml-output-formats-enabled feature flag — requesting any of these without the flag returns 400 code=ml_formats_disabled):
| Format | Description |
|---|---|
bio | Token classification (B/I/O) labels |
yolo | YOLOv8 object detection labels |
coco | COCO JSON object detection annotations |
donut | Donut OCR-free metadata.jsonl + per-page images |
ML formats require a render format. All ML training formats (
bio,yolo,coco,donut) derive word-level annotations from the renderer pipeline. You must include at least one render format —pdf_typed,pdf_handwritten, orpng_typed— in the same request. Submitting ML formats without a render format returns400 code=missing_render_dependency. Example:["bio", "donut"]alone is invalid; use["pdf_typed", "bio", "donut"].
You can request multiple formats in a single job (e.g., ["pdf_typed", "bio", "yolo"]). Regardless of how many formats you request, the completed job is downloaded as a single dataset zip containing every produced artifact — including JSON and CSV ground truth, which are always present.
Multi-surface jobs (
["pdf_typed", "pdf_handwritten"]) run in parallel by default (STAX-1707). Pre-2026-05-15 such jobs were forced through a sequential code path as a defensive workaround. Bulk jobs now use the full bulk-worker core count regardless of how many PDF surfaces were requested. Output bundles separate the two surfaces intopdfs/typed/andpdfs/handwritten/(plusimages/{typed,handwritten}/and the per-formatannotations/<format>/{typed,handwritten}/trees); a typed and a handwritten render of the same logical document share one identity (see ADR-058 for the architectural commitment). Filled PDFs (pdf_filled), when requested, are delivered alongside underpdfs/filled/with no paired images or annotations.
Response:
{
"data": {
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"credits_required": 2200,
"warnings": []
},
"meta": {
"request_id": "...",
"credits_charged": 2200
}
}
warnings is always present — on an idempotent replay too, where it
describes the job being returned — and is [] for almost every job. A non-empty
entry means the job was accepted and charged but will not deliver part of what
it asked for — today only fraud_scenarios_dropped, for a fraud preset
whose raster scenarios the job cannot deliver (see
Fraud datasets). Each entry is
{"code", "message", "dropped_scenarios": [{"name", "raster_codes", "reason", "message"}]}.
The same list is returned by POST /pricing/estimate
and POST /jobs/size-estimate, so it can be
seen before the paid request.
Example — single form:
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_id": "irs_w2_2025",
"quantity": 50,
"output_formats": ["pdf_typed"],
"seed": 12345
}'
Example — multi-form (coherent generation):
Generate W-2 and 1040 documents for the same identities. Each identity's W-2 wages will match the 1040 line 1 wages, and SSN/name/address fields are consistent across both forms.
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_ids": ["irs_w2_2025", "irs_f1040_2024"],
"quantity": 10,
"output_formats": ["pdf_typed"],
"seed": 42
}'
Note: Calling
/generatetwice with the same seed and differentform_idvalues is not a substitute for a multi-form job: each form biases identity generation by its own requirements, so the two runs agree only where those requirements agree — two editions of the same form family can reproduce the same person, while a W-2 and a 1040-NR generally will not. Useform_idsto get documents that are guaranteed to match.
Coherence Mode (ML ablation control)
Multi-form jobs accept a coherence_mode flag inside config that
controls how identities and field values relate across the forms of
a single item_index:
coherent(default) — one identity peritem_index; every form in that item is filled from it. Cross-form correlations (same SSN on W-2 and 1040, same wages on W-2 box 1 and 1040 line 1) are preserved. Use this for realistic tax-prep datasets and any production workload.shuffled— identities are still generated one-per-item_index, but each field's final value vector is permuted across items with a seed derived from(seed, form_id, field_id). Marginal distributions and spatial layout stay valid; within-image correlations are broken. Use this for ML ablation experiments that need to measure how much a model leans on cross-field correlations inside one document. Requiresquantity > 1: a single-item job cannot be shuffled (a 1-element permutation is the identity), so the API rejectsshuffledwithquantity <= 1as a 400 (invalid_coherence_mode) instead of silently returning coherent output.random— each(item_index, form_id)pair gets its own independently generated identity, so W-2 and 1040 in the same item will usually carry different SSNs, names, and addresses. Use this as the strongest ablation baseline, or to stress-test downstream code that should not assume identity continuity across forms.
Reproducibility: passing the same (form_ids, quantity, seed, output_formats, coherence_mode) produces byte-identical output for
all three modes. Under coherent and shuffled the identity for item
i is seeded seed + i, so jobs you intend to pool must use seeds at
least quantity apart — see
Reproducible Generation. The chosen mode is recorded in manifest.json under
dataset_info.generation_params.coherence_mode so dataset consumers
can branch on it.
The job's identity seed-derivation version is recorded beside it as
generation_params.identity_seed_version — see
Reproducible Generation.
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_ids": ["irs_w2_2025", "irs_f1040_2024"],
"quantity": 100,
"seed": 42,
"output_formats": ["pdf_typed"],
"config": {"coherence_mode": "shuffled"}
}'
Bank statements (statement)
The bank_statement_* editions do not fill a template: each renders a real
statement period out of a simulated ledger, so they take their own block inside
config. Every key is optional — an absent block, an explicit null, and {}
all mean the defaults below.
Limited release. The
bank_statement_*editions are not generally available yet. Until your account has access they are omitted fromGET /api/v1/forms,GET /api/v1/forms/{form_id}returns404, and naming one in/generatereturns400 code=unknown_form.
| Key | Type | Accepted | Default | Description |
|---|---|---|---|---|
period_months | integer | 1–3 | 1 | How many calendar months one rendered statement covers. |
history_months | integer | 1–12 | 1 | How many months of activity the ledger simulates before the rendered window so the opening balance is realistic. None of it is printed — it changes the numbers, not the page count. |
sequence_length | integer | 1–12 | 1 | How many contiguous statements are delivered as one document (a packet). 1 is a single statement and is what every job that omits the key gets. |
edge_cases | string[] | see Structural edge cases | (drawn) | Which ledger edge cases the item's first statement carries. Omit the key to draw them at their natural prevalence; [] forces none; a list forces exactly those. An unknown name is a 422. |
surfaces | string[] | pdf, png, png_screenshot | ["pdf", "png"] | Which delivery surfaces the statement ships on. pdf and png are accepted but change nothing: the PDF is always delivered and page PNGs follow output_formats (png_typed), so omitting one does not suppress it. png_screenshot is the only value that changes what ships — it adds the banking-app-chrome delivery. Repeating a surface is rejected. |
Booleans are not integers here: {"period_months": true} is a 422, not 1.
Which months a statement covers. By default the window is the most recent
period_months whole months that ended before the request date — never a
partial month. When the job also names a W-2 (form_ids: ["irs_w2_2025", "bank_statement_national_2026"]), the most recent statement's window instead
begins on 1 December of the tax year minus one, so the ledger's payroll
deposits reconcile against the W-2 the job delivers beside it.
Answer-key row values. In a transactions[] or checks[] row of the
per-document ground truth, the index keys — row, page, transaction_row
and statement_index — are integers, and every other value is a string
(dates ISO). page — the 0-indexed page a
row's cells landed on — is the only key that may be null, and only for a row
that painted no ink. A ledger field the entry did not carry (counterparty on
a row with no named payee, check_number on anything that is not a check) is
the empty string, never null. amount and balance_after are signed
plain decimals; checks[].amount_paid is a non-negative magnitude, because
the sign lives on transactions[].amount and only there.
Which summary line a row is counted under (transactions[].bucket). Each
transactions[] row carries bucket, one of deposits, withdrawals,
checks, fees, interest — the summary line that row is counted under. It
is a partition: every row falls in exactly one bucket, so
summary.total_<bucket> is the sum of the magnitudes of the rows carrying that
value and summary.<bucket>_count is how many there are. On an edition that
prints its ledger in sections, bucket is also the section a row printed
under, and the subtotal beneath that section is the same figure the summary
line carries a page earlier.
The section a legacy ledger prints a row under (transactions[].channel_group).
Each transactions[] row also carries channel_group: its bucket, except
that withdrawals is split by how the money left — withdrawals-electronic
(ACH, transfer) or withdrawals-card (card, ATM). The legacy edition prints
its ledger in these sections ("ELECTRONIC WITHDRAWALS", "ATM AND DEBIT CARD
TRANSACTIONS"), each with its own subtotal, while the summary still prints one
total_withdrawals; the two section subtotals add up to it. Every other
value equals bucket, and every design publishes the key.
Split money columns (transactions[].credit_amount, debit_amount). Each
transactions[] row also carries credit_amount and debit_amount: the
entry's magnitude under whichever of a split-column ledger's two money
columns it prints in, and the empty string under the other. Every row fills
exactly one of the two. They are a presentation of amount — a credit's
credit_amount equals amount, a debit's debit_amount equals -amount —
not a second ledger; amount keeps the sign and is what the running balance
reconciles against. An edition that prints a single signed column still
publishes both keys, so a consumer can read the pair on every design.
Returned items (an amount of "0.00"). A "0.00" is a real row, not a
missing value, and it is not specific to checks. An item on any debit
channel — ach, atm, card, check, transfer — presented against
insufficient funds may be returned unpaid. That is one of three
outcomes; the bank may instead pay the item into overdraft, either with an
overdraft fee or with no fee at all — once the month's allowance of
balance-driven fees is spent, or on an account that charges none. An item paid into overdraft posts at its full amount, so a
negative balance in the ledger does not imply a returned item.
Only the returned case posts a zero-dollar memo row, on the item's own channel,
described as the bank's own returned-item wording followed by the descriptor
the item would have had, cased the way that edition cases every descriptor —
so the memo's wording is not a fixed string to match on — immediately
followed by the NSF fee row it triggered (a fee-channel
debit). These appear in transactions[] with amount of "0.00", so a
consumer that filters the ledger on a non-zero amount drops them on every
channel.
Pair the memo with its fee by adjacency, amount and channel — never by
description. The memo row is the "0.00" row on the original debit channel;
the fee is the adjacent negative fee-channel row. The fee's wording is drawn
from the institution's fee vocabulary: it varies by institution and by account
holder, and may carry a leading qualifier or a trailing date. There is no
string, and no prefix, that reliably identifies it — do not match on it.
A returned check is additionally listed in checks[] on the editions that
print a Checks Paid table. That memo row keeps the check's number, so it is
bucketed under checks and appears there with amount_paid of "0.00" like any
other; filtering on amount_paid > 0 silently drops those, and
checks[].transaction_row leads back to the ledger row. Editions that print no
such table have no checks array at all, so test for the key before reading
it — of the five bank-statement editions regional, credit_union and legacy
print one.
Statement notices. Besides the fixed disclosures every copy of an edition
prints, a statement carries 0–2 short notices — an "Important Account
Information" policy change, a marketing blurb, a disclaimer qualifying a figure
printed elsewhere on the page. Which ones a document carries is drawn per
statement from that statement's own seed, so a reproduced job reproduces them
exactly; no notice at all is the most common outcome, and an empty
notices array is a real answer, not a missing value.
On the editions that print them, the per-document ground truth carries a
notices[] array of {row, page, title, body, kind}, where kind is one of
policy, marketing, or disclaimer. Editions that print no notices have no
notices array at all, so test for the key before reading it — the same rule
checks follows. The text is synthetic and institution-neutral: it names no
bank, no product brand, and no URL. On a fraud job the draw is independent of
the fraud label, so a notice — or the absence of one — separates genuine
documents from tampered ones no better than chance.
Derived summary figures. Beyond the period's five money totals, summary
carries the figures a real statement summary prints beside them. All are
derived from the ledger rows the same document publishes — nothing here is
a second source of truth, so a consumer can recompute every one of them from
transactions[] and check the page against itself:
| Key | Type | Description |
|---|---|---|
deposit_count, withdrawal_count, check_count, fee_count | integer-valued string | How many entries each summary bucket counts. Every row falls in exactly one bucket, so these can never sum past the row count. |
days_in_cycle | integer-valued string | Days the cycle covers, both endpoints included — 1–31 December is 31, not 30. |
average_ledger_balance | decimal string | Mean of the daily closing balances over every day of the cycle, quiet days included. |
minimum_daily_balance | decimal string | Lowest daily closing balance over the cycle, on the same every-day basis. |
apy_earned | decimal string | The account's annual percentage yield as the percent number the statement prints ("0.45", not "0.0045"). The per-cent sign is the page's own static text. |
interest_paid_ytd, fees_paid_ytd | decimal string | Interest and fees for the calendar year the statement's period_end falls in, summed over this statement and every earlier statement in the same packet. Attributed per row, by posted_on, not per statement: a cycle that straddles new year contributes only the rows that posted in the year being summed. |
interest_paid_prior_year | decimal string | The same figure, on the same per-row basis, for the previous calendar year. Read prior_year_partial before using it. |
ytd_partial | "True" / "False" | Read this before reading interest_paid_ytd / fees_paid_ytd. "True" means the run of statements does not reach 1 January, so those figures are a sum over the months available rather than a whole year. A single statement is always "True". |
prior_year_partial | "True" / "False" | The same warning for interest_paid_prior_year. "True" means the run does not cover the whole of the previous calendar year — it begins after 1 January, ends before 31 December, or never reaches that year at all. A single statement is always "True", and its prior-year figure is "0.00" because there is nothing to sum, not because the account earned nothing. |
period_start_long, period_end_long | string | The period dates in long form ("December 1, 2021"). The short period_label is unchanged. |
opening_balance_label, closing_balance_label | string | The label printed beside each balance, carrying a month/day ("Beginning balance on 12/01"). The year is in period_label. |
monthly_fee_standard | decimal string | The account's published monthly maintenance fee. "0.00" on a product that charges none. |
monthly_fee_charged | decimal string | What the cycle actually posted, taken from the fee rows in transactions[]. |
fee_waived | "True" / "False" | "True" only when a published fee went uncharged. An account with no published fee has nothing to waive and is "False". |
waiver_minimum_balance | decimal string | The published balance at or above which the fee is waived; "0.00" means the product publishes no such waiver. |
waiver_met_balance | decimal string | The balance the waiver claim is measured against — the minimum daily balance. Note that a waiver may legitimately be granted to a cycle whose minimum daily balance never reached the threshold: the bank judges the balance at the moment the fee would post, not the month's low. The reverse — a fee charged on a cycle that met the threshold — does not occur on genuine output. |
sheet_sequence | digit string | The print-run sheet counter an edition's page furniture prints ("0592380"). A fact about the document rather than the ledger: derived from the account and the cycle, so two cycles of one account carry different counters and a reproduced job reproduces it exactly. |
interest_earned | "True" / "False" | "True" when the cycle paid any interest — the flag an edition's interest panel is shown on. A "False" statement prints no interest panel at all, so total_interest is "0.00" on it. |
interest_life_to_date | money | Interest paid over every cycle the delivered run carries, with no calendar window — the online edition's "Interest Life To Date". |
interest_life_complete | "True" / "False" | "True" when the earliest cycle in that sum starts on or before the day the account opened, so the figure really is the account's lifetime interest. The online edition prints its "since you opened your account" line only on a "True" statement. |
Daily balances (daily_balances[]). On the editions that print a "Daily
balance summary", the ground truth carries a daily_balances[] array of
{row, page, date, balance} — one row per day the account had activity, in
posting order, carrying the balance that day ended on. Editions that print
no such section have no daily_balances array at all, so test for the key
before reading it — the same rule checks and notices follow.
Like checks[], it is a second view of entries already in
transactions[], but the join is arithmetic rather than an index, because
no single entry corresponds to a day's line: a row's balance is the
previous listed row's balance plus the signed amount of every
transactions[] row whose posted_on equals this row's date. The first
listed row is measured from summary.opening_balance, and the last row's
balance is summary.closing_balance.
A day with no activity is absent rather than repeating the previous
figure, so consecutive rows are not consecutive days and the gaps are not
missing data. This is also why summary.average_ledger_balance and
summary.minimum_daily_balance are not derivable from these rows alone: they
are computed over every day of the cycle, quiet days included. date is ISO
and balance is a signed plain decimal, the same conventions the other arrays
use. On a packet the rows of every statement are flattened into this one array
in delivery order, and statement_index says which statement a row came from.
Check-sequence gaps (checks[].sequence_gap). Each checks[] row carries
sequence_gap, "True" when the previous printed check number in the
array is not this row's number minus one — a cheque written and cleared
elsewhere, voided, or still outstanding. It is "False" on the first row, on a
row with no number, and on either side of a non-numeric number: "no previous
number to compare" is not a gap.
Multi-statement packets (sequence_length). A packet is 2–12 contiguous
statements for one identity delivered as one document: one PDF whose pages
are the statements' pages oldest-first, one answer key, one set of page images.
The ledger is continuous, so statement n+1's period starts the day after
statement n's ends and its opening balance is statement n's closing
balance.
A packet changes the ground truth in four ways:
- Each per-document JSON in
ground_truth/json/gains a reservedstatementsarray — one object per statement, in delivery order, withstatement_index,period_start,period_end,period_label,opening_balance,closing_balance,total_deposits,total_withdrawals,total_checks,total_fees,total_interest, every derived summary figure documented under Derived summary figures above,edge_cases(see Structural edge cases below),page_startandpage_end.statements[i].statement_index == i,statements[i].opening_balance == statements[i-1].closing_balanceexactly (money is a plain signed two-decimal string on both sides), andpage_start/page_endare inclusive packet-absolute page indices. Every summary scalar an edition paints is republished per statement, because the top-levelsummary.*keys are statement 0's. transactionsandchecksare flattened across the packet:rowandchecks[].transaction_rowindex the flattened array, and every row carriesstatement_indexnaming the statement it printed on.- Page numbering is packet-absolute everywhere a page appears — a row's
page,statements[].page_start/page_end, and the_p{n}suffix on page images — so nothing that maps a box onto a page image has to know about statements. row_indexon the per-word and per-record ground truth stays statement-local (the row as its own statement printed it), so a record joins an answer-key row on(statement_index, row_index)— never onrow_indexalone.
A job that omits sequence_length is byte-identical to what it produced
before packets existed: no statements array, and statement_index emitted
nowhere.
A packet does not change how the seed is spent: quantity counts items,
and one item is one identity seeded seed + item_index no matter how many
statements that item's packet contains. A 25-item job with
sequence_length: 3 occupies 25 item seeds, not 75, so the spacing rule in
Reproducible Generation is quantity apart, never
quantity * sequence_length. (For the bank_statement_* editions specifically, the
ledger behind each statement is seeded from its own mixer rather than from
seed + item_index, so sibling jobs with overlapping identity ranges still
print different transactions — there the overlap is in the account holders,
not in the money. No other form family has a comparable second seed stream, so
for the rest an overlapping item repeats wholesale.)
Structural edge cases (edge_cases). Real statements are not all a tidy
month of payroll and groceries, and a dataset that never shows a reversal or an
empty cycle trains a reader that has never seen one. Every item's first
statement draws, on its own seed stream, from these cases — each an independent
draw at the prevalence shown, so a run of a thousand statements carries about
this share of each; name them in edge_cases to force them instead:
| Name | Prevalence | What the statement carries |
|---|---|---|
reversed_transaction | 8% | A card purchase and, 1–3 days later, a REVERSAL credit of the same line. |
fee_reversal | 5% | A fee and a REFUND credit of it on the same or the next day. |
returned_deposit | 3% | A mobile check deposit, then a RETURNED DEPOSITED ITEM debit of the same amount plus a returned-item fee. |
voided_check | 4% | A check credited back as VOID under its own number, then reissued under the next number. |
foreign_transaction | 7% | An INTL PURCHASE with the foreign amount, currency and rate on the line, and the 3% (min $1) fee beside it. |
skipped_check | 6% | A gap in the check-number sequence (the checks section marks it). |
opened_during_period | 2% | The account opens a few days into the cycle: zero opening balance, an OPENING DEPOSIT first row. |
closed_during_period | 1% | The account closes in the last third of the cycle: the balance is withdrawn to zero and nothing posts after. |
single_transaction | 1% | Exactly one row. |
no_transactions | 1% | No rows at all; the opening and closing balances agree. |
dense | 4% | 110–150 small card debits on top of the ordinary month — a ledger that runs to several pages. |
no_transactions and single_transaction win alone when drawn, and a cycle
cannot both open and close (the closing case wins). The two check cases are
dropped on an institution whose design clears no checks (the online
archetype). Only what actually left a row is recorded: the answer key's
variation.edge_cases (below) and, in a packet, each statements[].edge_cases
carry the names comma-joined, "" for none — a job that forced voided_check
on an online bank reads "", not a case the page does not show.
Per-document variation (variation.*). Independently of which of the five
designs the institution draws, each statement build makes a handful of choices
the edition permits — which transaction-table column set to print, whether a
continuation page carries the full furniture or a terse account-and-page line,
whether column headers repeat on continuation pages, whether a section may be
cut by a page break, and whether a lone row may be stranded on a last page.
The choices are drawn once per item (so every statement of a packet agrees —
a bank prints the same shape every month) and published in the answer key so a
consumer can group or filter on them:
| Key | Values | Meaning |
|---|---|---|
variation.table_style | T1–T6 | The transaction table's column set: T1 date/description/amount; T2 + balance; T3 date/description/debit/credit/balance; T4 date/reference/description/withdrawal/deposit; T5 post date/trans date/description/amount/balance; T6 date/check no./description/amount. Each edition offers two. |
variation.continuation_headers | full, minimal | The per-page furniture on continuation pages. online has none and publishes "". |
variation.repeat_column_headers | True, False | Whether the column-header row is redrawn on every page that carries rows. |
variation.section_split | allow, break | Whether a sectioned ledger may be cut mid-section by a page break. Editions without sections publish "". |
variation.orphan_rows | True, False | True lets a page break strand a single row; False keeps at least two together. |
variation.edge_cases | comma-joined names | The edge cases above that the statement carries; "" for none. |
An axis the edition does not vary is "". The transactions rows and their
row_schema keys are the same under every style — a style changes which
columns are painted, and the per-page annotation formats (Donut, FUNSD,
COCO, YOLO) describe only the painted ones.
The png_screenshot surface. Naming png_screenshot in surfaces adds a
second delivery of every rendered page: the clean typed raster composited into
mobile or web banking-app chrome. It is not an output_formats token — it
rides the typed render, so the job must also request pdf_typed — and it never
suppresses the PDF or the page PNGs, which ship in the same bundle. Screenshots
are never degraded: they are always composited from the clean typed raster, so
a degradation_profile never adds scan artifacts to them. It does change the
DPI that raster is taken at — 200 rather than 300 — but the canvas size and
every projected bounding box are unaffected. In the bundle:
- page images at
images/screenshot/{form}_{item}_screenshot_p{n}.png; - annotations in the surface's own tree,
annotations/screenshot/<format>/—coco/annotations_screenshot.json,donut/metadata_screenshot.jsonl,funsd/{form}_{item}_screenshot_p{n}.jsonandyolo/labels/{form}_{item}_screenshot_p{n}.txt. The nesting is inverted from the PDF surfaces'annotations/<format>/{typed,handwritten}/trees, so the whole screenshot delivery can be copied or dropped in one move. BIO is deliberately not duplicated there: a BIO file carries no geometry, so a screenshot-space copy would be byte-identical to the PDF surface's.
coherence_mode interaction. The statement window is resolved once per
job, so all three modes render the same periods. Under coherent the ledger is
reconciled against the job's W-2 (payroll deposits against net pay); under
shuffled and random that cross-form reconciliation is dropped, because both
modes decouple the W-2 from the identity the ledger was built for. The
statement's own internal coherence checks (the balance chain) run in every
mode; period contiguity is arithmetic, not a post-render check.
Pricing. A statement is priced like any other typed-PDF document: flow
editions declare no template fields, so the per-document cost is the typed base
rate (20 credits) and total_credits = 20 * quantity, before any
degradation_profile multiplier (see Credit Pricing). Neither
sequence_length nor surfaces multiplies it — pricing counts documents, and
the document is (item_index, form_id). A 3-statement packet delivered on two
surfaces costs exactly what a single statement costs.
Validation. The whole statement block is checked before the credit debit,
under two codes.
422 code=invalid_statement_config — the block itself is wrong:
| Rejected | Example message |
|---|---|
statement is not an object | config['statement'] must be an object; got str. Accepted keys are ['period_months', 'history_months', 'sequence_length', 'surfaces']. |
| an unknown key inside the block | Unknown key(s) ['period_month'] in config['statement']; accepted keys are ['period_months', 'history_months', 'sequence_length', 'surfaces']. |
| a non-integer (booleans included) value | config['statement']['period_months'] must be an integer between 1 and 3; got True. |
| an out-of-range value | config['statement']['sequence_length'] must be between 1 and 12; got 13. |
surfaces is not a list | config['statement']['surfaces'] must be a list of surface strings; got str. Accepted surfaces are ['pdf', 'png', 'png_screenshot']. |
surfaces names an unknown surface | Unknown delivery surface(s) ['png_scrnshot'] in config['statement']['surfaces']; accepted surfaces are ['pdf', 'png', 'png_screenshot']. |
surfaces repeats a surface | config['statement']['surfaces'] must not repeat a surface; got ['pdf', 'pdf']. |
| the packet reaches too far back (see below) | The job asks for 12 contiguous statements, so its earliest one would be dated <earliest period start> — more than 2 years before its most recent one (<newest period start>). … (the two dates are the packet's own, derived from the request date) |
422 code=invalid_flow_job — the block is fine but the job around it is not:
| Rejected |
|---|
png_screenshot on a job that names no bank_statement edition |
png_screenshot without pdf_typed in output_formats (the screenshot is composited from the typed render) |
pdf_handwritten, png_handwritten or pdf_filled requested for a statement — v1 is typed only |
more than one bank_statement edition in one job (one identity banks at one institution) |
| a W-2 whose tax year cannot pin a statement window: more than 2 years before, or after, the request date |
How far back a packet may reach. The bank registry behind these editions is
a snapshot of the institutions as they are now — no effective dates, no
predecessor names — so a statement dated years back would print today's banks
on a historical date. The earliest statement in a packet may therefore not be
more than 2 years before the packet's most recent one, which puts the effective
ceiling on sequence_length at 12 with period_months: 1, 12 with 2, and
9 with 3. A single statement can never trip it. Composed with the W-2
rule above, the oldest statement any accepted job can print is dated December of
the fifth year before the request year.
Example — a 3-statement packet with screenshots:
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_id": "bank_statement_national_2026",
"quantity": 25,
"output_formats": ["pdf_typed", "png_typed"],
"seed": 2372,
"config": {
"statement": {
"period_months": 1,
"history_months": 6,
"sequence_length": 3,
"surfaces": ["pdf", "png_screenshot"]
}
}
}'
Each of the 25 items is one document: a single PDF of three consecutive
one-month statements, its page images under images/typed/, the same pages
composited under images/screenshot/, and one answer key whose statements
array has three entries.
Example — a rejected packet:
{
"detail": {
"code": "invalid_statement_config",
"message": "config['statement']['sequence_length'] must be between 1 and 12; got 13.",
"status": 422
}
}
Fraud datasets (fraud)
A fraud block inside config turns a bank-statement job into a labelled
fraud dataset: a share of the delivered statements carry a tamper, every
document is labelled genuine or tampered in ground_truth/fraud/labels.jsonl,
and manifest.fraud records the recipe that was applied — including
target_policy, which every shard's shard_manifest.json repeats under the
same fraud.target_policy key so a sharded delivery can name its target
distribution without reading the dataset manifest. That field reads uniform,
motivated, or legacy_preferred — the last on a bundle built before the
policy existed, whose forensic edits were selected by the older preference for
money, date and identifier cells. legacy_preferred is a description of
delivered data, not a policy you can request.
legacy_preferred names the preference that ran, not a single distribution.
That preference matched cells by name, and from 2026-09-15 it stopped matching
the regional edition's split debit_amount/credit_amount columns, so
regional bundles built between then and this release carry no amount-cell
forensic edits while earlier ones do. Both read legacy_preferred. To tell
them apart, read manifest.generator.git_shas or the job date; a bundle whose
git_shas straddles that date cannot be assigned to either population and
should be quarantined rather than classified. Note that git_shas is itself
imprecise in both directions — see Generator provenance below — so a
single-SHA bundle is evidence, not proof, that it sits wholly on one side.
legacy_preferred is also the one target_policy value a bundle can publish
that you cannot send back: /generate accepts uniform and motivated only,
and refuses anything else with 422 invalid_fraud_config. This holds for the
per-recipe scenarios[].target_policy exactly as it does for the job-level
field, and a legacy bundle publishes legacy_preferred on every recipe, so
a recipe list copied out of such a manifest is refused too — the message quotes
the offending recipe and the two values a recipe may carry. So a manifest read
from an old bundle cannot be round-tripped into a request as-is. To regenerate
a comparable dataset, choose the policy you want explicitly — motivated is
the closer of the two to what the old preference did, but it is not the same
distribution, and nothing reproduces legacy_preferred exactly.
An absent block, or
an explicit null, is an ordinary genuine-only job; {} is a fraud job at
every default below. The block is available on the same limited release as the
bank_statement_* editions.
| Key | Type | Accepted | Default | Description |
|---|---|---|---|---|
ratio | number | 0–1 | 0.5 | Share of items that are tampered. 0 is a genuine-only statement dataset that still carries labels; 1 tampers every item. |
pairing | string | paired, unpaired | paired | paired delivers each tampered statement beside its genuine twin, sharing a pair_id; unpaired delivers one document per item. |
scenarios | string or array | a preset name, or a non-empty recipe list | semantic_only | Which tampers are drawn. Presets: balanced_all, semantic_only, forensic_only, fabricated_only, subtle_income_inflation. A recipe is {"name", "weight", "tampers"[, "subtlety"][, "target_policy"]} — see below. |
subtlety | string | obvious, moderate, subtle, mixed | mixed | How visible the edits are. mixed draws a level per document. |
rechained_share | number | 0–1 | 0.5 | Share of tampered documents whose running balance is re-chained after the edit, so the arithmetic still adds up and the tamper is not given away by a broken chain. |
target_policy | string | uniform, motivated | uniform | Which cells the PDF- and raster-layer edits (P1, P2, P6, R1–R6) land on. uniform draws from every value the statement prints about the account holder, with no preference, so the cell type of a tamper box is not itself a clue. motivated prefers the cells a forger would have to change for a forged statement to hold together — every amount, running balance and summary figure, a transaction's posting date but not the period the statement covers, a name or account number but not a mailing address — falling back to any printed value when a page offers none of them. Neither ever edits the bank's own name, address or routing number. The semantic codes are unaffected: each one names its own cell. |
Recipes. An explicit scenarios list names your own weighted mix:
name matches ^[a-z0-9_-]{1,64}$, weight is a positive number (relative,
need not sum to one), and tampers is a non-empty list of tamper codes from
the taxonomy. A recipe may also carry its own subtlety and target_policy,
each overriding the block-level value for the documents that recipe plans, so
one mix can hold both a motivated-forger recipe and a uniform one.
Every recipe in manifest.fraud.scenarios[] publishes a target_policy, never
null, and the value published is the resolved one: the recipe's own
policy if it set one, otherwise the job's. It names the distribution that
recipe's boxes were drawn from only for a forensic recipe under a policy this
job actually recorded — which excludes three cases, in all of which the field
is published but describes nothing: a recipe carrying no forensic code,
because target_policy governs P1, P2, P6 and R1–R6 alone — a
semantic recipe's codes each name their own cell, and a fabrication recipe
prints a document rather than editing cells, so neither is steered by it (and
semantic_only is the default scenarios preset, with fabricated_only
another, so this is the ordinary case rather than a corner); a value
of legacy_preferred, which as above names the preference that ran and not a
single distribution; and a job that straddled a deploy, where manifest.fraud
is written from the plan stored when the job's first chunk started, so every
recipe publishes legacy_preferred even though the chunks rendered after the
deploy drew their boxes uniform. Filter on scenarios[].target_policy only
after you have ruled all three out.
The published value does not tell you whether the recipe chose that policy
or inherited it, and nothing else in the bundle does either. Diffing it
against the job-level
manifest.fraud.target_policy says which recipes diverged from the job —
that and no more: a recipe that explicitly set the job's own value reads
identically to one that inherited it, which is both the majority case and
exactly the case you are looking at when you suspect an override. Choose
versus inherit is recorded only in the request you sent, so it is available to
the requester and to nobody else; a benchmark team handed a delivered dataset
without the request cannot recover it from the manifest at all.
A sharded delivery repeats these resolved values in every
shard_manifest.json under fraud.scenario_policies — a different key
from the dataset manifest's fraud.scenarios because it is a different shape:
[{"name", "target_policy"}], the policies only, with no tampers, weights
or layers. The full recipe list stays in the dataset manifest, where it
cannot drift from itself; the shard carries only what a shard read on its own
needs in order not to misname the distribution of its own documents.
Each recipe must stay on one layer — semantic (S), PDF
(P), raster (R) or fabrication (F) — and name at most one semantic
code, because exactly one tamper engine runs per document. Every code in the
taxonomy executes today. A recipe can still be dropped from the mix rather
than refused — a preset's raster recipe that the job cannot deliver, below — and
every drop is recorded in manifest.fraud.scenarios; only a mix that the drops
leave empty is a 422.
Raster recipes need page images on the clean profile. A raster (R)
tamper is written into the delivered page images, so it can be delivered only
when output_formats includes one that rasterizes the pages (png_typed for a
statement) and degradation_profile is absent or clean — degradation
re-rasterizes the PDF after the tamper. What happens otherwise depends on how
the scenarios were named:
-
An explicit
scenarioslist that names any raster recipe the job cannot deliver is refused before the credit debit with422 code=undeliverable_fraud_scenarios.error.detailsnames each scenario and why, so you can addpng_typed, switch to thecleanprofile, or drop those recipes:{ "error": { "code": "undeliverable_fraud_scenarios", "message": "config['fraud']['scenarios'] names 1 scenario(s) this job cannot deliver (copy_move): raster (R) tampers are written into the delivered page images and output_formats ['pdf_typed'] rasterizes none — add 'png_typed'. Otherwise remove those scenarios from the list. …", "details": { "undeliverable_scenarios": [ { "name": "copy_move", "raster_codes": ["R3"], "reason": "no_raster_output_format", "message": "the raster codes ['R3'] write their forgery into the delivered page images and this job rasterizes none" } ], "accepted_output_formats": ["png_typed", "donut", "coco", "bio"] } } }reasonisno_raster_output_formatornon_clean_degradation_profile; in the second casedetails.degradation_profilenames the profile.accepted_output_formatslists the formats that rasterize a statement's pages. A recipe the job cannot carry for a more basic reason — a scenario spanning two layers,pdf_handwrittenoutput, thepng_screenshotsurface — gets thatinvalid_fraud_configrefusal first, and an unknowndegradation_profileis400 code=invalid_degradation_profilebefore any of it. -
A preset (
balanced_all,forensic_only) is accepted: its raster recipes are dropped, the rest share the requestedratio, and the job is priced the same. The drop is reported as afraud_scenarios_droppedentry in the create response'swarnings(and in both estimate endpoints'), with the samedropped_scenariosshape as above, as well as inmanifest.fraud.scenarios.
Rules. The block is accepted only when every form in the job is a
bank_statement edition, only with coherence_mode absent or coherent,
and never beside fill_scenarios. A fraud dataset is delivered on the typed
PDF and its page PNGs: pdf_filled, pdf_handwritten and png_handwritten
are refused, and so is the png_screenshot surface for now. A fraud dataset
is one statement per item: statement.sequence_length must be absent or 1,
because the tamper engines do not yet render packets.
Items that cannot be drawn. Occasionally one drawn statement cannot carry
its planned tamper (the edit would be undetectable, or would break a rule the
genuine twin must keep). That item is redrawn with a new account holder —
deterministically, so the same seed always redraws the same items — keeping
its planned tamper and split. An item that fails four draws is skipped:
neither twin is delivered, and it is listed in manifest.fraud.counts.skipped
(items, item_indexes, by_failure_class, by_class, by_scenario) and in
manifest.item_draws. Every job, fraud or not, reports its redraws and skips
in manifest.item_draws (redrawn_items, failed_draws,
failed_draws_by_class, skipped_items, skipped[]). The block is always
present — all-zero with an empty skipped[] on the overwhelming majority of
jobs that redraw nothing — so "nothing was skipped" and "this bundle predates
the block" never read the same. Each skipped[] entry carries
item_index, attempts, failure_class and a plain-language reason.
A skipped item still consumes its credit. A 20-item job that delivers 19
is charged for 20. Compare manifest.fraud.counts.skipped (or
manifest.item_draws.skipped_items, on any job) against the quantity you
ordered to see what a given job actually delivered.
The job fails rather than delivering a short or quietly biased dataset if more than 5% of its items (rounded down — so none on a job under 20 items) or three consecutive items would be skipped, or if one failure class redraws more than a quarter of the items (minimum 3). The last bound catches a fault that every item survives on its second draw: nothing is skipped, but every delivered identity is then conditioned on the fault not having fired.
Validation. The whole block is checked before the credit debit, under
422 code=invalid_fraud_config (or undeliverable_fraud_scenarios, above). Explicit null values inside the block are
refused, not read as defaults. The same checks run on
POST /pricing/estimate and
POST /jobs/size-estimate when config is
supplied there, so a recipe can be checked before the paid request.
| Rejected | Example message |
|---|---|
fraud is not an object | config['fraud'] must be an object; got str. Accepted keys are ['ratio', 'pairing', 'scenarios', 'subtlety', 'rechained_share', 'target_policy']. |
| an unknown key inside the block | Unknown key(s) ['ratioo'] in config['fraud']; accepted keys are ['ratio', 'pairing', 'scenarios', 'subtlety', 'rechained_share', 'target_policy']. |
ratio / rechained_share not a number in 0–1 (booleans and strings included) | config['fraud']['ratio'] must be a number between 0 and 1; got True. |
an unknown pairing, subtlety or target_policy | config['fraud']['pairing'] must be one of ['paired', 'unpaired']; got 'pairs'. |
| an unknown preset name | Unknown fraud preset 'forensic'; the backend ships ['balanced_all', 'fabricated_only', 'forensic_only', 'semantic_only', 'subtle_income_inflation']. |
| a recipe spanning two layers, or naming two semantic codes | Scenario 'x' spans layers ['pdf', 'semantic']. … |
| a mix the drops leave empty | Every requested fraud scenario was dropped, so this job would deliver a genuine-only dataset while charging for a fraud one: … |
an explicit scenarios list with a raster recipe the job cannot deliver (code=undeliverable_fraud_scenarios, see above) | config['fraud']['scenarios'] names 1 scenario(s) this job cannot deliver (copy_move): … |
a form that is not a bank_statement edition | config['fraud'] is accepted only when every form in the job is a bank_statement edition; ['irs_w2_2025'] cannot carry a tamper. … |
fill_scenarios, or a coherence_mode other than coherent | config['fraud'] requires config['coherence_mode'] = 'coherent'; got 'shuffled'. … |
statement.sequence_length above 1 | config['fraud'] cannot be combined with config['statement']['sequence_length'] > 1: the tamper engines render single statements, not packets, … |
pdf_filled / pdf_handwritten output, or the png_screenshot surface | A fraud dataset cannot be delivered on ['pdf_filled']: … / A fraud dataset cannot be delivered on the 'png_screenshot' surface yet: … |
subtle_income_inflationis refused today. That preset is theS1(inflated payroll deposit) code alone, andS1needs a payroll or transfer credit that a generated identity is not guaranteed to have, so a job configured with nothing else has no fallback on the identities that lack one. Until that is fixed the request is a 422 naming the constraint; usesemantic_onlyorbalanced_all, or add a recipe with a code that has no row precondition (S2,S6,S7,S8).
Example — a paired, all-layers fraud dataset:
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_id": "bank_statement_national_2026",
"quantity": 50,
"output_formats": ["pdf_typed", "png_typed"],
"seed": 2383,
"config": {
"fraud": {
"ratio": 0.5,
"pairing": "paired",
"scenarios": "balanced_all",
"subtlety": "mixed"
}
}
}'
Example — a rejected block:
{
"detail": {
"code": "invalid_fraud_config",
"message": "config['fraud']['ratio'] must be a number between 0 and 1; got 1.5.",
"status": 422
}
}
Partial fill and payer scenarios (fill_scenarios)
Real intake pipelines rarely fill every field at once. A payer or EHR sends a
provider a form with the patient, insurance, and ordered procedure blocks
already prefilled; the provider completes the rest, and plenty of fields stay
blank on the way through. fill_scenarios is a declarative, per-request
policy — a sibling of coherence_mode inside config — that reproduces this:
you describe named, weighted scenarios, and the job quantity is split
across them deterministically by seed.
Each scenario assigns every field of every requested form exactly one fill stage:
| Stage | Rendered as | In the answer key |
|---|---|---|
prefilled | Typed text on every surface — including handwritten output. Models machine prefill by the payer/EHR. | the synthesized (or pinned) value |
completed | Surface-native (typed → typed, handwritten → handwritten). The default stage and the pre-existing behavior. | the synthesized value |
blank | Empty; absent from every bbox label surface (FUNSD/BIO/COCO/YOLO). | "" — distinguishable from unset (below) |
Scenario object:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | — | Must match ^[a-z0-9_-]{1,64}$ and be unique across the list. Echoed in _metadata.fill.scenario. |
weight | number | no | 1 | Positive, finite relative share of quantity. Counts are apportioned by the largest-remainder method, then interleaved across item_index by a seed-derived shuffle. |
default_stage | string | no | "completed" | Stage for every field not matched by a selector or pin. One of "completed" or "blank" only — never "prefilled". |
prefilled | string[] | no | [] | Selectors (see below) whose matched fields are forced to the prefilled stage. |
blank | string[] | no | [] | Selectors whose matched fields are forced to the blank stage. |
values | object | no | {} | {field_id: literal} value pins. Keys are exact field ids (not concept: globs); values are strings, numbers, or booleans. A pin forces its field to prefilled. |
Selector syntax (the strings in prefilled / blank):
- A bare string is an exact field id — the
idof a field as returned byGET /forms/{form_id}(see Get Form Details). concept:<glob>is an fnmatch-style glob against a field'ssemantic_conceptannotation, the metadataGET /forms/{form_id}exposes per field (alongsidestructural_roleandentity_role). For exampleconcept:healthcare.patient.*selects every patient-demographic field on the form. This is the addressing axis an agent uses to translate intent ("prefill the patient and insurance blocks") into a policy without hard-coding field ids.
Baseline scenario — the intake artifact. Prefill the patient, insurance, diagnosis, and procedure blocks; everything else is genuinely blank. This is exactly what lands on the provider's desk before they complete it:
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_id": "cms_1500_standard_02_12",
"quantity": 500,
"output_formats": ["pdf_typed"],
"seed": 42,
"config": {
"fill_scenarios": [
{
"name": "payer-intake",
"default_stage": "blank",
"prefilled": [
"concept:healthcare.patient.*",
"concept:insurance.*",
"concept:healthcare.diagnosis.*",
"concept:healthcare.procedure.*"
]
}
]
}
}'
Baseline + completed variant. The same prefill set, but now the provider
has finished the form: flip default_stage from "blank" to "completed" so
every unlisted field is synthesized and rendered surface-native. One scenario
shape, two document realities:
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_id": "cms_1500_standard_02_12",
"quantity": 500,
"output_formats": ["pdf_typed"],
"seed": 42,
"config": {
"fill_scenarios": [
{
"name": "completed",
"default_stage": "completed",
"prefilled": [
"concept:healthcare.patient.*",
"concept:insurance.*",
"concept:healthcare.diagnosis.*",
"concept:healthcare.procedure.*"
]
}
]
}
}'
Weighted multi-scenario job — baseline plus additional blocks A/B/C. A
payer population isn't uniform: most claims arrive with just the baseline
prefill, some also carry a referring provider (block A), and a few add a prior
authorization (block B) and service facility (block C). Weights split the 2,000
documents 3 : 2 : 1 (i.e. 1,000 / 667 / 333 after largest-remainder rounding).
The last scenario also pins the plan name to a literal via values:
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_id": "cms_1500_standard_02_12",
"quantity": 2000,
"output_formats": ["pdf_typed"],
"seed": 42,
"config": {
"fill_scenarios": [
{
"name": "baseline",
"weight": 3,
"default_stage": "blank",
"prefilled": [
"concept:healthcare.patient.*",
"concept:insurance.*",
"concept:healthcare.diagnosis.*",
"concept:healthcare.procedure.*"
]
},
{
"name": "baseline-plus-referral",
"weight": 2,
"default_stage": "blank",
"prefilled": [
"concept:healthcare.patient.*",
"concept:insurance.*",
"concept:healthcare.diagnosis.*",
"concept:healthcare.procedure.*",
"concept:healthcare.referring_provider.*"
]
},
{
"name": "baseline-plus-auth-facility",
"weight": 1,
"default_stage": "blank",
"prefilled": [
"concept:healthcare.patient.*",
"concept:insurance.*",
"concept:healthcare.diagnosis.*",
"concept:healthcare.procedure.*",
"box23_prior_auth",
"concept:healthcare.facility.*"
],
"values": { "box11c_plan_name": "AETNA PPO" }
}
]
}
}'
Ground-truth provenance (_metadata.fill). So consumers can tell
blank-by-policy apart from blank-because-a-binding-was-empty, each
per-document JSON in ground_truth/json/ carries a fill block nested inside
the reserved _metadata wrapper:
{
"box2_patient_name": "MARIA GARCIA",
"box1a_insured_id": "ZGP934771201",
"box21a_diagnosis_code": "E11.9",
"box31_signature": "",
"_metadata": {
"uuid": "…",
"form_id": "cms_1500_standard_02_12",
"seed": 42,
"fill": {
"scenario": "payer-intake",
"stages": {
"box2_patient_name": "prefilled",
"box1a_insured_id": "prefilled",
"box21a_diagnosis_code": "prefilled",
"box31_signature": "blank"
}
}
}
}
stages maps every field id to its stage (prefilled / completed /
blank); scenario is the name the document drew.
Validation. The whole submission is rejected with
422 code=invalid_fill_scenarios (the message names the offending
scenario/selector) if: more than 20 scenarios are supplied; a name is
missing, malformed, or duplicated; a weight is non-positive or non-finite; a
default_stage is anything but "completed" / "blank"; a bare selector, a
pin key, or a values key names a field id that exists on none of the
requested forms; a concept: glob matches no field on any requested form; or a
field is driven to both prefilled/pinned and blank in the same scenario.
Contradictions are always errors, never silently resolved.
v1 restriction — mutually exclusive with
coherence_mode: "shuffled". Bothfill_scenariosand shuffled coherence drive the same per-field value override channel, so combining them is rejected with422 code=invalid_fill_scenarios. Usefill_scenarioswith the defaultcoherentmode (orrandom); shuffled-coherence ablations cannot also carry a fill policy in v1.
List Jobs
GET /api/v1/jobs
GET /api/v1/jobs?limit=20&status=completed
Returns your generation jobs, newest first. Supports cursor-based pagination.
Scope required: read
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Results per page (1–100) |
cursor | string | — | Pagination cursor from a previous response |
status | enum | — | Filter by status (accepted values below) |
form_id | string | — | Filter to jobs whose primary form is this form id |
Accepted status values: pending, generating, completed, failed,
expired, canceling, canceled, skipped.
Any other value is rejected with 422 code=validation_error, and the message
names the accepted values:
{
"error": {
"code": "validation_error",
"message": "query.status: Input should be 'pending', 'generating', 'completed', 'failed', 'expired', 'canceling', 'canceled' or 'skipped'"
}
}
Pagination:
The response meta includes:
has_more—trueif more results exist beyond this pagenext_cursor— pass this as thecursorparameter to get the next page
# First page
curl "https://symagedocs.ai/api/v1/jobs?limit=10" \
-H "Authorization: Bearer sk_live_YOUR_KEY"
# Next page (using next_cursor from the previous response)
curl "https://symagedocs.ai/api/v1/jobs?limit=10&cursor=2026-03-24T10:00:00+00:00" \
-H "Authorization: Bearer sk_live_YOUR_KEY"
Get Job Status
GET /api/v1/jobs/{job_id}
Returns detailed status for a specific job.
Scope required: read
Response fields:
| Field | Type | Description |
|---|---|---|
job_id | string | Job identifier |
status | string | pending, generating, completed, failed, expired, canceling, canceled, or skipped |
progress | float | Completion progress (0.0 to 1.0) |
form_id | string | Form used for generation |
form_name | string | Human-readable form name |
quantity | integer | Number of documents requested |
output_formats | string[] | Requested output formats |
credits_required | integer | Total credits for this job |
credits_charged | integer | Credits actually charged |
seed | integer | Seed used (null if random) |
error | string | Error message if job failed (null otherwise) |
created_at | string | ISO 8601 creation timestamp |
completed_at | string | ISO 8601 completion timestamp (null if not done) |
generation_time_ms | integer | Total generation time in milliseconds (null if not completed) |
worker_count | integer | Number of workers used for this job |
peak_workers | integer | Peak concurrent workers during generation |
throughput_docs_per_min | float | Generation throughput in documents per minute |
Download Job Output
GET /api/v1/jobs/{job_id}/download/{format}
Downloads the generated output in the specified format. The job must be in a terminal status: completed, or — so partial output is recoverable — canceled, failed, or expired. Downloading a job that is still running returns 409.
Scope required: read
Path parameters:
| Parameter | Values | Description |
|---|---|---|
format | dataset, csv, json | One format per request. Per-format downloads (pdf_typed, pdf_handwritten, coco, bio, yolo, funsd, donut, paperlives, hf_datasets, coco_layout, funsd_layout) have been retired in favor of the unified dataset. Requesting any of those returns 400 code=invalid_format. bundle, the pre-rename token for dataset, is still accepted by this endpoint during the migration soak but is deprecated and rejected by the Python SDK — use dataset. |
Response: Binary file download.
datasetreturns a single ZIP archive containing every artifact the job produced (PDFs, PNGs, JSON, CSV, and any ML annotations that were requested) plus amanifest.jsondescribing the contents.jsonreturns the aggregated structured data as JSON.csvreturns the aggregated data as a CSV file.
Very large jobs are stored as a sharded dataset with no single archive; download/dataset then returns 409 with a pointer to the dataset manifest (GET /jobs/{job_id}/dataset/manifest), from which the preview and shard archives can be fetched individually. The Python SDK's generate.download_dataset() handles both layouts automatically.
Two top-level artifacts normally sit beside the archive(s) of a finalized job, single or sharded, and are fetched individually rather than unzipped:
GET /api/v1/jobs/{job_id}/dataset/manifest—manifest.json, the machine-readable descriptor (layout, counts, completeness, shard list).GET /api/v1/jobs/{job_id}/dataset/readme—README.md, the human-readable dataset card describing what the delivery contains.
Both 302 to a short-lived presigned URL, require the read scope, and return 404 if the job has not finished packaging. The README is written on a best-effort basis, so a job can occasionally finish with a manifest and no dataset card; treat a 404 on /dataset/readme as "no card", not as a failed job. The Python SDK writes both into the output directory of generate.download_dataset(), and generate.download_readme(job_id, path) fetches the card on its own.
Generator provenance. A change to the generator can alter the documents a given seed produces without moving any version field in the manifest (version is the bundle format; fraud.taxonomy_version is the fraud label schema). To tell which build produced a dataset, read the top-level generator block. It is present in the top-level manifest.json, in the manifest.json inside dataset.zip / preview.zip, and in every sharded layout's shard_manifest.json; wherever it appears it describes the whole dataset, not just the documents in that one archive:
"generator": {
"scope": "dataset",
"git_shas": ["66ee1d35…"],
"render_versions": ["3f9c2a1b7d4e5f60"],
"finalizer_git_sha": "66ee1d35…",
"stamps_seen": 4,
"stamps_unreadable": 0,
"guard_key": "render_version"
}
git_shas— every commit that produced a per-build record for this job (full 40-character SHAs), sorted. Normally one entry. Two or more means at least two builds worked on the job, which usually means it straddled a deploy and the delivered documents mix both revisions. Usually, not always: when a part of the job is retried, the superseded attempt leaves its record behind while its documents are replaced by the retry's, so a second commit can be listed whose work is no longer in what you received. Read it as at least two builds were involved, not as proof that two are present in the bytes. The guard covers an enumerated rendering contract, including fraud fabrication, but simulated data and computed fields remain outside it. A multi-SHA dataset can contain documents from both sides of a data-generation change, which is why it is worth acting on.render_versions— the rendering-contract hashes the documents were rendered under. One entry whenguard_keyis"render_version". It can hold several whenguard_keyis"git_sha": one stamp without a render version makes the uniformity check fall back to comparing commits, and nothing then constrains the other stamps' render versions.finalizer_git_sha— the commit of the worker that packaged the archive and wrote the manifest. It is not necessarily one ofgit_shas.scope— always"dataset". Unlikecountsandsplits, which a preview archive scopes to its own contents, this block always describes the whole dataset. So a preview or a single shard from a straddling job reports both commits even if every document in that one archive came from one of them.stamps_seen/stamps_unreadable— how many per-build records the job had, and how many of them could not be used (an unreadable record, or one carrying no commit).stamps_unreadable > 0meansgit_shasmay be incomplete — a dropped record is exactly how a straddle would look like a single build. Fail closed on it if you need certainty.stamps_seenis a snapshot from one listing of the records, taken after the dataset's files were fetched and immediately before its manifest was written; a record that lands after that listing — for example a chunk redelivered onto a different build partway through packaging — is the one case this count does not reflect (see When the listing finds more than one build, below).guard_key— which field the platform's own uniformity check compared:"render_version","git_sha", ornullwhen it had no usable records and let the bundle through leniently."dev"in place of a commit marks a local, non-release build; every local build reports the same"dev", so it identifies no particular code. The free sample pack is built this way.stamps_seen: 0means the job predates per-build records. That is different fromstamps_seen: 3, stamps_unreadable: 3, which means the records existed and could not be read.
Adding this block moved every sharded layout's shard_manifest.json from schema_version "1.0.0" to "1.1.0". The top-level manifest's own schema_version is unchanged: the block is additive there and older readers ignore it.
1.1.0 covers two additions, so do not read it as a guarantee of either. The same version bump carries this generator block and the fraud fragment's fraud.target_policy / fraud.scenario_policies, which shipped separately; a build made between them writes "1.1.0" with one of the two present and not the other. Both are additive and neither removed anything, so the version is safe to pin — but discriminate on the keys, not the version: test for generator and for fraud on the fragment itself before reading either. fraud is in any case absent on every non-fraud job, at every version.
Where the block can be missing. Every archive a job delivers carries it, and so does the top-level manifest.json — with one known exception: writing that top-level manifest.json is best-effort for a small (single-archive) job, so a job can occasionally finish with dataset.zip and no top-level manifest at all, and a retry does not repair it. The copy inside dataset.zip is unaffected; read it from there when /dataset/manifest returns 404 on a completed job.
When the listing finds more than one build. Every copy of the block for one dataset — the in-zip manifest, the top-level manifest, and every shard fragment — is now built from the same single listing of the per-build records, taken once, after the archive's downloads and immediately before its manifest is written. Publishing that listing is a claim only one finalizer can hold for a job at a time, so in normal operation the copies agree — a job's packaging step running more than once and each run listing the records at its own moment, which used to leave the top-level and in-archive copies disagreeing, is the case this closes. The claim is not unconditional: a finalizer that stalls past its lease can lose it to a peer, and an operator restarting a job while a previous run for it is still in flight can start a second, unfenced publication. In those residual cases the copies can still disagree, so it is still worth comparing them if you don't trust a job's history.
That single listing can still see evidence an earlier, pre-download check missed — for example a chunk redelivered onto a different render build while the archive was being assembled. When it does, the job does not ship with the inconsistency: it fails outright, with a full refund, rather than completing and publishing a generator block the earlier check never proved. Recovery is to regenerate the job once the underlying build issue is resolved. Read two or more git_shas on a completed job as at least two builds produced records for this job — grounds to have refused, not proof that two builds are present in the bytes you received, since the same evidence can also come from a retried part of the job whose superseded attempt left its record behind while its documents were replaced.
How to compare two datasets. They came from the same generator code when their git_shas are equal, non-empty, contain no "dev", and both have stamps_unreadable: 0. Anything else — an empty list, an unreadable record, a "dev" entry — means the artifact cannot answer the question, and you should treat the two as possibly different rather than the same. The block is advisory provenance, not a merge gate: nothing refuses a merge on it, and it does not replace fraud.taxonomy_version as the value fraud consumers pin on. Manifests written before this field existed do not have it at all.
A deploy in the other direction fails the job instead. A fraud job stores the plan that decides its delivered filenames when its first chunk starts, and every later chunk checks the plan it computes against the stored one. A job in flight across a forward deploy keeps the stored plan, which is how it can end up straddling two git_shas as described above. A rollback is not symmetric: a worker on the older build cannot interpret a plan a newer build wrote, so it fails the job rather than finalizing it under a plan it does not understand. A rollback while a large fraud job is running therefore costs you the job, not a silently mixed dataset. Re-submit once the platform has settled.
Dataset splits. Every dataset is partitioned into train / validation /
test at fixed 80 / 10 / 10 ratios. The API exposes no way to change them —
there is no split-ratio key on /generate. Each item (identity) is assigned to
exactly one split, deterministically from the job's seed, so the same
quantity with the same explicit seed always produces the same
assignment. A request that omits seed draws a random one — the split sizes
are unchanged, but which item lands in which split is not reproducible. The
seed actually used is reported back in the manifest. Sizes are floor-allocated:
train = floor(quantity * 0.8), validation = floor(quantity * 0.1), and
test takes the remainder, with train forced to at least one item. Two
consequences to plan for:
validationis empty for anyquantitybelow 10. Atquantity: 5the allocation is train 4, test 1, validation 0.testis non-empty at everyquantityof 2 or more, and empty only atquantity: 1, where the single item is the whole training split.
A split that floors to zero items is omitted, not shipped empty — a
zero-length subset breaks standard training loops. It is absent from
manifest.splits, and its name is listed in the manifest's top-level
omitted_splits array ([] when all three shipped). The YOLO surfaces follow:
annotations/yolo/data.yaml and annotations/yolo/{train,val,test}.txt are
written only for splits that shipped — the same on the screenshot surface's own
annotations/screenshot/yolo/ tree. The dataset card (README.md) carries a
"Split sizing note" only when something was omitted.
Read manifest.splits rather than assuming three split files exist. Treat it
as the authority, and do not treat data.yaml as an equivalent view of it:
the YOLO surfaces are keyed on images, so a split that shipped items but
produced no image for them is present in manifest.splits and absent from
data.yaml. data.yaml answers "which splits can I train on from this YOLO
tree"; manifest.splits answers "which splits did this job ship". Worked examples: quantity: 5 ships train 4 and test 1 with
omitted_splits: ["validation"]; quantity: 10 ships train 8, validation
1, test 1 with omitted_splits: [].
For per-file access (e.g., to fetch a single PDF), use GET /jobs/{job_id}/downloads to obtain presigned S3 URLs.
Example:
# Download every artifact as a single ZIP
curl https://symagedocs.ai/api/v1/jobs/JOB_ID/download/dataset \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-o output.zip
# Download JSON data
curl https://symagedocs.ai/api/v1/jobs/JOB_ID/download/json \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-o data.json
Job Visibility and Account Scoping
Jobs belong to the account that created them. Every job endpoint —
GET /api/v1/jobs, GET /api/v1/jobs/{job_id}, the download and per-item
endpoints, and cancel — is filtered to the jobs owned by the account behind
the API key you present. There is no cross-account or organization-wide view.
Two things create a job, and both stamp the owning account:
| Created via | How | Owner |
|---|---|---|
web_session | Submitted from the web app while signed in | The signed-in user's account |
api_key | POST /api/v1/generate with a Bearer key | The account the key belongs to |
An API key sees a job only when the key belongs to the same account that created it. A key you hold sees your own web-app jobs; it does not see jobs created in someone else's browser session or with someone else's key.
A job that exists but belongs to another account returns the same 404 as a
job id that never existed — existence is never leaked across accounts:
{"error": {"code": "job_not_found", "message": "Job not found in this account"}}
So a 404 on a job id you were given by someone else usually means the job is
in their account, not that it is gone.
To hand a partner a job they can pull themselves, create it with their
API key: have them issue a key on their own account and submit the job with
POST /api/v1/generate using that key. The resulting job is theirs, and every
/api/v1/jobs/{job_id}/… call with that key works. A job submitted from your
browser session, or with your key, can never be listed or downloaded with
theirs — you would have to download it yourself and transfer the archive out of
band.
The bundle's manifest.json records which channel produced the dataset in its
top-level created_via field ("web_session" or "api_key"); the field is
absent on jobs created before the field existed.
Download links are short-lived. The URLs these endpoints hand back are presigned S3 links with a fixed lifetime, independent of the 48-hour retention window on the job's output (the per-item endpoints are the one exception to that window — see below):
| Endpoint | Presigned URL lifetime |
|---|---|
GET /api/v1/jobs/{job_id}/downloads (and the per-item download endpoint) | 1 hour (expires_in: 3600) |
GET /api/v1/jobs/{job_id}/download/dataset (302 redirect) | 15 minutes |
GET /api/v1/jobs/{job_id}/dataset/manifest, /dataset/readme, /dataset/preview, /dataset/annotations, /dataset/shards/{n} (302 redirects) | 15 minutes |
Re-request the endpoint to mint a fresh URL; sharing a presigned URL with a partner is not a substitute for the job living in their account.
Presigned Downloads
Get Job Download URLs
GET /api/v1/jobs/{job_id}/downloads
Returns presigned S3 URLs for all output files of a completed job. Files can be downloaded directly from S3 without proxying through the server.
Scope required: read
Response:
{
"data": {
"job_id": "550e8400-...",
"files": [
{
"filename": "irs_w2_2025_abc123.pdf",
"url": "https://s3.amazonaws.com/...presigned",
"content_type": "application/pdf",
"item_index": 0
}
],
"expires_in": 3600
}
}
URLs expire after 1 hour. Request new URLs if needed.
Cancel a Job
POST /api/v1/jobs/{job_id}/cancel
Cancels a running job. Items already rendered when the cancel takes effect remain on storage and can be downloaded via the dataset download endpoint. Idempotent: cancelling a job that's already CANCELING / CANCELED returns 200 with the current state. Cancelling a terminal-but-not-canceled job (COMPLETED / FAILED / EXPIRED) returns 409.
Scope required: generate
Response: standard envelope with {"job_id", "status"}. Cancel is server-authoritative: the API drains the in-flight work and finalizes the cancel inline, so status is normally canceled whether the job was still PENDING or already GENERATING. canceling appears only when the server could not finalize inline; in that case the response also includes a note field — "Cancellation in progress; will complete within 10 minutes." — indicating a background reconciler will converge the job to canceled within that SLA.
Refunds: Cancelling a PENDING job (no work performed yet) refunds the full credit debit. Cancelling a GENERATING job refunds only the unspent portion (prorated) — credits already consumed by items rendered before the cancel took effect are not returned. Either way the refund happens automatically.
Idempotency-Key Header
POST /api/v1/generate accepts an optional Idempotency-Key header. When the same (user, key) pair is seen again within 24 hours the original job_id is returned and credits are not debited a second time. Deduplication is scoped to your user account, not the individual API key — a retry sent with a different key you own still matches. Useful for safe network retries.
The key may be at most 255 characters; an empty or longer key returns 400 code=invalid_idempotency_key.
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Idempotency-Key: my-retry-safe-key-001" \
-H "Content-Type: application/json" \
-d '{"form_id": "irs_w2_2025", "quantity": 100}'
The replay response sets meta.idempotent_replay = true and meta.credits_charged = 0.
Per-Item Endpoints
Per-item training-data access lives on the /jobs surface:
| Endpoint | Description |
|---|---|
GET /api/v1/jobs/{job_id}/items | Cursor-paginated list of per-item records. Each item carries presigned download URLs. |
GET /api/v1/jobs/{job_id}/items/{item_id}/download | Presigned URLs for a single item's files. |
Not subject to the 48-hour download window. Unlike every other download
surface on a job (/download/{format}, /downloads, /dataset/*), these two
endpoints do not expire 48 hours after job completion. They stay available
until the underlying files are deleted by the S3 bucket lifecycle rule: 7 days
after upload. This is intentional, not a bug — do not rely on it as a permanent
archive.
Raw Identities
Generate Identities
POST /api/v1/identities
Generates synthetic identities as raw JSON — no form rendering, no PDF output. Useful for testing, schema exploration, or when you need identity data without filled documents.
Scope required: generate
Request body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
quantity | integer | no | 1 | Number of identities (1–10,000) |
config | object | no | null | Generation configuration |
seed | integer | no | null | Seed for reproducibility |
Response: The standard envelope with data containing an array of identity objects. Each identity has fields like first_name, last_name, ssn, dob, address, employer information, and more.
Example:
curl -X POST https://symagedocs.ai/api/v1/identities \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"quantity": 5, "seed": 42}'
Account
Get Credit Balance
GET /api/v1/account/balance
Returns your credit usage totals.
Scope required: account
Response fields:
| Field | Type | Description |
|---|---|---|
credits_used | integer | Total credits charged across all completed jobs |
credits_allocated | integer | Total credits reserved across all jobs (including pending) |
Get Usage Summary
GET /api/v1/account/usage?days=30
Returns a usage summary for the specified period.
Scope required: account
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
days | integer | 30 | Number of days to summarize (1–365) |
Response fields:
| Field | Type | Description |
|---|---|---|
period_days | integer | Period covered |
total_jobs | integer | Number of jobs created |
total_items_generated | integer | Total documents generated |
total_credits_used | integer | Total credits charged |
jobs_by_status | object | Job count by status (completed, failed, etc.) |
Webhooks
Webhooks let you receive real-time HTTP notifications when events occur — no polling required. Register a URL, and SymageDocs will POST a signed JSON payload whenever a subscribed event fires.
Events
| Event | Fires when |
|---|---|
job.completed | A generation job finishes successfully (includes presigned download URLs) |
job.failed | A generation job fails |
Inline Registration via webhook_url (deprecated)
Deprecated. The
webhook_urlparameter still works but will be removed in a future major version. Prefer pre-registering webhooks viaPOST /api/v1/webhooks(below).
Instead of pre-registering webhooks, pass a webhook_url parameter when creating a job:
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"form_id": "irs_w2_2025",
"quantity": 10,
"webhook_url": "https://your-server.com/hooks"
}'
A temporary webhook subscription is auto-created for job.completed and job.failed. This counts against the per-user webhook cap (see below): if you are already at the cap, the job is rejected with 422, and the credit debit for the job is refunded.
Register a Webhook
POST /api/v1/webhooks
Auth: Bearer API key — the same Authorization: Bearer sk_live_... header as every other /api/v1 endpoint. Your identity is resolved from the key itself. (The web UI manages the same webhooks through its own session-authenticated internal surface.)
Request body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
url | string | yes | — | HTTPS endpoint to receive events (max 2,048 chars) |
events | string[] | no | ["job.completed", "job.failed"] | Event types to subscribe to (the two events above) |
description | string | no | null | Optional label (max 500 chars) |
Response:
{
"webhook_id": "550e8400-...",
"signing_secret": "whsec_a1b2c3d4e5f6...",
"secret_prefix": "whsec_a1b2c3d4",
"url": "https://example.com/hook",
"events": ["job.completed", "job.failed"],
"description": null,
"created_at": "2026-03-26T12:00:00+00:00"
}
Important: The
signing_secretis shown once at creation. Store it securely — you'll need it to verify payload signatures.
Per-user cap: Each user may have at most 20 active webhooks. Registering one beyond the cap returns
422. Disable or delete an existing webhook to free a slot.
List Webhooks
GET /api/v1/webhooks
Returns all active webhooks for the current user. The full signing secret is never included — only the secret_prefix for identification.
Update a Webhook
PATCH /api/v1/webhooks/{webhook_id}
Update a webhook's URL, subscribed events, description, or active status. All fields are optional — only include what you want to change.
Request body:
| Field | Type | Description |
|---|---|---|
url | string | New endpoint URL |
events | string[] | New event subscriptions |
description | string | New description |
is_active | boolean | Enable or disable delivery |
Disable a Webhook
DELETE /api/v1/webhooks/{webhook_id}
Soft-deletes the webhook. It stops receiving events immediately but remains in the database for audit purposes.
The Bearer surface also exposes POST /api/v1/webhooks/{webhook_id}/rotate (rotate the signing secret), GET /api/v1/webhooks/{webhook_id}/deliveries (paginated delivery history), POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry, and POST /api/v1/webhooks/{webhook_id}/test (fire a test event) — these mirror what the Python SDK's client.webhooks resource calls.
Payload Format
Every webhook delivery is a POST request with a JSON body:
{
"event": "job.completed",
"timestamp": "2026-03-30T12:05:00+00:00",
"webhook_id": "550e8400-...",
"data": {
"job_id": "661e9500-...",
"status": "completed",
"form_id": "irs_w2_2025",
"quantity": 100,
"credits_charged": 2000,
"completed_at": "2026-03-30T12:05:00+00:00",
"download_urls": [
{ "filename": "irs_w2_2025_abc123.pdf", "url": "https://s3...presigned" },
{ "filename": "irs_w2_2025_abc123.json", "url": "https://s3...presigned" }
]
}
}
Headers included with every delivery:
| Header | Description |
|---|---|
X-Symagedocs-Signature | HMAC-SHA256 hex digest of the raw body |
X-Symagedocs-Event | Event type (e.g., job.completed) |
X-Symagedocs-Delivery | Unique delivery ID for deduplication |
Verifying Signatures
Verify the X-Symagedocs-Signature header to ensure the payload came from SymageDocs and wasn't tampered with:
import hashlib, hmac
def verify_webhook(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
const crypto = require("crypto");
function verifyWebhook(body, signature, secret) {
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Retry Policy
Failed deliveries (non-2xx response or network error) are retried up to 3 times with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 10 seconds |
| 2nd retry | 60 seconds |
| 3rd retry | 300 seconds (5 minutes) |
After all retries are exhausted, the delivery is marked as failed. Your webhook remains active and will continue receiving future events.
Tabular Generation
The tabular API generates structured CSV/JSON data from either a natural-language description or an explicit column schema. It supports 50+ column types including identity-derived fields (names, SSNs, addresses) and synthetic fields (random numbers, enums, patterns).
Parse NL Description
POST /api/v1/tabular/parse
Converts a natural-language description into a structured column schema using an LLM with heuristic fallback.
Scope required: generate
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
prompt | string | yes | Natural language description of desired columns (1–2,000 chars) |
Example:
curl -X POST https://symagedocs.ai/api/v1/tabular/parse \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "first name, last name, age, city, state, annual salary"}'
Response:
{
"data": {
"columns": [
{ "name": "first_name", "type": "text_first_name", "constraints": {} },
{ "name": "last_name", "type": "text_last_name", "constraints": {} },
{ "name": "age", "type": "identity_age", "constraints": {} },
{ "name": "city", "type": "address_city", "constraints": {} },
{ "name": "state", "type": "address_state", "constraints": {} },
{
"name": "annual_salary",
"type": "number_integer",
"constraints": { "min": 20000, "max": 200000 }
}
],
"parser_type": "llm",
"model": "claude-haiku-4-5-20251001"
},
"meta": { "request_id": "...", "count": 6 }
}
You can modify the returned columns (rename, remove, adjust constraints) before passing them to the generate endpoint.
Generate Tabular Data
POST /api/v1/tabular/generate
Creates an async tabular generation job from an explicit column schema. Poll the status endpoint for progress.
Scope required: generate
Request body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
columns | object[] | yes | — | Column definitions (1–50 columns, see below) |
quantity | integer | no | 100 | Number of rows (1–10,000) |
output_formats | string[] | no | ["csv"] | Output formats: csv, json |
seed | integer | no | null | Seed for reproducibility |
Column definition:
Each column is an object with:
| Field | Type | Description |
|---|---|---|
name | string | Column header name |
type | string | Column type (see common types below) |
constraints | object | Type-specific parameters (min, max, values, weights, etc.) |
Common column types:
| Type | Description | Constraints |
|---|---|---|
text_first_name | First name from synthetic identity | — |
text_last_name | Last name | — |
text_full_name | Full name | — |
ssn | SSN (###-##-####) | — |
identity_age | Age in years | — |
identity_gender | Gender (M/F) | — |
address_city | City | — |
address_state | State abbreviation | — |
address_zip | ZIP code | — |
phone | Phone number | — |
email | Email address | — |
number_integer | Random integer | min, max, distribution_type, distribution_params |
number_decimal | Random decimal | min, max, decimals |
custom_enum | Weighted random choice | values: [...], weights: [...] |
custom_mask | Pattern-based string | mask (A=upper, a=lower, 9=digit, X=any) |
Example:
curl -X POST https://symagedocs.ai/api/v1/tabular/generate \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"columns": [
{"name": "first_name", "type": "text_first_name", "constraints": {}},
{"name": "last_name", "type": "text_last_name", "constraints": {}},
{"name": "age", "type": "identity_age", "constraints": {}},
{"name": "salary", "type": "number_integer", "constraints": {"min": 30000, "max": 150000}}
],
"quantity": 1000,
"output_formats": ["csv", "json"]
}'
Response:
{
"data": {
"job_id": "550e8400-...",
"status": "processing",
"credits_required": 20
},
"meta": { "request_id": "...", "credits_charged": 20 }
}
Credits: Base rate is 1 credit per 50 rows (0.02 credits/row). A column surcharge applies only when the schema has more than 10 columns: each 500 "extra cells" (rows × columns beyond 10) costs 1 additional credit. Final cost = ceil(rows/50 + ceil(rows × max(cols−10, 0) / 500)). The example above uses 4 columns and 1,000 rows: base = 1,000/50 = 20 credits, no surcharge (4 < 10), total = 20 credits.
Get Tabular Job Status
GET /api/v1/tabular/{job_id}/status
Returns progress and ETA for a tabular generation job.
Scope required: read
Response: The payload is wrapped in the standard { "data": …, "meta": … }
envelope (see Success Responses). The fields below are
returned inside data:
| Field | Type | Description |
|---|---|---|
job_id | string | Job identifier |
status | string | processing, completed, or failed |
rows_generated | integer | Rows completed so far |
total_rows | integer | Total rows requested |
percent | float | Completion percentage (0.0–100.0) |
estimated_seconds_remaining | float | ETA in seconds (null if unavailable) |
error | string | Error message if failed (null otherwise) |
{
"data": {
"job_id": "tab_3f2a…",
"status": "processing",
"rows_generated": 1200,
"total_rows": 5000,
"percent": 24.0,
"estimated_seconds_remaining": 18.0,
"error": null
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
Download Tabular Output
GET /api/v1/tabular/{job_id}/download/{format}
Downloads the generated tabular data. The job must be in completed status. Data expires after 48 hours.
Scope required: read
Path parameters:
| Parameter | Values | Description |
|---|---|---|
format | csv, json | One format per request. |
Response: File download (text/csv or application/json).
Errors:
| Code | Meaning |
|---|---|
| 400 | Invalid format (not csv or json) |
| 404 | Job not found |
| 409 | Job not yet completed |
| 410 | Data expired (older than 48 hours) |
Pricing
These endpoints require no authentication and can be used to preview costs before creating jobs.
Get Pricing Rates
GET /api/v1/pricing/rates
Returns the current credit rate constants used for cost calculation.
Authentication: None required.
Response:
{
"csv": {
"rows_per_credit": 50,
"extra_column_threshold": 10,
"extra_cells_per_credit": 500
},
"pdf_typed": {
"base_credits": 20,
"field_threshold": 25,
"surcharge_per_band": 2,
"band_size": 25
},
"pdf_handwritten": {
"base_credits": 40,
"field_threshold": 25,
"surcharge_per_band": 4,
"band_size": 25
},
"pdf_filled": {
"base_credits": 12,
"field_threshold": 25,
"surcharge_per_band": 1.2,
"band_size": 25
},
"degradation_multipliers": { ... },
"rounding": {"free_threshold": 0, "minimum_credits": 1, "method": "ceil"},
"bundling": "PDF output includes CSV/JSON free"
}
Estimate Credit Cost
POST /api/v1/pricing/estimate
Estimates the credit cost for a job without creating it.
Authentication: None required.
Request body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
field_count | integer | yes | — | Number of fields/columns on the form |
output_formats | string[] | yes | — | Output formats (e.g., ["pdf_typed"]) |
record_count | integer | yes | — | Number of records/rows to generate |
degradation_profile | string | no | null | Degradation profile to apply (see valid values below) |
config | object | no | null | The statement and fraud blocks only, validated exactly as /generate validates them (422 code=invalid_statement_config / invalid_fraud_config / undeliverable_fraud_scenarios). Neither changes the quote; a fraud preset that would drop raster recipes is reported in warnings. |
When provided, degradation_profile must be one of clean, scanned, faxed, photographed, or mixed. Any other value returns 400 code=invalid_degradation_profile.
Unknown fields are rejected. Unlike
/generate, this endpoint accepts exactly the five fields above — any other key in the request body returns422. Note in particular thatdegradation_profileis a top-level field here, not nested underconfigas it is in/generate:configaccepts only thestatementandfraudblocks, and any other key under it (config.degradation_profileincluded) returns422 code=invalid_requestrather than a silently profile-free (and therefore lower) estimate.
Response:
{
"credits": 2200,
"warnings": []
}
warnings is the same list /generate returns for
this config — [] unless, say, a fraud preset would drop raster recipes.
Bundle Size Estimation
These endpoints require no authentication and can be used to preview the compressed download size of a job before submitting it. The estimates are calibrated against real staging bundles (see calibration_sample_count and calibrated_at in the response). Expected accuracy is +/- 30% for 80% of jobs.
Get Bundle-Size Rate Constants
GET /api/v1/jobs/size-rates
Returns the calibrated per-format byte constants and tier thresholds used by the size estimator.
Authentication: None required.
Response:
{
"rates": {
"pdf_typed_bytes_per_file": 518426,
"pdf_handwritten_bytes_per_file": 2240759,
"pdf_filled_bytes_per_file": 518426,
"png_typed_bytes_per_file": 1549775,
"png_handwritten_bytes_per_file": 549089,
"funsd_bytes_per_file": 5093,
"bio_bytes_per_instance": 43661,
"yolo_bytes_per_file": 1960,
"coco_bytes_per_surface": 55508,
"donut_bytes_per_row": 2703,
"ground_truth_json_bytes_per_file": 1984,
"ground_truth_csv_bytes_per_job": 4698,
"bundle_overhead_bytes_per_job": 2757,
"funsd_emit_rate": 0.858,
"bio_emit_rate": 1.0
},
"tiers": {
"small_max_bytes": 104857600,
"medium_max_bytes": 1073741824
},
"render_dpi": {
"clean": 300,
"degraded": 200
},
"expected_accuracy": "+/- 30%",
"calibration_sample_count": 296,
"calibrated_at": "2026-05-08"
}
The render_dpi block reports the source-of-truth raster DPI the bundle ships at for each condition: clean renders are rasterized at a higher DPI than degraded ones (degradation noise and warp inflate the payload, so a lower DPI is used).
Estimate Bundle Download Size
POST /api/v1/jobs/size-estimate
Estimates the compressed zip download size for a job based on its forms, page counts, output formats, and record count.
Authentication: None required.
Request body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
field_counts | integer[] | yes | — | Number of fields on each form (one entry per form) |
page_counts | integer[] | yes | — | Number of pages on each form (must match field_counts length) |
output_formats | string[] | no | [] | Requested output format tokens (e.g., ["pdf_typed", "bio"]) |
record_count | integer | yes | — | Number of identities to generate (>= 0) |
degradation_profile | string | no | null | Optional degradation profile (clean, scanned, faxed, photographed, mixed). Degraded renders are materially larger, so supplying the profile yields a more accurate estimate; omit it for the clean-path estimate. |
config | object | no | null | The statement and fraud blocks only, validated exactly as /generate validates them (422 code=invalid_statement_config / invalid_fraud_config / undeliverable_fraud_scenarios). Any other key under config — degradation_profile is top-level here — returns 422 code=invalid_request. Neither block changes the estimate; a fraud preset that would drop raster recipes is reported in warnings. |
field_counts and page_counts must have the same length (>= 1). A length mismatch returns 422.
When provided, degradation_profile must be one of clean, scanned, faxed, photographed, or mixed; any other value returns 400 code=invalid_degradation_profile, as on /pricing/estimate.
Unknown or rejected format tokens (e.g. "pdf_unicorn") return 400 with code=invalid_output_format.
Response:
{
"total_bytes": 5255252,
"human_readable": "~5.0 MB",
"tier": "small",
"breakdown": {
"pdf_typed": 5184260,
"pdf_handwritten": 0,
"pdf_filled": 0,
"png_typed": 0,
"png_handwritten": 0,
"funsd": 43697,
"bio": 0,
"yolo": 0,
"coco": 0,
"donut": 0,
"ground_truth": 19840,
"overhead": 7455
},
"expected_accuracy": "+/- 30%",
"calibration_sample_count": 296,
"calibrated_at": "2026-05-08",
"notes": [],
"warnings": []
}
Tier values:
| Tier | Range |
|---|---|
small | < 100 MB |
medium | 100 MB to < 1 GB |
large | >= 1 GB |
Warnings field: the same list /generate returns for this config — [] unless, say, a fraud preset would drop raster recipes.
Notes field: When record_count is 0, notes includes "record_count = 0; size shown is per-job overhead floor." and total_bytes reflects only the fixed per-job overhead.
FUNSD behavior: FUNSD ground-truth files are always included in the dataset zip when any render format (pdf_typed, pdf_handwritten, png_typed, png_handwritten) is requested. The funsd breakdown entry will be non-zero whenever a render format is present, regardless of whether funsd appears in output_formats.
Health Check
GET /api/v1/health
Lightweight liveness probe for external monitoring (ALB, Pingdom, StatusCake). No authentication required.
Response:
{
"status": "healthy",
"git_sha": "a1b2c3d"
}
git_sha is the commit the live backend image was built from ("dev" when unset locally). Use it to confirm which backend image is serving traffic after a deploy.
API Key Management
Manage API keys from the web UI. Key management lives on the internal surface (/api/internal/*), which is session-authenticated through the web application's backend proxy — the proxy injects the x-user-id and x-internal-secret headers. These endpoints are not Bearer-authenticated and are not intended to be called directly from outside the web UI; a raw request without the internal shared secret is rejected.
Create an API Key
POST /api/internal/api-keys
Creates a new API key. The raw key is returned once — store it securely.
Request body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | — | Key label (1–200 chars) |
scopes | string[] | no | ["generate", "read", "account"] | Scopes for this key |
Response:
{
"key_id": "abc-123",
"api_key": "sk_live_a1b2c3d4e5f6...",
"prefix": "sk_live_a1b2c3d4",
"name": "My Key",
"scopes": ["generate", "read", "account"],
"created_at": "2026-03-26T12:00:00+00:00"
}
List API Keys
GET /api/internal/api-keys
Returns all API keys for the current user. Full keys are never included — only the prefix for identification.
Response fields per key:
| Field | Type | Description |
|---|---|---|
key_id | string | Key identifier |
prefix | string | First 16 characters of the key |
name | string | Key label |
scopes | string[] | Granted scopes |
tier | string | Rate limit tier |
last_used_at | string | Last usage timestamp (null if never used) |
created_at | string | Creation timestamp |
is_active | boolean | Whether the key is active |
Revoke an API Key
DELETE /api/internal/api-keys/{key_id}
Permanently disables a key. Takes effect immediately and cannot be undone.
Rotate an API Key
POST /api/internal/api-keys/{key_id}/rotate
Creates a new key and revokes the old one atomically. The new key inherits the old key's name and scopes.
Response:
{
"old_key_id": "abc-123",
"new_key": {
"key_id": "def-456",
"api_key": "sk_live_x9y8z7w6...",
"prefix": "sk_live_x9y8z7w6",
"name": "My Key",
"scopes": ["generate", "read", "account"],
"created_at": "2026-04-01T12:00:00+00:00"
}
}
Training Data (ML)
/generate jobs can emit labeled training data for document AI models. Pass label_scheme inside the request config, request the ML output formats you need (bio, coco, donut, yolo) alongside a render format (pdf_typed/pdf_handwritten), and pull per-item annotations from the /jobs/{id}/items surface.
Label schemes:
| Scheme | Description |
|---|---|
field_id | Labels use form field IDs (e.g., employee_ssn) |
semantic_concept | Labels use semantic concepts (e.g., social_security_number) |
nist3 | Labels use NIST Type-3 categories |
field_type | Labels use field types (e.g., ssn, currency, text) |
Workflow:
1. POST /generate {form_id, quantity, output_formats: ["pdf_typed", "bio", "coco"],
config: {label_scheme: "nist3"}}
2. GET /jobs/{job_id} → poll until status = "completed"
3. GET /jobs/{job_id}/items → enumerate items (paginated)
4. GET /jobs/{job_id}/items/{item_id}/download → presigned URLs (PDF, JSON, COCO, BIO)
For a complete working example, see the Python SDK's examples/train_kie_model.py, which demonstrates this workflow end to end: creating a job with NIST3 labels and BIO output, iterating training examples via iter_training_examples, and fetching word annotations. The SDK README's Training data section documents the same surface from the client side.
Anti-pattern: list-valued fields in
semantic_conceptscheme. When two or more form fields share the samesemantic_conceptwithout being a checkbox selection group (see below), the Donut exporter collapses their values into a JSON array, and the BIO exporter emits the same B/I tag pair for every token — destroying positional information. This is an anti-pattern. If a form has multiple fixed-position slots for what is conceptually the same data (e.g., the four W-2 Box 12 rows), each slot must have its own per-slot concept (e.g.,tax.box12a.code,tax.box12b.code, …).
Checkbox selection groups. Radio-style checkbox groups (one concept, several option boxes — e.g., the CMS-1500 insurance type with seven boxes, or patient sex M/F) are the deliberate exception to the rule above. Each option box carries the shared concept plus an option name, and the exporters emit the selection rather than per-box values. In Donut, the concept's leaf holds the selected option's name as a string (
"medicare"), an empty string when no box is marked, or a list of names in field order when several boxes are marked (check-all-that-apply). Plain yes/no question pairs still emit a single JSON boolean. In BIO, each option box's tag carries the option name as a suffix (B-insurance.type/medicare), the same scheme used for/yesand/noon binary pairs.
Donut consumer conventions
Each row in metadata.jsonl has a ground_truth field containing a JSON string of the form {"gt_parse": {...}}. The consumer — typically a HuggingFace Donut training script — is responsible for serializing gt_parse into Donut's tagged-token sequence. Two conventions apply.
Nested list values. When multiple form fields resolve to the same leaf path in the gt_parse tree (e.g., per-slot W-2 Box 12 amounts that share a parent path), DonutExporter collects their values into a JSON array at that leaf. The canonical Donut recursive flattener (used in clovaai/donut's token2json round-trip) emits one sibling tagged block per list element:
// gt_parse input (list-valued leaf)
{ "box12": { "amount": ["1200.00", "450.00"] } }
// Donut token sequence (sibling blocks per element)
<s_box12><s_amount>1200.00</s_amount><s_amount>450.00</s_amount></s_box12>
This is correct Donut convention. A consumer should not assume scalar values at every leaf.
Task-prefix wrapping token. Donut requires a per-task start token (e.g., <s_w2>, <s_invoice>) wrapping every gt_parse sequence. symagedocs does not emit this token — metadata.jsonl rows contain only the bare gt_parse object. The consumer must prepend the task token before serializing the sequence. Derive it from the request's form_id (e.g., irs_w2_2025 → <s_irs_w2_2025>, or <s_w2> for a coarser scheme). The token must also be added to the tokenizer's additional_special_tokens and the model's resized embeddings — standard Donut fine-tuning setup.
Credit Pricing
Credits are charged when a generation job is created. The cost depends on the form's field count, the output format, and the quantity.
Pricing Formulas
Typed PDF (standard):
cost_per_document = 20 + ceil(max(field_count - 25, 0) / 25) * 2
total_credits = cost_per_document * quantity
Handwritten PDF:
cost_per_document = 40 + ceil(max(field_count - 25, 0) / 25) * 4
total_credits = cost_per_document * quantity
Filled PDF (pdf_filled, fillable forms only):
Priced in a tier between typed and CSV at 60% of the typed rate. Values are delivered in live, editable AcroForm widgets with no rasterization.
cost_per_document = 12 + ceil(max(field_count - 25, 0) / 25) * 1.2
total_credits = ceil(cost_per_document * quantity)
When a job also requests pdf_typed or pdf_handwritten, the higher tier is billed and the filled artifact rides free, the same bundling rule that includes CSV/JSON with any PDF.
JSON and CSV ground truth: Every dataset zip automatically includes ground_truth/json/ (per-document structured JSON) and ground_truth/data.csv (aggregated tabular CSV) at no additional cost. These are not selectable in output_formats — passing "json" or "csv" there returns 400 code=invalid_output_format.
Examples
| Form | Fields | Quantity | Formats | Credits |
|---|---|---|---|---|
| W-2 | 20 | 100 | pdf_typed | 2,000 |
| 1040 | 50 | 100 | pdf_typed | 2,200 |
| 1040 | 50 | 10 | pdf_typed | 220 |
Tabular data: Base rate is 1 credit per 50 rows. A surcharge of 1 credit per 500 extra cells applies when columns exceed 10 (extra cells = rows × (columns − 10)). Final cost = ceil(rows/50 + ceil(rows × max(cols−10, 0) / 500)).
| Data | Columns | Rows | Credits |
|---|---|---|---|
| Tabular | 5 | 1,000 | 20 |
| Tabular | 20 | 1,000 | 40 |
| Tabular | 10 | 5,000 | 100 |
| Tabular | 50 | 10,000 | 1,000 |
Use the form catalog's credit_cost field to see the per-document cost for each form at default (typed PDF) output.
Common Workflows
Batch Document Generation
# 1. Find the form you need
curl https://symagedocs.ai/api/v1/forms?category=tax \
-H "Authorization: Bearer $API_KEY" | jq '.data[] | {id, name, credit_cost}'
# 2. Create a generation job
JOB_ID=$(curl -s -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"form_id": "irs_w2_2025", "quantity": 100, "output_formats": ["pdf_typed"]}' \
| jq -r '.data.job_id')
echo "Job created: $JOB_ID"
# 3. Poll until complete
while true; do
STATUS=$(curl -s https://symagedocs.ai/api/v1/jobs/$JOB_ID \
-H "Authorization: Bearer $API_KEY" | jq -r '.data.status')
echo "Status: $STATUS"
[ "$STATUS" = "completed" ] && break
[ "$STATUS" = "failed" ] && echo "Job failed!" && exit 1
sleep 5
done
# 4. Download results — every artifact in one zip
curl https://symagedocs.ai/api/v1/jobs/$JOB_ID/download/dataset \
-H "Authorization: Bearer $API_KEY" -o w2_dataset.zip
# Or just the structured data
curl https://symagedocs.ai/api/v1/jobs/$JOB_ID/download/json \
-H "Authorization: Bearer $API_KEY" -o w2_data.json
Tabular Data Generation
# 1. Parse a natural language description into a schema
SCHEMA=$(curl -s -X POST https://symagedocs.ai/api/v1/tabular/parse \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "employee name, department, hire date, salary, city"}' \
| jq '.data.columns')
echo "Parsed schema: $SCHEMA"
# 2. Generate 5,000 rows from the schema
JOB_ID=$(curl -s -X POST https://symagedocs.ai/api/v1/tabular/generate \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"columns\": $SCHEMA, \"quantity\": 5000, \"output_formats\": [\"csv\"]}" \
| jq -r '.data.job_id')
echo "Job created: $JOB_ID"
# 3. Poll until complete
while true; do
STATUS=$(curl -s https://symagedocs.ai/api/v1/tabular/$JOB_ID/status \
-H "Authorization: Bearer $API_KEY" | jq -r '.data.status')
echo "Status: $STATUS"
[ "$STATUS" = "completed" ] && break
[ "$STATUS" = "failed" ] && echo "Job failed!" && exit 1
sleep 2
done
# 4. Download CSV
curl https://symagedocs.ai/api/v1/tabular/$JOB_ID/download/csv \
-H "Authorization: Bearer $API_KEY" -o employees.csv
Event-Driven Generation (Webhooks)
Instead of polling for job completion, register a webhook and let SymageDocs notify you:
# 1. Register a webhook (once, via the web UI or the /api/v1/webhooks endpoint)
curl -X POST https://symagedocs.ai/api/v1/webhooks \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-server.com/webhooks/symagedocs", "events": ["job.completed", "job.failed"]}'
# Save the signing_secret from the response!
# 2. Generate documents — no need to poll
JOB_ID=$(curl -s -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"form_id": "irs_w2_2025", "quantity": 100, "output_formats": ["pdf_typed"]}' \
| jq -r '.data.job_id')
echo "Job $JOB_ID created — webhook will notify when done"
# 3. Your server receives a POST to /webhooks/symagedocs with:
# {"event": "job.completed", "data": {"job_id": "...", "status": "completed", ...}}
# Verify the X-Symagedocs-Signature header, then download results.
Reproducible Generation
Use the seed parameter to generate identical output across runs:
curl -X POST https://symagedocs.ai/api/v1/generate \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"form_id": "irs_w2_2025", "quantity": 10, "seed": 12345}'
Running this request again with the same seed and quantity produces the same documents.
How the seed reaches an item — and why sibling jobs must be spaced. Under
the default coherent mode (and under shuffled, which permutes field values
but seeds identities the same way) the identity for item i is seeded
seed + i. A job therefore occupies the contiguous seed range
[seed, seed + quantity), and two jobs whose seeds are closer together than
their quantity generate the same people: item 0 of a job seeded s + 1 is
item 1 of a job seeded s. Nothing inside a single dataset shows this — each
bundle is internally distinct — so it only surfaces when you pool sibling jobs,
which is exactly what building a training corpus does.
Identity is not the only thing that repeats. The degradation draw rides the same offset, so for a form with no per-item simulated content of its own, two overlapping sibling items are the same person and the same scan/fax/photo artifacts — a duplicate in every sense a model can see.
The rule: space the seeds of jobs you intend to pool at least quantity
apart. Worked example — a job with seed: 20260910 and quantity: 250
occupies item seeds 20260910..20261159, so the next job in that release
must start at 20261160 or higher. Four 250-document jobs seeded 20260910,
20261160, 20261410, 20261660 share no identity; the same four jobs seeded
20260910..20260913 would yield 253 distinct identities across the 1,000
documents rather than 1,000.
coherence_mode: "random" does not use this offset — it mixes
(seed, item_index, form_id) — so the spacing rule above is stated for
coherent and shuffled. That is a different derivation with its own
collision profile, not a guarantee of none.
Retried identities and identity_seed_version. Some forms require an
identity with properties that only emerge from generation (a minimum income, a
spouse, dependents). When item i's first identity does not qualify, the
generator retries it with a new seed. Jobs created before this was versioned
seeded retry k as seed + i + k — item i + k's own seed — so a retried
item could come out as the same person as the item after it (same name, same
SSN) inside one bundle. Jobs created now seed each retry from a 53-bit hash
of the item's seed and the attempt number, so a retry landing on another
item's seed is astronomically unlikely.
Each job records the scheme its identities were generated with in
manifest.json under generation_params.identity_seed_version:
| Value | Meaning |
|---|---|
absent or legacy | Not confirmed v2. Retries may have been seeded seed + i + k, so a retried item may share an identity with a later item in the bundle. |
v2 | Retries seeded from a 53-bit hash. A retried item sharing another item's seed is astronomically unlikely. |
legacy is the conservative label, not a statement that every retry used
seed + i + k: the platform writes it whenever it cannot confirm that every
part of the job was generated under v2. The scheme is fixed when the job is
created, but the value records what generation can be shown to have used. A
job created while the platform is being upgraded can be generated by a worker
that predates versioning. That worker uses seed + i + k, and its manifest
has no identity_seed_version, which means legacy. Such a manifest may
carry internal identity_seed_version_assigned or
identity_seed_version_resolved fields. Ignore them and read
identity_seed_version.
A job can deliver more than one copy of manifest.json (the top-level
manifest.json and the one inside each archive). If identity_seed_version
is absent or legacy in any copy, treat the whole job as legacy.
Items that qualified first time (no retry) are the same under both schemes,
so resubmitting an older job's seed reproduces every non-retried item
exactly; only retried items change. The seed-spacing rule above still applies
to both.
Key Rotation
Rotate keys periodically for security. Rotation is done from the web UI (Account → API Keys → Rotate). The old key stops working immediately and the new key takes over. The underlying endpoint lives on the session-authenticated internal surface (see API Key Management) and cannot be called directly with a Bearer key.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 Invalid API key | Key doesn't exist or was revoked | Create a new key in the web UI |
403 Missing required scopes | Key lacks the scope for this endpoint | Create a new key with the needed scopes |
404 Job not found in this account (job_not_found) | Job doesn't exist or belongs to another account | Check the job ID; keys can only access their own account's jobs |
409 Job is not completed | Trying to download before generation finishes | Poll GET /jobs/{id} until status is completed |
429 Rate limit exceeded | Exceeded your tier's per-key request rate | Back off using the X-RateLimit-Remaining header and retry |