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.

MethodEndpointScopeDescription
GET/formsreadList available forms (filter: ?category=tax)
GET/forms/{id}readForm details with field definitions
POST/generategenerateCreate an async generation job
GET/jobsreadList your jobs (paginated: ?limit=50&cursor=...)
GET/jobs/{id}readJob status and progress
GET/jobs/{id}/download/{fmt}readDownload output (dataset, csv, or json)
GET/jobs/{id}/downloadsreadPresigned download URLs for all job output files
GET/jobs/{id}/itemsreadList per-item records for a job
GET/jobs/{id}/items/{item_id}/downloadreadPresigned URLs for one item's files
POST/jobs/{id}/cancelgenerateCancel a running job; refund unspent credits
POST/identitiesgenerateGenerate raw identities (JSON only, no forms)
GET/account/balanceaccountCredit usage totals
GET/account/usageaccountUsage summary (filter: ?days=30)
POST/tabular/parsegenerateNL description to column schema (LLM-powered)
POST/tabular/generategenerateGenerate tabular data from column schema
GET/tabular/{id}/statusreadTabular job progress and ETA
GET/tabular/{id}/download/{fmt}readDownload 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

ScopeWhat it allows
generateCreate jobs, generate identities, tabular parse/generate
readList forms, view jobs, download output, tabular status/download
accountView balance and usage

Credit Cost (per document)

OutputFormula
Typed PDF20 + ceil(max(fields - 25, 0) / 25) * 2
Handwritten PDF40 + ceil(max(fields - 25, 0) / 25) * 4
Filled PDF12 + 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/ and ground_truth/data.csv at no extra cost. They are not selectable in output_formats.

Response Envelope

{"data": ..., "meta": {"request_id": "uuid", ...}}

Common Errors

CodeMeaning
401Invalid or revoked API key
402Insufficient credits
403Key missing required scope
404Resource not found
409Job not yet completed (for downloads)
422Invalid parameter or unservable request
429Rate 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:

ScopeGrants access to
generateCreate generation jobs, generate raw identities
readList forms, check job status, download outputs
accountView 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:

ActionDescription
CreateGenerates a new key. The raw key is shown once — copy it immediately.
ListShows all your keys with their prefix, name, scopes, and last-used date. Full keys are never shown.
RevokePermanently disables a key. Takes effect immediately. Cannot be undone.
RotateCreates 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.

TierSustainedBurst
Free10 requests/second30 requests
Pro30 requests/second90 requests
Scale100 requests/second300 requests
Enterprise200 requests/second600 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:

HeaderMeaning
Retry-AfterSeconds to wait before retrying (rounded up to at least 1)
X-RateLimit-LimitYour tier's burst capacity (maximum requests in the current window)
X-RateLimit-RemainingRemaining budget within the current window (always 0 on a 429 response)
X-RateLimit-ResetUnix 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,
    ...
  }
}
  • data contains the response payload (object or array depending on the endpoint).
  • meta contains request metadata. request_id is 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

CodeMeaning
200Success
400Bad request (rejected by the endpoint itself)
401Authentication failed (invalid or revoked key)
402Insufficient credits
403Forbidden (missing required scope)
404Resource not found
409Conflict (e.g., downloading from an incomplete job)
422Invalid query/body parameter, or an unservable request
429Rate 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:

ParameterTypeDefaultDescription
categorystring—Filter by form category (e.g., tax, healthcare, business)
familystring—Filter by form family (e.g., irs_w2, irs_f1040)
limitinteger—Maximum number of forms to return (≥ 1)

Scope required: read

Response fields per form:

FieldTypeDescription
idstringUnique form identifier (use this in generation requests)
namestringHuman-readable form name
categorystringForm category (e.g., tax, healthcare, business)
yearintegerTax year or form version year
familystringForm family (e.g., 1040, w2)
entity_typestringEntity type (individual, business, healthcare)
credit_costintegerCredits per document (at default PDF output)
field_countintegerNumber 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:

FieldTypeDescription
versionstringForm definition version
page_countintegerNumber of pages in the form
fieldsarrayList of field definitions
fields[].idstringField identifier
fields[].labelstringHuman-readable field label
fields[].typestringField type (e.g., text, enum, ssn, currency)
fields[].pageintegerPage number the field appears on
fields[].semantic_conceptstringDotted semantic concept the field carries (e.g., healthcare.patient.name, person.ssn). Present only on annotated fields.
fields[].structural_rolestringDocument structural role (e.g., answer, question, header, label). Present only when set.
fields[].entity_rolestringWhich 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).

FieldTypeRequiredDefaultDescription
form_idstringconditional—Single form to generate. Mutually exclusive with form_ids.
form_idsstring[]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.
quantityintegerno1Number 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_formatsstring[]no["pdf_typed"]Output formats (see below)
configobjectnonullGeneration configuration overrides
seedintegernonullSeed for reproducible output
ink_colorstringno"blue"Ink color for handwritten output: black, blue, or red
ink_color_distributionobjectnonullDistribution 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_consistencystringno"per_document"Writer consistency: per_document or per_field
realism_levelstringno"high"Deprecated. Accepted for backward compatibility; ignored. All handwritten output is rendered at high realism.
webhook_urlstringnonullDeprecated. 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:

FormatDescription
pdf_typedFilled PDF with typed text
pdf_handwrittenFilled PDF with handwritten-style text (not available for all forms)
pdf_filledCompleted PDF whose values sit in live, editable AcroForm widgets (fillable forms only)
png_typedPer-page PNG rasterizations of pdf_typed (requires pdf_typed)
png_handwrittenPer-page PNG rasterizations of pdf_handwritten (requires pdf_handwritten)

pdf_filled delivers 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: requesting pdf_filled for a non-fillable form rejects the whole submission with 400 code=format_unsupported_for_form, and error.details.unsupported_form_ids lists every non-fillable form in the request. pdf_filled is not a render surface: it cannot satisfy the ML-format render dependency, is never paired with a PNG, and is never degraded (a degradation_profile has no effect on it). Filled PDFs are delivered under pdfs/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" in output_formats — the API will return 400 code=invalid_output_format if you do.

Unknown formats are rejected. Any output_formats token outside the documented set above is rejected with 400 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_typed must be requested together with pdf_typed, and png_handwritten must be requested together with pdf_handwritten. PNG always travels with its paired PDF; PNG-only output is not a supported configuration. Submitting png_typed without pdf_typed (or png_handwritten without pdf_handwritten) is rejected by the API with 400 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):

FormatDescription
bioToken classification (B/I/O) labels
yoloYOLOv8 object detection labels
cocoCOCO JSON object detection annotations
donutDonut 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, or png_typed — in the same request. Submitting ML formats without a render format returns 400 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 into pdfs/typed/ and pdfs/handwritten/ (plus images/{typed,handwritten}/ and the per-format annotations/<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 under pdfs/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 /generate twice with the same seed and different form_id values 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. Use form_ids to 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 per item_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. Requires quantity > 1: a single-item job cannot be shuffled (a 1-element permutation is the identity), so the API rejects shuffled with quantity <= 1 as 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 from GET /api/v1/forms, GET /api/v1/forms/{form_id} returns 404, and naming one in /generate returns 400 code=unknown_form.

KeyTypeAcceptedDefaultDescription
period_monthsinteger1–31How many calendar months one rendered statement covers.
history_monthsinteger1–121How 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_lengthinteger1–121How 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_casesstring[]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.
surfacesstring[]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:

KeyTypeDescription
deposit_count, withdrawal_count, check_count, fee_countinteger-valued stringHow many entries each summary bucket counts. Every row falls in exactly one bucket, so these can never sum past the row count.
days_in_cycleinteger-valued stringDays the cycle covers, both endpoints included — 1–31 December is 31, not 30.
average_ledger_balancedecimal stringMean of the daily closing balances over every day of the cycle, quiet days included.
minimum_daily_balancedecimal stringLowest daily closing balance over the cycle, on the same every-day basis.
apy_earneddecimal stringThe 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_ytddecimal stringInterest 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_yeardecimal stringThe 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_longstringThe period dates in long form ("December 1, 2021"). The short period_label is unchanged.
opening_balance_label, closing_balance_labelstringThe label printed beside each balance, carrying a month/day ("Beginning balance on 12/01"). The year is in period_label.
monthly_fee_standarddecimal stringThe account's published monthly maintenance fee. "0.00" on a product that charges none.
monthly_fee_chargeddecimal stringWhat 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_balancedecimal stringThe published balance at or above which the fee is waived; "0.00" means the product publishes no such waiver.
waiver_met_balancedecimal stringThe 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_sequencedigit stringThe 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_datemoneyInterest 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 reserved statements array — one object per statement, in delivery order, with statement_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_start and page_end. statements[i].statement_index == i, statements[i].opening_balance == statements[i-1].closing_balance exactly (money is a plain signed two-decimal string on both sides), and page_start/page_end are inclusive packet-absolute page indices. Every summary scalar an edition paints is republished per statement, because the top-level summary.* keys are statement 0's.
  • transactions and checks are flattened across the packet: row and checks[].transaction_row index the flattened array, and every row carries statement_index naming 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_index on 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 on row_index alone.

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:

NamePrevalenceWhat the statement carries
reversed_transaction8%A card purchase and, 1–3 days later, a REVERSAL credit of the same line.
fee_reversal5%A fee and a REFUND credit of it on the same or the next day.
returned_deposit3%A mobile check deposit, then a RETURNED DEPOSITED ITEM debit of the same amount plus a returned-item fee.
voided_check4%A check credited back as VOID under its own number, then reissued under the next number.
foreign_transaction7%An INTL PURCHASE with the foreign amount, currency and rate on the line, and the 3% (min $1) fee beside it.
skipped_check6%A gap in the check-number sequence (the checks section marks it).
opened_during_period2%The account opens a few days into the cycle: zero opening balance, an OPENING DEPOSIT first row.
closed_during_period1%The account closes in the last third of the cycle: the balance is withdrawn to zero and nothing posts after.
single_transaction1%Exactly one row.
no_transactions1%No rows at all; the opening and closing balances agree.
dense4%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:

KeyValuesMeaning
variation.table_styleT1–T6The 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_headersfull, minimalThe per-page furniture on continuation pages. online has none and publishes "".
variation.repeat_column_headersTrue, FalseWhether the column-header row is redrawn on every page that carries rows.
variation.section_splitallow, breakWhether a sectioned ledger may be cut mid-section by a page break. Editions without sections publish "".
variation.orphan_rowsTrue, FalseTrue lets a page break strand a single row; False keeps at least two together.
variation.edge_casescomma-joined namesThe 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}.json and yolo/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:

RejectedExample message
statement is not an objectconfig['statement'] must be an object; got str. Accepted keys are ['period_months', 'history_months', 'sequence_length', 'surfaces'].
an unknown key inside the blockUnknown key(s) ['period_month'] in config['statement']; accepted keys are ['period_months', 'history_months', 'sequence_length', 'surfaces'].
a non-integer (booleans included) valueconfig['statement']['period_months'] must be an integer between 1 and 3; got True.
an out-of-range valueconfig['statement']['sequence_length'] must be between 1 and 12; got 13.
surfaces is not a listconfig['statement']['surfaces'] must be a list of surface strings; got str. Accepted surfaces are ['pdf', 'png', 'png_screenshot'].
surfaces names an unknown surfaceUnknown delivery surface(s) ['png_scrnshot'] in config['statement']['surfaces']; accepted surfaces are ['pdf', 'png', 'png_screenshot'].
surfaces repeats a surfaceconfig['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.

KeyTypeAcceptedDefaultDescription
rationumber0–10.5Share of items that are tampered. 0 is a genuine-only statement dataset that still carries labels; 1 tampers every item.
pairingstringpaired, unpairedpairedpaired delivers each tampered statement beside its genuine twin, sharing a pair_id; unpaired delivers one document per item.
scenariosstring or arraya preset name, or a non-empty recipe listsemantic_onlyWhich 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.
subtletystringobvious, moderate, subtle, mixedmixedHow visible the edits are. mixed draws a level per document.
rechained_sharenumber0–10.5Share 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_policystringuniform, motivateduniformWhich 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 scenarios list that names any raster recipe the job cannot deliver is refused before the credit debit with 422 code=undeliverable_fraud_scenarios. error.details names each scenario and why, so you can add png_typed, switch to the clean profile, 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"]
        }
      }
    }
    

    reason is no_raster_output_format or non_clean_degradation_profile; in the second case details.degradation_profile names the profile. accepted_output_formats lists 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_handwritten output, the png_screenshot surface — gets that invalid_fraud_config refusal first, and an unknown degradation_profile is 400 code=invalid_degradation_profile before any of it.

  • A preset (balanced_all, forensic_only) is accepted: its raster recipes are dropped, the rest share the requested ratio, and the job is priced the same. The drop is reported as a fraud_scenarios_dropped entry in the create response's warnings (and in both estimate endpoints'), with the same dropped_scenarios shape as above, as well as in manifest.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.

RejectedExample message
fraud is not an objectconfig['fraud'] must be an object; got str. Accepted keys are ['ratio', 'pairing', 'scenarios', 'subtlety', 'rechained_share', 'target_policy'].
an unknown key inside the blockUnknown 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_policyconfig['fraud']['pairing'] must be one of ['paired', 'unpaired']; got 'pairs'.
an unknown preset nameUnknown 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 codesScenario 'x' spans layers ['pdf', 'semantic']. …
a mix the drops leave emptyEvery 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 editionconfig['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 coherentconfig['fraud'] requires config['coherence_mode'] = 'coherent'; got 'shuffled'. …
statement.sequence_length above 1config['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 surfaceA fraud dataset cannot be delivered on ['pdf_filled']: … / A fraud dataset cannot be delivered on the 'png_screenshot' surface yet: …

subtle_income_inflation is refused today. That preset is the S1 (inflated payroll deposit) code alone, and S1 needs 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; use semantic_only or balanced_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:

StageRendered asIn the answer key
prefilledTyped text on every surface — including handwritten output. Models machine prefill by the payer/EHR.the synthesized (or pinned) value
completedSurface-native (typed → typed, handwritten → handwritten). The default stage and the pre-existing behavior.the synthesized value
blankEmpty; absent from every bbox label surface (FUNSD/BIO/COCO/YOLO)."" — distinguishable from unset (below)

Scenario object:

FieldTypeRequiredDefaultDescription
namestringyes—Must match ^[a-z0-9_-]{1,64}$ and be unique across the list. Echoed in _metadata.fill.scenario.
weightnumberno1Positive, finite relative share of quantity. Counts are apportioned by the largest-remainder method, then interleaved across item_index by a seed-derived shuffle.
default_stagestringno"completed"Stage for every field not matched by a selector or pin. One of "completed" or "blank" only — never "prefilled".
prefilledstring[]no[]Selectors (see below) whose matched fields are forced to the prefilled stage.
blankstring[]no[]Selectors whose matched fields are forced to the blank stage.
valuesobjectno{}{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 id of a field as returned by GET /forms/{form_id} (see Get Form Details).
  • concept:<glob> is an fnmatch-style glob against a field's semantic_concept annotation, the metadata GET /forms/{form_id} exposes per field (alongside structural_role and entity_role). For example concept: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". Both fill_scenarios and shuffled coherence drive the same per-field value override channel, so combining them is rejected with 422 code=invalid_fill_scenarios. Use fill_scenarios with the default coherent mode (or random); 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:

ParameterTypeDefaultDescription
limitinteger50Results per page (1–100)
cursorstring—Pagination cursor from a previous response
statusenum—Filter by status (accepted values below)
form_idstring—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 — true if more results exist beyond this page
  • next_cursor — pass this as the cursor parameter 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:

FieldTypeDescription
job_idstringJob identifier
statusstringpending, generating, completed, failed, expired, canceling, canceled, or skipped
progressfloatCompletion progress (0.0 to 1.0)
form_idstringForm used for generation
form_namestringHuman-readable form name
quantityintegerNumber of documents requested
output_formatsstring[]Requested output formats
credits_requiredintegerTotal credits for this job
credits_chargedintegerCredits actually charged
seedintegerSeed used (null if random)
errorstringError message if job failed (null otherwise)
created_atstringISO 8601 creation timestamp
completed_atstringISO 8601 completion timestamp (null if not done)
generation_time_msintegerTotal generation time in milliseconds (null if not completed)
worker_countintegerNumber of workers used for this job
peak_workersintegerPeak concurrent workers during generation
throughput_docs_per_minfloatGeneration 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:

ParameterValuesDescription
formatdataset, csv, jsonOne 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.

  • dataset returns a single ZIP archive containing every artifact the job produced (PDFs, PNGs, JSON, CSV, and any ML annotations that were requested) plus a manifest.json describing the contents.
  • json returns the aggregated structured data as JSON.
  • csv returns 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 when guard_key is "render_version". It can hold several when guard_key is "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 of git_shas.
  • scope — always "dataset". Unlike counts and splits, 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 > 0 means git_shas may 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_seen is 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", or null when 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: 0 means the job predates per-build records. That is different from stamps_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:

  • validation is empty for any quantity below 10. At quantity: 5 the allocation is train 4, test 1, validation 0.
  • test is non-empty at every quantity of 2 or more, and empty only at quantity: 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 viaHowOwner
web_sessionSubmitted from the web app while signed inThe signed-in user's account
api_keyPOST /api/v1/generate with a Bearer keyThe 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):

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

EndpointDescription
GET /api/v1/jobs/{job_id}/itemsCursor-paginated list of per-item records. Each item carries presigned download URLs.
GET /api/v1/jobs/{job_id}/items/{item_id}/downloadPresigned 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:

FieldTypeRequiredDefaultDescription
quantityintegerno1Number of identities (1–10,000)
configobjectnonullGeneration configuration
seedintegernonullSeed 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:

FieldTypeDescription
credits_usedintegerTotal credits charged across all completed jobs
credits_allocatedintegerTotal 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:

ParameterTypeDefaultDescription
daysinteger30Number of days to summarize (1–365)

Response fields:

FieldTypeDescription
period_daysintegerPeriod covered
total_jobsintegerNumber of jobs created
total_items_generatedintegerTotal documents generated
total_credits_usedintegerTotal credits charged
jobs_by_statusobjectJob 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

EventFires when
job.completedA generation job finishes successfully (includes presigned download URLs)
job.failedA generation job fails

Inline Registration via webhook_url (deprecated)

Deprecated. The webhook_url parameter still works but will be removed in a future major version. Prefer pre-registering webhooks via POST /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:

FieldTypeRequiredDefaultDescription
urlstringyes—HTTPS endpoint to receive events (max 2,048 chars)
eventsstring[]no["job.completed", "job.failed"]Event types to subscribe to (the two events above)
descriptionstringnonullOptional 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_secret is 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:

FieldTypeDescription
urlstringNew endpoint URL
eventsstring[]New event subscriptions
descriptionstringNew description
is_activebooleanEnable 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:

HeaderDescription
X-Symagedocs-SignatureHMAC-SHA256 hex digest of the raw body
X-Symagedocs-EventEvent type (e.g., job.completed)
X-Symagedocs-DeliveryUnique 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:

AttemptDelay
1st retry10 seconds
2nd retry60 seconds
3rd retry300 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:

FieldTypeRequiredDescription
promptstringyesNatural 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:

FieldTypeRequiredDefaultDescription
columnsobject[]yes—Column definitions (1–50 columns, see below)
quantityintegerno100Number of rows (1–10,000)
output_formatsstring[]no["csv"]Output formats: csv, json
seedintegernonullSeed for reproducibility

Column definition:

Each column is an object with:

FieldTypeDescription
namestringColumn header name
typestringColumn type (see common types below)
constraintsobjectType-specific parameters (min, max, values, weights, etc.)

Common column types:

TypeDescriptionConstraints
text_first_nameFirst name from synthetic identity—
text_last_nameLast name—
text_full_nameFull name—
ssnSSN (###-##-####)—
identity_ageAge in years—
identity_genderGender (M/F)—
address_cityCity—
address_stateState abbreviation—
address_zipZIP code—
phonePhone number—
emailEmail address—
number_integerRandom integermin, max, distribution_type, distribution_params
number_decimalRandom decimalmin, max, decimals
custom_enumWeighted random choicevalues: [...], weights: [...]
custom_maskPattern-based stringmask (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:

FieldTypeDescription
job_idstringJob identifier
statusstringprocessing, completed, or failed
rows_generatedintegerRows completed so far
total_rowsintegerTotal rows requested
percentfloatCompletion percentage (0.0–100.0)
estimated_seconds_remainingfloatETA in seconds (null if unavailable)
errorstringError 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:

ParameterValuesDescription
formatcsv, jsonOne format per request.

Response: File download (text/csv or application/json).

Errors:

CodeMeaning
400Invalid format (not csv or json)
404Job not found
409Job not yet completed
410Data 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:

FieldTypeRequiredDefaultDescription
field_countintegeryes—Number of fields/columns on the form
output_formatsstring[]yes—Output formats (e.g., ["pdf_typed"])
record_countintegeryes—Number of records/rows to generate
degradation_profilestringnonullDegradation profile to apply (see valid values below)
configobjectnonullThe 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 returns 422. Note in particular that degradation_profile is a top-level field here, not nested under config as it is in /generate: config accepts only the statement and fraud blocks, and any other key under it (config.degradation_profile included) returns 422 code=invalid_request rather 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:

FieldTypeRequiredDefaultDescription
field_countsinteger[]yes—Number of fields on each form (one entry per form)
page_countsinteger[]yes—Number of pages on each form (must match field_counts length)
output_formatsstring[]no[]Requested output format tokens (e.g., ["pdf_typed", "bio"])
record_countintegeryes—Number of identities to generate (>= 0)
degradation_profilestringnonullOptional 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.
configobjectnonullThe 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:

TierRange
small< 100 MB
medium100 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:

FieldTypeRequiredDefaultDescription
namestringyes—Key label (1–200 chars)
scopesstring[]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:

FieldTypeDescription
key_idstringKey identifier
prefixstringFirst 16 characters of the key
namestringKey label
scopesstring[]Granted scopes
tierstringRate limit tier
last_used_atstringLast usage timestamp (null if never used)
created_atstringCreation timestamp
is_activebooleanWhether 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:

SchemeDescription
field_idLabels use form field IDs (e.g., employee_ssn)
semantic_conceptLabels use semantic concepts (e.g., social_security_number)
nist3Labels use NIST Type-3 categories
field_typeLabels 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_concept scheme. When two or more form fields share the same semantic_concept without 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 /yes and /no on 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

FormFieldsQuantityFormatsCredits
W-220100pdf_typed2,000
104050100pdf_typed2,200
10405010pdf_typed220

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

DataColumnsRowsCredits
Tabular51,00020
Tabular201,00040
Tabular105,000100
Tabular5010,0001,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:

ValueMeaning
absent or legacyNot 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.
v2Retries 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

SymptomCauseFix
401 Invalid API keyKey doesn't exist or was revokedCreate a new key in the web UI
403 Missing required scopesKey lacks the scope for this endpointCreate 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 accountCheck the job ID; keys can only access their own account's jobs
409 Job is not completedTrying to download before generation finishesPoll GET /jobs/{id} until status is completed
429 Rate limit exceededExceeded your tier's per-key request rateBack off using the X-RateLimit-Remaining header and retry