Code and receipt
Upload a till receipt from the browser, submit it with the code, and follow it until TRA has answered.
For a campaign where capabilities.receipt_required is true: the consumer sends a code and a till receipt. TRA's record of the receipt decides whether it counts and how many entries it earns.
Four calls deal with the receipt, and the photo itself goes from the browser straight to storage:
- Browser. Downsizes the photo, then tells your server its type and exact size.
- Your server → API.
POST /api/v1/receiptswithtype: "photo". The reply has the receiptidand anuploadtarget. - Browser → storage. PUTs the file straight to
upload.urlwith exactlyupload.headers. The file never passes through your server or ours. - Your server → API. Only if the URL expired:
POST /api/v1/receipts/{id}/upload-urlfor a fresh one. OptionallyGET /api/v1/receipts/{id}to confirmstate: "uploaded". - Your server → API.
POST /api/v1/submissionswith the phone, the code, consent andreceipt_id. - Browser. Shows the message. If the submission is not
terminal, pollsGET /api/v1/submissions/{id}through your server everypoll_after_seconds.
1. Downsize the photo in the browser
A phone photo is 3 to 12 MB; the limit is 5 MB, and a smaller file uploads faster on a mobile connection. Draw it to a canvas with the longest side at 2,000 px and export a JPEG. The QR code stays readable at that size.
async function downsize(file: File): Promise<Blob> {
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" })
const scale = Math.min(1, 2000 / Math.max(bitmap.width, bitmap.height))
const canvas = document.createElement("canvas")
canvas.width = Math.round(bitmap.width * scale)
canvas.height = Math.round(bitmap.height * scale)
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("could not encode"))), "image/jpeg", 0.85))
}Check the result before going further: if blob.size is over 5,242,880 bytes show the file_too_large message; if the file was not an image show file_wrong_type.
If your page can decode the receipt's QR code, send its text as qr_payload. It saves us decoding the photo, and it is only ever used to look the receipt up with TRA.
2. Start the receipt
Send the type and the exact size of the blob you will upload. Both are signed into the upload URL.
curl -s -X POST "$UTC_API_URL/api/v1/receipts" \
-H "Authorization: Bearer $UTC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"photo","content_type":"image/jpeg","size_bytes":183520}'{
"id": "9c1d4e7a-2b3f-4a68-8e15-6f0b7d2c4a90",
"type": "photo",
"state": "awaiting_upload",
"upload": {
"url": "https://utc-receipts.fra1.digitaloceanspaces.com/test-receipt/9c1d4e7a-2b3f-4a68-8e15-6f0b7d2c4a90?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost&X-Amz-Signature=…",
"method": "PUT",
"headers": {
"Content-Type": "image/jpeg"
},
"expires_at": "2026-10-13T09:05:00.000Z",
"max_bytes": 5242880,
"accepted_types": [
"image/jpeg",
"image/png",
"image/webp"
]
},
"created_at": "2026-10-13T09:00:00.000Z"
}const receipt = await utc.createReceipt({ type: "photo", content_type: "image/jpeg", size_bytes: blob.size })3. Upload from the browser
PUT the blob to upload.url. Three things must be exact, because the URL is signed with them:
- The headers: send every header in
upload.headers, with those values, and no others of your own. Today that isContent-Type. Do not addAuthorization: the key never goes to the browser, and the URL is the permission. - The body: the blob itself, exactly
size_byteslong. The browser setsContent-Lengthfrom it. - The method:
PUT, not a form POST.
const put = await fetch(receipt.upload.url, {
method: receipt.upload.method, // "PUT"
headers: receipt.upload.headers, // exactly these
body: blob,
})
if (!put.ok) throw new Error(`upload failed: ${put.status}`)The upload is cross-origin
4. If the URL expired
An upload URL lasts 5 minutes (upload.expires_at). After that storage answers 403. Ask for a new one for the same receipt:
curl -s -X POST "$UTC_API_URL/api/v1/receipts/$RECEIPT_ID/upload-url" \
-H "Authorization: Bearer $UTC_API_KEY"uploadis a new target: PUT to it.uploadis null andstateisuploaded: the file is already there. The first upload for a receipt is the only one; go on to the submission.- 422
receipt_already_attached: a submission already uses this receipt.
To check without asking for a URL:
curl -s "$UTC_API_URL/api/v1/receipts/$RECEIPT_ID" \
-H "Authorization: Bearer $UTC_API_KEY"5. Submit
The same call as a code-only campaign, with receipt_id. claimed is optional: what the consumer says they bought, kept for comparison.
curl -s -X POST "$UTC_API_URL/api/v1/submissions" \
-H "Authorization: Bearer $UTC_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"phone":"0712345678","code":"TEST-0-ENTERED","consent":true,"receipt_id":"'"$RECEIPT_ID"'"}'const submission = await utc.submit(
{ phone, code, consent: true, receipt_id: receipt.id, claimed: { items: [{ key: "signature-merlot", qty: 2 }], branch: "regent-estate" } },
{ idempotencyKey }
)If the photo is not in storage the answer is 422 receipt_not_uploaded. Finish the PUT and send the same request again with the same Idempotency-Key.
6. The outcome, and polling
accepted/verified,entered: TRA returned the receipt and every rule passed.entriesis the number of qualifying items;receipthas the date, branch and items.pending/under_review,receipt_received: recorded, and waiting for TRA or for a person. Say so, and say the page can be closed.pending/under_review,receipt_unreadable: the QR could not be read from the photo. A person will look at it.rejected/declined:message_keysays why:receipt_wrong_store,receipt_outside_window,receipt_no_qualifying_product,receipt_already_used,receipt_not_found,receipt_time_mismatch.
While terminal is false, poll through your server, waiting poll_after_seconds each time. It is 15 while the receipt waits on TRA and 300 once it is waiting for a person, so follow the number; do not pick your own.
curl -s "$UTC_API_URL/api/v1/submissions/$SUBMISSION_ID" \
-H "Authorization: Bearer $UTC_API_KEY"const latest = await utc.pollSubmission(submission.id, {
maxSeconds: 45,
onUpdate: (s) => console.log(s.display_status, s.poll_after_seconds),
})
// latest.terminal may still be false: show "we're checking", and point to Check my entries.The typed fallback
When there is no usable photo (the camera failed, the QR is torn), the consumer can type the verification code printed under the QR and the time on the receipt. capabilities.typed_receipt says whether the campaign takes it.
curl -s -X POST "$UTC_API_URL/api/v1/receipts" \
-H "Authorization: Bearer $UTC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"typed","code":"TEST0VERIFIED1","time":"10:15"}'A typed receipt needs no upload: its state is uploaded at once and upload is null. Send its id as receipt_id on the submission. A wrong time is receipt_time_mismatch; the consumer can correct it with a new receipt and a new submission.
When to offer it
receipt_unreadable. That submission is pending and holds the consumer's code, so the retry goes to the same one: create a new receipt (typed, or a clearer photo) and submit again with the same code and phone, a new Idempotency-Key and the new receipt_id. The reply carries the same submission id, now checked against the new receipt, with replayed: false. A submission takes three replacement receipts; after that it replays and a person reviews it.Errors on the way
- 413
file_too_large:size_bytesis over the limit, or the stored file is. Downsize and start a new receipt. - 415
file_wrong_type: the stored file is not a JPEG, PNG or WebP. Acontent_typeoutside those three on step 2 is 422validation_failed. - 422
receipt_required: the campaign needsreceipt_id. - 422
receipt_already_attached: one receipt, one submission. Start a new receipt. - 404
not_found: thereceipt_idis not one of this campaign's, or was made with a key of the other environment.