Check OCR & Data Extraction API
Extract check number, date, amount, payee, memo, MICR routing, and bank from scanned or photographed checks. VLM-powered; agreement between courtesy + legal amounts is returned as a confidence signal.
- PAT auth on the
/api/v1/agent/*endpoints - Webhook-driven (no polling)
- Per-check self-consistency flags (
isCheckConsistent,isMicrValid) - Handwritten-field detection
Prerequisites
- A Personal Access Token from Account → API. Set it as
PAT=dcp_…. - An HTTPS receiver for the webhook. The runnable examples use webhook.site as a throwaway endpoint for testing.
End-to-end example
Note the subType: "checkImages" flag — this is what switches the worker from statement-table parsing to per-check VLM extraction.
bash
#!/usr/bin/env bash
set -euo pipefail
PAT="${PAT:?Set PAT to your dcp_… token}"
BASE="https://www.docuclipper.com"
PDF="checks.pdf"
# 1. Throwaway public receiver for the demo. In production, point at your own HTTPS endpoint.
TOK=$(curl -s -X POST https://webhook.site/token -d '{}' -H 'Content-Type: application/json' | jq -r .uuid)
RECEIVER="https://webhook.site/$TOK"
cleanup() {
[ -n "${WEBHOOK_ID:-}" ] && curl -s -X DELETE -H "Authorization: Bearer $PAT" "$BASE/api/v1/agent/webhooks/$WEBHOOK_ID" >/dev/null
curl -s -X DELETE "https://webhook.site/token/$TOK" >/dev/null
}
trap cleanup EXIT
# 2. Register webhook
WEBHOOK_ID=$(curl -s -X POST -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d "{\"url\":\"$RECEIVER\",\"events\":[\"bank_statement.extraction.completed\",\"document.extraction.failed\"]}" \
"$BASE/api/v1/agent/webhooks" | jq -r .id)
# 3. Get presigned upload URL + PUT the file to S3
PRESIGN=$(curl -s -X POST -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d "{\"filename\":\"$PDF\",\"mimetype\":\"application/pdf\"}" \
"$BASE/api/v1/agent/documents/upload-url")
S3_URL=$(echo "$PRESIGN" | jq -r .url)
DOC_ID=$(echo "$PRESIGN" | jq -r .document.id)
curl -s -o /dev/null -X PUT -H "Content-Type: application/pdf" --data-binary "@$PDF" "$S3_URL"
# 4. Create job
JOB_ID=$(curl -s -X POST -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d "{\"documents\":[$DOC_ID],\"jobType\":\"ExtractData\",\"subType\": \"checkImages\"}" \
"$BASE/api/v1/agent/jobs" | jq -r .jobId)
echo "job $JOB_ID — waiting for bank_statement.extraction.completed…"
# 5. Wait for the webhook. NOTE: this bash flow skips HMAC verification —
# see the Python or Node.js tab for production code.
DEADLINE=$(( $(date +%s) + 600 ))
while [ $(date +%s) -lt $DEADLINE ]; do
PAYLOAD=$(curl -s "https://webhook.site/token/$TOK/requests" | jq -r --arg jid "$JOB_ID" --arg ev "bank_statement.extraction.completed" '
.data[]? | select(.headers["x-docuclipper-event"][0]==$ev) | select(.content|fromjson|.job.id==$jid) | .content' | head -1)
if [ -n "$PAYLOAD" ]; then echo "$PAYLOAD" | jq .data; exit 0; fi
sleep 2
done
echo "timed out" >&2; exit 1Webhook payload
Real data field from a successful run on a real check.
json
{
"2666983": {
"checks": {
"bankMode": {
"transactions": [
{
"memo": "Avance Professional Service - Accounting Service for October (Check #7203)",
"amount": -550,
"date": "20251028000000",
"name": "Avance Professional Service Acc",
"payee": "Avance Professional Service Acc",
"checkNumber": "7203",
"metadata": {
"confidence": 1,
"extractor": "vlm",
"amountText": "five hundred and fifty dollars",
"legalAmountNumeric": 550,
"routingNumber": "021100361",
"bank": "JPMorgan Chase Bank, N.A.",
"isCheckConsistent": true,
"isMicrValid": true,
"docTypeGuess": "check",
"handwrittenFields": ["date", "payee", "courtesyAmount", "legalAmount", "memo", "signature"],
"payee": "Avance Professional Service",
"checkMemo": "Accounting Service for October"
},
"pageNumber": 2
}
],
"totalCredits": "0.00",
"totalDebits": "-550.00",
"numCredits": 0,
"numDebits": 1
},
"metadata": []
}
}
}Field reference
| Field | Type | Description |
|---|---|---|
| [documentId].checks.bankMode.transactions[] | array | One entry per check on the document |
| transactions[].amount | number | Signed amount (negative = check written / outgoing) |
| transactions[].checkNumber | string | Check number from MICR + courtesy/legal lines |
| transactions[].date | string | Check date, YYYYMMDDHHMMSS |
| transactions[].payee | string | Payee from the "Pay to the order of" line |
| transactions[].memo | string | Memo line text |
| transactions[].metadata.routingNumber | string | Routing number from MICR |
| transactions[].metadata.bank | string | Bank name resolved from the routing number |
| transactions[].metadata.amountText | string | Legal amount in words (e.g. "five hundred dollars") |
| transactions[].metadata.legalAmountNumeric | number | Numeric form of the legal amount, for cross-check vs courtesy amount |
| transactions[].metadata.isCheckConsistent | boolean | Courtesy ↔ legal amount agreement signal |
| transactions[].metadata.isMicrValid | boolean | MICR mod-10 routing-number checksum validates |
| transactions[].metadata.docTypeGuess | string | "check" if VLM is confident the page is a check |
| transactions[].metadata.handwrittenFields | array | Fields detected as handwritten — e.g. payee, courtesyAmount, legalAmount, signature |
Notes & gotchas
- The check extractor runs full-page VLM on each page. Multi-check PDFs return one entry per page in
bankMode.transactions[]. - When
isCheckConsistentisfalse, the courtesy and legal amounts disagree — treat the check as needing human review. docTypeGuessvalues other than"check"(e.g."deposit_slip") indicate the VLM thinks the page isn't actually a check.