Webhooks & polling
Receive async results two ways: a signed webhook callback when a scan completes, or by polling GET /v1/receipts/{id}.
Two ways to get an async result
After you submit to POST /v1/receipts/async, the result is not in the immediate 202 response — only the scan id is. You can either (1) supply a callbackUrl and let Tagjet POST the finished result to your server, or (2) poll GET /v1/receipts/{id} until its status is terminal. Both are first-class; pick whichever fits your infrastructure.
Option A — webhook callback
Pass a callbackUrl when you submit the async scan (a multipart field, or a JSON body field). When processing finishes, Tagjet sends a single POST to that URL containing the verbose scan result as the JSON body. The callback URL must be a public http(s) endpoint — requests to private/internal hosts are blocked.
curl https://api.tagjet.app/v1/receipts/async \
-H "apikey: tj_live_your_key_here" \
-F "file=@receipt.jpg" \
-F "callbackUrl=https://your-app.example.com/hooks/tagjet"What the callback request looks like
The POST carries Content-Type: application/json and two Tagjet headers: X-Tagjet-Event: scan.completed identifies the event, and X-Tagjet-Signature carries an HMAC-SHA256 signature of the exact request body, formatted as sha256=<hex>. The body itself is the same shape as GET /v1/receipts/{id} (verbose: id, status, data with confidences, and so on). Delivery is retried a few times on non-2xx responses, so make your handler idempotent (key off the scan id).
POST /hooks/tagjet HTTP/1.1
Content-Type: application/json
X-Tagjet-Event: scan.completed
X-Tagjet-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
{ "id": "0e0b1f1a-...", "status": "DONE", "overallConfidence": 0.93, "data": { ... } }Verifying the signature
Compute HMAC-SHA256 over the raw request body using your webhook signing secret, hex-encode it, prefix it with sha256=, and compare against the X-Tagjet-Signature header with a constant-time comparison. Reject the request if they do not match.
import crypto from 'node:crypto'
function verify(rawBody, header, secret) {
const digest = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const expected = `sha256=${digest}`
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header))
}Option B — polling
If you cannot expose a public callback URL, simply poll GET /v1/receipts/{id} on a back-off interval until status is terminal (DONE, or a failure with an error object). This needs no extra setup beyond the id you already received from the 202 response.
import time, requests
headers = {"apikey": "tj_live_your_key_here"}
scan_id = "0e0b1f1a-..." # from the 202 response
while True:
scan = requests.get(
f"https://api.tagjet.app/v1/receipts/{scan_id}", headers=headers
).json()
if scan["status"] == "DONE":
print(scan["data"]["totalAmount"])
break
time.sleep(2)Which to use
Prefer webhooks when you control a public endpoint — they avoid wasted polling requests and give you results the moment they are ready. Use polling for local development, serverless setups without a stable inbound URL, or quick scripts. You can also do both: supply a callbackUrl and still fall back to GET /v1/receipts/{id} if a delivery is missed.
