Tutorials

Add receipts from your app

Upload receipts of any size with a private upload link, then follow them until they're filed.

15 minutes · Beginner

Expense apps, scanners, shared drives and email tools all collect receipts. This tutorial sends them to Oatmilk the way the Oatmilk app does: prepare an upload, send the file to a private link, confirm it, and follow the job until the receipt is filed.

For files up to 2 MB, the one-call uploads.inline in the quickstart is simpler. Use this flow for anything up to 50 MB, and when you want the file to travel separately from your API request.

Try the flow

Choose a receipt from your computer to see every request with its real size and hash. The file stays in your browser: nothing is uploaded.

Choose a receipt to see its exact size and SHA-256 in each request, or use a tiny sample PDF.

1. Prepare the upload

Tell Oatmilk what you're about to send: the file's name, type, size and SHA-256 hash. The hash lets Oatmilk prove the stored file is exactly the one you meant, and it makes retries safe.

prepare.js
import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";

const bytes = await readFile("receipt.jpg");
const sha256 = createHash("sha256").update(bytes).digest("hex");
const idempotencyKey = randomUUID();
curl https://app.getoatmilk.com/api/v1/accounting/uploads.prepare \
  -H "Authorization: Bearer $OATMILK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "filename": "receipt.jpg",
  "mimeType": "image/jpeg",
  "sizeBytes": 182734,
  "sha256": "9f2b5c1d7e3a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c"
}'
uploads.prepare response
{
  "data": {
    "submissionId": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
    "uploadUrl": "https://storage.example.com/upload/sign/accounting/receipt.jpg?token=synthetic",
    "uploadToken": "synthetic-upload-token",
    "storagePath": "org_synthetic/4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a/upload/9f2b5c1d7e3a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c/receipt.jpg",
    "expiresIn": 7200,
    "alreadyUploaded": false
  }
}

2. Send the file

PUT the unchanged bytes to uploadUrl within two hours. Don't send your API key to this address: the link carries its own short-lived permission.

upload.js
const put = await fetch(prepared.uploadUrl, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: bytes });
if (!put.ok) throw new Error(`Upload failed with ${put.status}`);

3. Confirm it

Confirming checks the stored bytes against the size and hash you declared, keeps the original, and starts reading it.

curl https://app.getoatmilk.com/api/v1/accounting/uploads.confirm \
  -H "Authorization: Bearer $OATMILK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "submissionId": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a"
}'
uploads.confirm response
{
  "data": {
    "submissionId": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
    "jobId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
    "generation": 1,
    "status": "queued"
  }
}

4. Follow the job

Ask jobs.get until the job is completed or failed, waiting a little longer each time. Or subscribe to the receipt.processed webhook and skip polling altogether.

wait.js
for (let wait = 1; ; wait = Math.min(wait * 2, 30)) {
  const job = await callOatmilk("jobs.get", { jobId: confirmed.jobId });
  if (job.status === "completed") break;
  if (job.status === "failed") throw new Error(job.error_code ?? "Receipt processing failed");
  await new Promise(resolve => setTimeout(resolve, wait * 1000));
}

Attach it to a purchase

If you know which card purchase the receipt is for, pass its entry ID as receiptFor when you prepare the upload. Oatmilk then compares the receipt with that purchase only, and refuses the upload if the purchase no longer needs one. attention.mine lists the caller's purchases that still need a receipt; see Chase missing receipts.

Recap

  • Generate one idempotency key per file and reuse it for every retry of every step.
  • Hash the exact bytes you upload. A different file needs a new key.
  • A confirmed upload is safe in Oatmilk even while it's still being read.