AttoWorks Document Digitization API — External Integration Guide
This is the general-purpose companion to a per-partner integration guide: it covers the full surface an external integrator uses — not just the single-scan flow — including project/form setup, AI-assisted form building, bulk (multi-page) scanning, the pending-submissions review queue, and scan history search. For a narrower, single-flow walkthrough tailored to one partner's use case, see that partner's dedicated guide.
Everything here is the public contract: what to call, what you get back, and how to poll. It intentionally does not describe how AttoWorks implements any of this internally.
Contents
- Prerequisites
- Authentication
- Response envelope
- Setting up a project
- Defining a form (document schema)
- AI-assisted form creation
- Single scan
- Bulk scan (multi-page / multi-document PDFs)
- Pending submissions (review queue)
- Scan history
- Operational guidance
- Endpoint reference
1. Prerequisites
AttoWorks will provide you with:
| Item | Description |
|---|---|
BASE_URL | Environment base URL. Separate URLs are issued for staging and production. |
orgId | Your organization identifier. |
projectId | Identifier for the project this integration operates against (an org can have more than one). |
API_KEY | A long-lived credential for your integration. |
All paths below are relative to {BASE_URL} and are prefixed with /api/v1.
Complete your integration and sign off against staging before requesting production credentials.
2. Authentication
Every request carries your API key in the Authorization header, using the
Bearer scheme:
Authorization: Bearer <API_KEY>
Content-Type: application/jsonNotes:
- Keys are valid for 365 days by default. AttoWorks will notify you ahead of expiry so a replacement key can be issued; both keys remain valid during the overlap, so rotation requires no downtime.
- Treat the key as a server-side secret. Do not embed it in a mobile app or browser bundle — proxy calls through your own backend.
- Your key can be revoked immediately on request if it's ever compromised — contact AttoWorks support.
Authentication errors
| HTTP | message | Cause |
|---|---|---|
| 401 | Authentication token not found | Authorization header missing. |
| 401 | Invalid token | Malformed or unrecognized key. |
| 401 | Token expired | Key has passed its expiry date — request a new one. |
3. Response envelope
All JSON responses use a consistent envelope.
Success
{
"status": "success",
"code": 200,
"error": false,
"data": {},
"message": "OCR completed"
}Failure
{
"status": "failed",
"code": 400,
"error": true,
"data": "File data not found",
"message": "File data not found"
}Always branch on the HTTP status code. data carries the payload on
success and diagnostic detail on failure. Status codes in use: 200, 201, 400, 401, 403, 404, 409, 422, 500.
4. Setting up a project
A project is the top-level container your forms and scans live under. This is typically a one-time setup step done together with AttoWorks during onboarding, but is also available directly:
POST /project/insert
// Request
{
"orgId": 42,
"user_id": "your-account-user-id",
"projectName": "Prescription Intake",
"description": "optional"
}// Response 201 (observed)
{
"status": "success",
"code": 201,
"error": false,
"data": {},
"message": "Project inserted successfully",
"assetsBaseUrl": "abcd"
}data comes back empty — the create call confirms success but does not
hand you the new project's id inline. Immediately follow up with
GET /project/org/:orgId/projects and match on projectName to resolve
the id you'll use as projectId everywhere below. assetsBaseUrl is the
base URL to use for any asset references returned for this org/project.
Other project endpoints you may find useful:
| Method & Path | Purpose |
|---|---|
GET /project/read/:id | Fetch a project once you know its id. |
GET /project/org/:orgId/projects | List your organization's projects — use this right after POST /project/insert to resolve the new project's id ({ projects: [{id, projectName}], count }). |
5. Defining a form (document schema)
A form is the schema that tells AttoWorks what to extract from a
document type (e.g. "Invoice", "Prescription", "ID Card"). Define it once
per document type; the returned id becomes the formId/documentId you
pass on every scan against that document type.
POST /form/insert
{
"projectId": 10,
"version": 1,
"name": "Invoice",
"description": "optional",
// Fields to extract
"documetFeildsInfo": [
{
"name": "Invoice Number",
"type": "text",
"required": true,
"blocking": false,
"alternate_names": ["Invoice No.", "Bill No."],
"output_name": "invoice_number",
"extra_info": "Usually near the top-right of the document",
"tags": ["identifier"],
"validation": {
"min": 0, "max": 0,
"min_length": 0, "max_length": 0,
"regex": "^INV-\\d+$",
"date_format": "dd/MM/yyyy",
"options": []
}
}
],
// Line-item / repeating tables
"documentTablesInfo": [
{
"table_name": "Line Items",
"extra_info": "optional",
"table_columns": [
{ "name": "Item", "type": "text", "required": true },
{ "name": "Amount", "type": "number" }
]
}
]
}// Response 201
{ "status": "success", "code": 201, "error": false,
"data": { "id": 137, "...": "..." },
"message": "..." }documetFeildsInfo/documentTablesInfo accept a wide/flexible shape — the
one above is what the extraction pipeline actively reads, so stick to these
property names for anything you want honored during OCR (unrecognized
properties are stored but ignored).
Other form endpoints:
| Method & Path | Purpose |
|---|---|
GET /form/:projectId/get-forms-by-project-id | List a project's forms. |
GET /form/details/:id | Fetch a form's full definition. |
PATCH /form/update/:id | Update a form (partial). |
Advanced options
These cover more specialized needs — most integrations don't need all of them on day one, and it's worth reviewing the ones that apply with your AttoWorks contact during setup.
Page/document classification — when a scanned batch can contain more
than one class of page (e.g. a cover page vs. a data page), a form can
carry classifyTextLogic: an object keyed by class name, each describing a
text-matching rule used to classify each page:
"classifyTextLogic": {
"InvoicePage": { "operator": "contains", "value": "Invoice Number", "logicalOperator": "single" },
"CoverPage": { "operator": "contains", "value": ["Cover", "Summary"], "logicalOperator": "or" }
}operator is "contains" or "not_contains"; logicalOperator is
"single" (checks only the first value), "or", or "and" when value
is an array.
Multi-document splitting — for batches that bundle several separate
documents into one file (e.g. several unrelated prescriptions in one PDF),
a form can carry documentSplitLogic describing how to split incoming
pages into separate documents before extraction. The exact rule set is
best defined together with your AttoWorks contact — tell us your batching
pattern and we'll configure it against your form.
Per-form storage override — s3_credentials lets a form's uploads
route to a storage account you control instead of AttoWorks' default:
"s3_credentials": { "bucket_name": "...", "aws_access_key_id": "...", "aws_secret_access_key": "...", "aws_region": "..." }Omitting aws_secret_access_key on an update preserves whatever secret is
already stored, rather than clearing it.
Automatic export to Google Sheets — configure a per-form (or per-project default) destination so every approved submission is pushed to a spreadsheet automatically:
PUT /form/:projectId/submission-destinations
{
"formId": 137,
"destinationType": "google_sheet",
"configuration": { "spreadsheetId": "...", "sheetName": "Sheet1" },
"enabled": true
}6. AI-assisted form creation
Rather than hand-authoring field lists, you can upload a sample document and have AttoWorks propose a schema, then iterate on it before saving it as a form.
Step 1 — get an upload URL
POST /documents/ai-upload/presigned-url
{ "contentType": "application/pdf", "fileName": "sample-invoice.pdf" }// Response 200/201
{ "uploadUrl": "https://...", "bucket": "attoworks-uploads", "key": "ai-drafts/3f2b.../sample-invoice.pdf" }Retain bucket and key — you'll pass them as imageRefs in step 3.
Step 2 — upload the sample
Same pre-signed direct-upload mechanics as the scan flow (§7, Step 2):
curl -X PUT "$uploadUrl" \
--header "Content-Type: application/pdf" \
--data-binary @sample-invoice.pdfRaw bytes, no Authorization header, Content-Type must match what you
declared in step 1. A 200 with an empty body means it worked.
Step 3 — generate a draft schema
POST /documents/generate-form-draft
{
"imageRefs": [{ "bucket": "...", "key": "..." }],
"text": "optional additional context/instructions",
"contextPayload": {
"documentCategory": "invoice",
"industry": "logistics",
"languages": ["en"],
"userInstructions": "Extract PO number and every line item"
}
}→
{
"draft": {
"name": "...", "description": "...",
"extraction_schema": {
"document_fields_info": [ /* field objects, same shape as §5 */ ],
"document_tables_info": [ /* table objects, same shape as §5 */ ]
},
"classify_text_logic": [ /* optional page-classification rules */ ]
},
"meta": { "requestId": "...", "latencyMs": 1234, "confidence_notes": ["..."] }
}Step 4 — refine (optional, repeatable)
POST /documents/refine-draft
{ "draft": { /* the draft object above */ }, "instructions": "Also capture the tax ID field" }→ a refined draft, same shape as step 3. Call this as many times as needed.
Step 5 — save it
Once you're happy with the draft, create the form with POST /form/insert
(§5), using draft.extraction_schema.document_fields_info as
documetFeildsInfo and document_tables_info as documentTablesInfo.
This whole flow is synchronous — no job ID or polling involved.
7. Single scan
The core flow for digitizing one document: request an upload URL, upload the file, trigger processing, poll until done, retrieve the result, then submit the reviewed data back.
1. POST /ocr/file/upload { formId, contentType }
→ { fileId, uploadUrl, identifier }
2. PUT <uploadUrl> (raw file bytes; Content-Type must
exactly match what was declared in
step 1, or the signature check fails)
3. POST /ocr/trigger { fileId, documentId, identifier, languages? }
→ { job_id, submissionId, status: "queued" }
4. Poll GET /ocr/status/:submissionId
until ocrStatus is "completed", "failed", or "excluded"
5. GET /ocr/result/:submissionId → sanitized OCR JSON (field values,
confidence, validation flags)
6. Reviewer corrects any flagged fields client-side, then:
PATCH /ocr/user-submit { ocrSubmission: { submission_id, document_id,
document_url, extracted_data: { form_fields, table_data } } }Step 1 — request an upload URL
POST /ocr/file/upload
{ "formId": 137, "contentType": "application/pdf" }Accepted contentType: application/pdf, image/jpeg, image/jpg,
image/png, image/webp.
// 201
{ "data": { "fileId": 90114, "uploadUrl": "https://...", "identifier": "3f2b9c1e-..." } }The uploadUrl is single-purpose and short-lived — request it immediately
before uploading, and retain fileId/identifier for step 3.
Step 2 — upload
Take the uploadUrl from step 1 and PUT the raw file bytes straight to
it — this is a direct-to-storage pre-signed upload, not a call to the
AttoWorks API itself.
curl -X PUT "$uploadUrl" \
--header "Content-Type: application/pdf" \
--data-binary @invoice.pdfRules that matter here:
| Rule | Why |
|---|---|
Send the raw file bytes as the request body — not multipart/form-data. | The pre-signed URL expects a plain binary PUT, the same as a direct storage upload. A multipart body will fail the signature check. |
Content-Type must exactly match the contentType you declared in step 1 (e.g. both application/pdf). | The upload URL is signed against that exact content type; a mismatch (even application/pdf vs application/octet-stream) is rejected. |
Do not send an Authorization header on this request. | The URL is already signed/authorized on its own — adding your API key here can cause the signature check to fail. |
| Use the URL once, immediately. | It's short-lived and single-purpose; don't cache or reuse it for a second file. |
Response: 200 OK with an empty body on success. Anything else (403,
signature errors, etc.) means the upload URL expired or the
Content-Type didn't match — request a fresh uploadUrl via step 1 and
retry rather than reusing the old one.
Step 3 — trigger
documentId is the same id you already have — it's the form's id
returned by POST /form/insert (§5), the same value you passed as
formId in Step 1. formId and documentId are two names for one thing
(the form/schema you're scanning against); nothing new to fetch here.
POST /ocr/trigger
{
"fileId": 90114,
"documentId": 137,
"identifier": "3f2b9c1e-8a44-4d0f-9b21-6f5c0a7e1d33",
"languages": ["en"]
}// 201
{ "data": { "job_id": "58231", "status": "queued", "submissionId": 58231 } }submissionId is the identifier for everything that follows — persist it
against your own record.
A multi-page PDF is accepted and processed as a single document. A file containing several unrelated documents is not automatically split — see §8 for that.
Step 4 — poll for status
GET /ocr/status/:submissionId
ocrStatus | Meaning | Terminal |
|---|---|---|
queued | Accepted, awaiting a processing slot. | No |
pending | Validated and dispatched for processing. | No |
text_extraction_initiated / text_extraction_completed | Reading text from the document. | No |
processing | Field extraction/validation in progress. | No |
submitted / ocr_completed | Extraction finished, finalizing. | No |
completed | Results available. | Yes |
failed | Processing failed — see errorMessage. | Yes |
excluded | Submission was withdrawn — see §10. | Yes |
Polling guidance: poll every 3–5 seconds. Most single-page documents
complete well under a minute. Apply a client-side timeout (2 minutes is a
reasonable default) after which you treat the submission as stalled and
contact AttoWorks support quoting the submissionId. Stop polling as soon
as ocrStatus is completed, failed, or excluded.
Step 5 — retrieve the result
GET /ocr/result/:submissionId
{
"data": {
"submission_id": "58231",
"status": "completed",
"document_id": "137",
"document_url": ["s3://.../uploads/..."],
"extracted_data": {
"form_fields": {
"Invoice Number": {
"value": ["INV-4021", ""],
"confidence": 0.96,
"status": "success",
"flags": []
},
"Due Date": {
"value": ["", ""],
"confidence": 0,
"status": "error",
"message": "Required field is empty",
"flags": ["empty", "critical"]
}
},
"table_data": {
"Line Items": [
{ "Item": { "value": ["Widget A", ""], "confidence": 0.9, "status": "success", "flags": [] } }
]
}
}
}
}Field object:
| Property | Description |
|---|---|
value | value[0] is the extracted value — always read/write this one. Remaining entries are alternates/options for reviewer convenience. |
confidence | 0.0–1.0. |
status | success or error (failed schema validation). |
flags | Review hints (below). |
Flags:
| Flag | Meaning | Suggested handling |
|---|---|---|
empty | No value was found. | Show blank for reviewer entry. |
critical | Required field empty, or failed validation. | Must be resolved before submission. |
low_confidence | Confidence below 0.9. | Highlight for confirmation. |
needs_review | Human confirmation recommended. | Route to reviewer. |
A field with no flags and status: "success" can be accepted without
review.
Step 6 — review, correct, and submit
Before allowing submission, require resolution of every field where
flags contains critical or status is error.
PATCH /ocr/user-submit
{
"ocrSubmission": {
"submission_id": 58231,
"document_id": "137",
"document_url": ["s3://.../uploads/..."],
"extracted_data": {
"form_fields": { /* corrections applied to value[0] */ },
"table_data": { /* rows may be added/removed by the reviewer */ }
}
}
}Set confidence to 1.0 and status to "success" on any field a
reviewer manually corrected. On success, this is also the point where an
export destination configured in §5 (e.g. Google Sheets) fires
automatically. Submission is idempotent by submission_id — resubmitting
overwrites the stored values with the latest payload. On a 400
validation failure, nothing is persisted — inspect
data.extracted_data.form_fields for status: "error" entries and
resubmit.
What actually blocks the 400: submission always blocks if any
required field has status: "error" — that's not configurable. If the form
schema additionally marks one or more (typically non-required) fields
"blocking": true (§5), an error on those also blocks, on top of the
required-field rule. Errors on every other, non-blocking, non-required
field still come back in the response for the reviewer to see, but don't
stop the submit.
Withdrawing a submission — if a scan is unreadable, a duplicate, or was uploaded in error:
PATCH /ocr/submission/:submissionId/exclude → marks it excluded and
removes it from reporting/history. This is terminal; submit a fresh scan
to reprocess the document.
8. Bulk scan (multi-page / multi-document PDFs)
For a large PDF that bundles multiple pages/documents and needs to be
split into individual results — e.g. a stack of scanned prescriptions
batched into one file — use the large-document flow instead of a single
/ocr/trigger call.
1. POST /ocr/file/upload { formId, contentType: "application/pdf" }
→ { fileId, uploadUrl, identifier }
2. PUT <uploadUrl> (the PDF bytes)
3. POST /large-document/trigger-large-document-processing
{ documentId, fileId, languages?, tags? }
→ { submissionId, statusCode, ... } ← submissionId here IS your largeDocumentId, see Step 3
4. Poll GET /large-document/:largeDocumentId/status
until splitStatus is "completed" or "failed"
5. GET /large-document/:largeDocumentId/submissions
→ { largeDocumentId, totalSplits, submissions: [{ submissionId, splitId, pageRange, s3Url }] }
6. For each split's submissionId, follow steps 4–6 of the single-scan flow
(§7): poll /ocr/status/:submissionId → /ocr/result/:submissionId →
PATCH /ocr/user-submitStep 1–2 — upload the PDF
Identical mechanics to the single-scan flow: POST /ocr/file/upload with
{ formId, contentType: "application/pdf" } to get { fileId, uploadUrl, identifier }, then PUT the raw PDF bytes to uploadUrl (see §7, Step 2
for the exact rules — no Authorization header, exact Content-Type
match, raw body not multipart).
Step 3 — trigger the split
POST /large-document/trigger-large-document-processing
{
"documentId": 137,
"fileId": 90201,
"tags": ["batch-2026-07-31"],
"languages": ["en"],
"isSynchronous": false
}| Field | Notes |
|---|---|
documentId | The form's id, same id space as formId/documentId everywhere else (§7, Step 3). |
fileId | From Step 1's upload response. |
languages | Optional; restricted to en, hi, mr, gu, ta, kn, te — same set as the single-scan flow. Anything outside this list is rejected. |
tags | Optional free-form strings, echoed back later for filtering/search. |
isSynchronous | Optional, default false. Only affects how long the request blocks waiting on the downstream split service (a blocking call vs. fire-and-forget) — it does not change what's returned or whether an id is available afterward. Leave it false unless you specifically need a synchronous response. |
// 201 (observed)
{
"status": "success", "code": 201, "error": false,
"data": { "submissionId": 55, "statusCode": 200 },
"message": "..."
}Read data.submissionId, not a field called largeDocumentId — there
is no field with that literal name in this response. submissionId here
is the large-document batch's own id (a different id space from the
per-split submissionId values you'll see in Step 5) — it's exactly the
value you plug into :largeDocumentId in Steps 4 and 5 below. This is
returned synchronously on every call regardless of isSynchronous.
Step 4 — poll for split status
GET /large-document/:largeDocumentId/status
{ "id": 55, "splitStatus": "processing", "totalPages": 42, "createdAt": "...", "updatedAt": "..." }splitStatus values: pending, processing, completed, failed.
Step 5 — list the individual splits
GET /large-document/:largeDocumentId/submissions
{
"largeDocumentId": 55,
"totalSplits": 6,
"submissions": [
{
"submissionId": 58301,
"splitId": 1,
"pageRange": { "start": 1, "end": 7 },
"splitOrder": 1,
"s3Url": "...",
"createdAt": "..."
}
]
}This endpoint is callable at any point in the batch's lifecycle, not just
once splitStatus is completed — before splitting finishes it simply
returns submissions: []. Don't treat a call to this endpoint as a
substitute for polling Step 4; wait for splitStatus: "completed" before
you rely on totalSplits/submissions being the final, full list.
Step 6 — process each split like a normal scan
Each entry in submissions has its own submissionId. From here, each
one is indistinguishable from a single-scan submission — follow §7 Steps
4–6 independently for every split: poll GET /ocr/status/:submissionId,
fetch GET /ocr/result/:submissionId, review, then PATCH /ocr/user-submit.
Polling guidance: batch splitting scales with page count — poll
/large-document/:id/status every 5–10 seconds rather than the tighter
interval used for single scans, and size your client-side timeout to the
batch (a rough guide: allow at least a few seconds per page before treating
it as stalled). Once splitStatus is completed, each split behaves
exactly like an independent single-scan submission for polling/result/
submit purposes.
9. Pending submissions (review queue)
To build a reviewer queue — submissions that finished OCR but haven't been reviewed/submitted yet:
GET /readonly/submissions/review/pending
Supports the same pagination/filtering as scan history (§10). Each result
is a submission that's ready for GET /ocr/result/:submissionId →
reviewer correction → PATCH /ocr/user-submit (§7, step 6).
10. Scan history
To search/list past submissions — for a dashboard, audit trail, or reconciliation:
GET /readonly/submissions
Query parameters: page, limit, sort_by, sort_order, project_id, user_id, ocr_status, start_date, end_date, department, sub_department, tags.
GET /readonly/submissions/:id — fetch one submission by id.
GET /readonly/submissions/org/:orgId — all submissions across every
project in the org, not scoped to a single project. Query parameters:
page, limit, status. Response shape matches /readonly/submissions
({ data: [...], pagination: {...} }). Non-ADMIN callers can only fetch
their own org — a mismatched orgId returns 403.
GET /readonly/submissions/stats — query project_id, start_date, end_date → totals plus status/daily breakdowns, useful for a summary
dashboard.
limit here means submission rows, same as every other paginated endpoint
in this API.
11. Operational guidance
| Topic | Guidance |
|---|---|
| Concurrency | Start conservatively (e.g. 10 concurrent submissions) and let AttoWorks know your expected peak volume so capacity can be confirmed. |
| Polling | 3–5s for single scans, 5–10s for bulk/large-document status. Avoid tighter intervals. |
| Retries | Retry 5xx responses with exponential backoff, up to 3 attempts. Never retry a 4xx without changing the request. |
| Idempotency | The upload-URL and trigger endpoints are not idempotent — a retried trigger call creates a second submission. Only retry a trigger after confirming the first attempt genuinely failed (i.e. didn't return a submissionId). |
| Logging | Log submissionId (and largeDocumentId for bulk) against every record you process — it's the reference for all support requests. |
| Support requests | Include submissionId/largeDocumentId, environment (staging/production), the UTC timestamp, and the full request/response envelope. |
12. Endpoint reference
| Method | Path | Purpose |
|---|---|---|
| POST | /project/insert | Create a project. |
| GET | /project/org/:orgId/projects | List an org's projects. |
| POST | /form/insert | Create a document schema (form). |
| GET | /form/:projectId/get-forms-by-project-id | List a project's forms. |
| GET | /form/details/:id | Fetch a form definition. |
| PUT | /form/:projectId/submission-destinations | Configure Google Sheets auto-export. |
| POST | /documents/ai-upload/presigned-url | Upload URL for an AI-draft sample document. |
| POST | /documents/generate-form-draft | AI-generate a draft schema from a sample. |
| POST | /documents/refine-draft | Iterate on a draft schema. |
| POST | /ocr/file/upload | Request a pre-signed upload URL. |
| POST | /ocr/trigger | Start single-document digitization. |
| GET | /ocr/status/:submissionId | Poll processing status. |
| GET | /ocr/result/:submissionId | Retrieve extracted data. |
| PATCH | /ocr/user-submit | Submit reviewed data. |
| PATCH | /ocr/submission/:submissionId/exclude | Withdraw a submission. |
| POST | /large-document/trigger-large-document-processing | Start bulk/multi-doc digitization. |
| GET | /large-document/:largeDocumentId/status | Poll bulk-split status. |
| GET | /large-document/:largeDocumentId/submissions | List a batch's individual submissions. |
| GET | /readonly/submissions/review/pending | Pending-review queue. |
| GET | /readonly/submissions | Scan history (paginated, filterable). |
| GET | /readonly/submissions/:id | Fetch one historical submission. |
| GET | /readonly/submissions/org/:orgId | Scan history across every project in an org. |
| GET | /readonly/submissions/stats | Summary stats. |