Developer docs

External Integration Guide

The public contract for the AttoWorks Document Digitization API — authentication, projects, forms, single and bulk scanning, the review queue, and scan history.

RESTJSONBearer auth

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

  1. Prerequisites
  2. Authentication
  3. Response envelope
  4. Setting up a project
  5. Defining a form (document schema)
  6. AI-assisted form creation
  7. Single scan
  8. Bulk scan (multi-page / multi-document PDFs)
  9. Pending submissions (review queue)
  10. Scan history
  11. Operational guidance
  12. Endpoint reference

1. Prerequisites

AttoWorks will provide you with:

ItemDescription
BASE_URLEnvironment base URL. Separate URLs are issued for staging and production.
orgIdYour organization identifier.
projectIdIdentifier for the project this integration operates against (an org can have more than one).
API_KEYA 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:

Text
Authorization: Bearer <API_KEY>
Content-Type: application/json

Notes:

  • 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

HTTPmessageCause
401Authentication token not foundAuthorization header missing.
401Invalid tokenMalformed or unrecognized key.
401Token expiredKey has passed its expiry date — request a new one.

3. Response envelope

All JSON responses use a consistent envelope.

Success

JSON
{
  "status": "success",
  "code": 200,
  "error": false,
  "data": {},
  "message": "OCR completed"
}

Failure

JSON
{
  "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

JSON
// Request
{
  "orgId": 42,
  "user_id": "your-account-user-id",
  "projectName": "Prescription Intake",
  "description": "optional"
}
JSON
// 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 & PathPurpose
GET /project/read/:idFetch a project once you know its id.
GET /project/org/:orgId/projectsList 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

JSON
{
  "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" }
      ]
    }
  ]
}
JSON
// 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 & PathPurpose
GET /form/:projectId/get-forms-by-project-idList a project's forms.
GET /form/details/:idFetch a form's full definition.
PATCH /form/update/:idUpdate 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:

JSON
"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 overrides3_credentials lets a form's uploads route to a storage account you control instead of AttoWorks' default:

JSON
"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

JSON
{
  "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

JSON
{ "contentType": "application/pdf", "fileName": "sample-invoice.pdf" }
JSON
// 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):

Shell
curl -X PUT "$uploadUrl" \
  --header "Content-Type: application/pdf" \
  --data-binary @sample-invoice.pdf

Raw 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

JSON
{
  "imageRefs": [{ "bucket": "...", "key": "..." }],
  "text": "optional additional context/instructions",
  "contextPayload": {
    "documentCategory": "invoice",
    "industry": "logistics",
    "languages": ["en"],
    "userInstructions": "Extract PO number and every line item"
  }
}

JSON
{
  "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

JSON
{ "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.

Text
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

JSON
{ "formId": 137, "contentType": "application/pdf" }

Accepted contentType: application/pdf, image/jpeg, image/jpg, image/png, image/webp.

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

Shell
curl -X PUT "$uploadUrl" \
  --header "Content-Type: application/pdf" \
  --data-binary @invoice.pdf

Rules that matter here:

RuleWhy
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

JSON
{
  "fileId": 90114,
  "documentId": 137,
  "identifier": "3f2b9c1e-8a44-4d0f-9b21-6f5c0a7e1d33",
  "languages": ["en"]
}
JSON
// 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

ocrStatusMeaningTerminal
queuedAccepted, awaiting a processing slot.No
pendingValidated and dispatched for processing.No
text_extraction_initiated / text_extraction_completedReading text from the document.No
processingField extraction/validation in progress.No
submitted / ocr_completedExtraction finished, finalizing.No
completedResults available.Yes
failedProcessing failed — see errorMessage.Yes
excludedSubmission 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

JSON
{
  "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:

PropertyDescription
valuevalue[0] is the extracted value — always read/write this one. Remaining entries are alternates/options for reviewer convenience.
confidence0.0–1.0.
statussuccess or error (failed schema validation).
flagsReview hints (below).

Flags:

FlagMeaningSuggested handling
emptyNo value was found.Show blank for reviewer entry.
criticalRequired field empty, or failed validation.Must be resolved before submission.
low_confidenceConfidence below 0.9.Highlight for confirmation.
needs_reviewHuman 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

JSON
{
  "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.

Text
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-submit

Step 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

JSON
{
  "documentId": 137,
  "fileId": 90201,
  "tags": ["batch-2026-07-31"],
  "languages": ["en"],
  "isSynchronous": false
}
FieldNotes
documentIdThe form's id, same id space as formId/documentId everywhere else (§7, Step 3).
fileIdFrom Step 1's upload response.
languagesOptional; restricted to en, hi, mr, gu, ta, kn, te — same set as the single-scan flow. Anything outside this list is rejected.
tagsOptional free-form strings, echoed back later for filtering/search.
isSynchronousOptional, 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.
JSON
// 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

JSON
{ "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

JSON
{
  "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

TopicGuidance
ConcurrencyStart conservatively (e.g. 10 concurrent submissions) and let AttoWorks know your expected peak volume so capacity can be confirmed.
Polling3–5s for single scans, 5–10s for bulk/large-document status. Avoid tighter intervals.
RetriesRetry 5xx responses with exponential backoff, up to 3 attempts. Never retry a 4xx without changing the request.
IdempotencyThe 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).
LoggingLog submissionId (and largeDocumentId for bulk) against every record you process — it's the reference for all support requests.
Support requestsInclude submissionId/largeDocumentId, environment (staging/production), the UTC timestamp, and the full request/response envelope.

12. Endpoint reference

MethodPathPurpose
POST/project/insertCreate a project.
GET/project/org/:orgId/projectsList an org's projects.
POST/form/insertCreate a document schema (form).
GET/form/:projectId/get-forms-by-project-idList a project's forms.
GET/form/details/:idFetch a form definition.
PUT/form/:projectId/submission-destinationsConfigure Google Sheets auto-export.
POST/documents/ai-upload/presigned-urlUpload URL for an AI-draft sample document.
POST/documents/generate-form-draftAI-generate a draft schema from a sample.
POST/documents/refine-draftIterate on a draft schema.
POST/ocr/file/uploadRequest a pre-signed upload URL.
POST/ocr/triggerStart single-document digitization.
GET/ocr/status/:submissionIdPoll processing status.
GET/ocr/result/:submissionIdRetrieve extracted data.
PATCH/ocr/user-submitSubmit reviewed data.
PATCH/ocr/submission/:submissionId/excludeWithdraw a submission.
POST/large-document/trigger-large-document-processingStart bulk/multi-doc digitization.
GET/large-document/:largeDocumentId/statusPoll bulk-split status.
GET/large-document/:largeDocumentId/submissionsList a batch's individual submissions.
GET/readonly/submissions/review/pendingPending-review queue.
GET/readonly/submissionsScan history (paginated, filterable).
GET/readonly/submissions/:idFetch one historical submission.
GET/readonly/submissions/org/:orgIdScan history across every project in an org.
GET/readonly/submissions/statsSummary stats.