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.