Build on Dodo.
A scan is a request your software makes and a record it gets back — fields you named, not a PDF to parse. Two APIs carry it: the scanner's own on your LAN, and the account API in the cloud. The record is the same object on both.
Two planes, one record.
| LAN | http://<scanner>:8443/v1 — trigger a scan on
the scanner in front of you and block until the record is ready.
| no round trip through us to start a job |
|---|---|---|
| Cloud | https://api.getdodo.in/v1 — records, profiles,
remote triggers, connectors, CSV export.
| Authorization: Bearer dk_… |
| Connectors | Push instead of pull: signed webhooks, S3 sinks, Google Sheets. Configured once, delivered with backoff. | records arrive at your endpoint |
Install.
TypeScript / Node 18+ · zero dependencies
npm install @hashteelab/dodo-scan
The package carries both clients — DodoScanner for the
LAN, DodoCloud for the account API — plus
verifyWebhookSignature. A Python LAN client ships with
the appliance tooling; write to
hello@getdodo.in for access. Any
language with an HTTP client works against the APIs directly.
Scan the page in front of you.
scan() queues a job on the device and, in the default
wait mode, resolves once the paper has been captured and extracted.
The fields you pass are the schema the extractor is held to.
scan → record, over the LAN
import { DodoScanner } from "@hashteelab/dodo-scan";
const scanner = new DodoScanner("scanner.local:8443");
const record = await scanner.scan({
documentType: "purchase_invoice",
reference: "PO-2214",
fields: {
supplier: "string",
invoice_date: "date",
total: "number",
line_items: [{ product: "string", qty: "number", rate: "number" }],
},
}); // resolves when the page is captured and extracted
console.log(record.data.total); the same call from Python
from dodo import DodoScanner
scanner = DodoScanner("scanner.local", 8443)
record = scanner.scan(
"purchase_invoice",
fields={"supplier": "string", "total": "number"},
reference="PO-2214",
)
print(record["data"]["supplier"])
Field types are string, number,
date, boolean, and a list of objects for
line-item tables. Save a set as a profile and pass
profile_id instead of repeating the schema on every call.
Read and correct from the cloud.
Every record your scanners produce is persisted to your account. The
API key is the dk_ shown once at registration (and again
at each rotation).
records, review, export
import { DodoCloud } from "@hashteelab/dodo-scan";
const cloud = new DodoCloud("api.getdodo.in", process.env.DODO_API_KEY!);
// Records your scanners have produced, newest first.
const records = await cloud.listExtractions({ status: "completed", q: "acme" });
// Correct a misread value and approve a record held for review.
await cloud.reviewExtraction("ext_…", "completed", { total: 8050 });
// Everything the current filters match, as CSV.
const csv = await cloud.exportCsv({ profileId: "pr_…" }); or plain HTTP
curl -H "Authorization: Bearer $DODO_API_KEY" \
"https://api.getdodo.in/v1/extractions?status=completed&limit=20" The record.
One shape, identical across the LAN API, the cloud API, webhooks, S3
sinks and both SDKs. data holds your fields, by the names
you chose.
ExtractionRecord
{
"extraction_id": "ext_9f21c4",
"status": "completed",
"confidence": 0.94,
"document_type": "purchase_invoice",
"device_id": "dv_a83f10",
"captured_at": "2026-08-24T09:12:44Z",
"reference": "PO-2214",
"profile_id": "pr_4c8a11",
"data": {
"supplier": "Acme Traders",
"invoice_date": "2026-08-21",
"total": 8050,
"line_items": [
{ "product": "Ballast 40W", "qty": 12, "rate": 420 }
]
},
"image_url": null
} | status | completed · needs_review · rejected | low confidence holds a record for a human |
|---|---|---|
| confidence | 0–1, for the extraction as a whole | your threshold, your call |
| reference | whatever you passed on the request | your PO number, ticket id, row key |
| image_url | short-lived presigned link to the capture | only when you ask for images |
Webhooks, signed.
Create a webhook connector and store the whsec_ secret it
returns — it is shown once. Each delivery carries three headers:
| X-Dodo-Event | extraction.completed · ping | test deliveries send the ping |
|---|---|---|
| X-Dodo-Delivery | cd_… | stable across retries — dedupe on it |
| X-Dodo-Signature | sha256=<hex HMAC-SHA256 of the raw body> | keyed by your whsec_ |
verify before you trust
import { verifyWebhookSignature } from "@hashteelab/dodo-scan";
app.post("/dodo", (req, res) => {
const ok = verifyWebhookSignature(
process.env.DODO_WEBHOOK_SECRET!,
req.rawBody, // the exact bytes, not re-serialized JSON
req.header("X-Dodo-Signature")!,
);
if (!ok) return res.status(401).end();
const { record } = JSON.parse(req.rawBody);
// … any 2xx acknowledges; anything else is retried with backoff
res.status(200).end();
}); Any 2xx acknowledges; anything else is retried with exponential backoff. The record is fetched fresh at delivery time, so a correction made between capture and delivery ships in its final form.
Endpoints.
The cloud surface a customer integration touches:
| GET | /v1/extractions | filters: device_id, profile_id, status, q, limit, include_images |
|---|---|---|
| PATCH | /v1/extractions/{id} | correct fields, approve or reject a review |
| GET | /v1/extractions/{id}/edits | field-level correction history |
| GET | /v1/extractions/export.csv | the current filter set, flattened |
| GET | /v1/extractions/{id}/image | presigned link to the capture |
| GET | /v1/devices | your fleet and its state |
| POST | /v1/devices/{id}/scan | remote trigger; returns a job and an upload URL |
| GET | /v1/jobs/{cloud_job_id} | poll a job to its record |
| GET/POST | /v1/profiles | document profiles — schema, prompt, name |
| GET/POST | /v1/connectors | webhook · s3 · gsheets, plus /test and /deliveries |
Errors.
Failures are JSON with a stable machine code, and say whether trying again is worth it.
error body
{ "code": "SCHEMA_INVALID", "message": "field schema is invalid", "retryable": false } 401 a bad or rotated key · 402 the monthly
page quota · 404 a device outside your fleet ·
400 a schema the extractor cannot be held to. Both SDKs
raise these as typed errors carrying code and
retryable.
Stuck?
Write to hello@getdodo.in — an engineer answers. Tell us the endpoint, the record id and roughly when it happened, and we can look the delivery up.