Skip to the page
Sections

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:

  1. Browser. Downsizes the photo, then tells your server its type and exact size.
  2. Your server → API. POST /api/v1/receipts with type: "photo". The reply has the receipt id and an upload target.
  3. Browser → storage. PUTs the file straight to upload.url with exactly upload.headers. The file never passes through your server or ours.
  4. Your server → API. Only if the URL expired: POST /api/v1/receipts/{id}/upload-url for a fresh one. Optionally GET /api/v1/receipts/{id} to confirm state: "uploaded".
  5. Your server → API. POST /api/v1/submissions with the phone, the code, consent and receipt_id.
  6. Browser. Shows the message. If the submission is not terminal, polls GET /api/v1/submissions/{id} through your server every poll_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.

In the browser
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
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}'
The reply
{
  "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"
}
With the client
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 is Content-Type. Do not add Authorization: the key never goes to the browser, and the URL is the permission.
  • The body: the blob itself, exactly size_bytes long. The browser sets Content-Length from it.
  • The method: PUT, not a form POST.
In the browser
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}`)
Note:

The upload is cross-origin

A browser will only make this request if the storage bucket lists your site's origin. Ops adds it when a brand is set up (Add a brand). The local stand-in does not check, so a first upload from a new origin has to be proven on staging.

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
curl -s -X POST "$UTC_API_URL/api/v1/receipts/$RECEIPT_ID/upload-url" \
  -H "Authorization: Bearer $UTC_API_KEY"
  • upload is a new target: PUT to it.
  • upload is null and state is uploaded: 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
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
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"'"}'
With the client
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. entries is the number of qualifying items; receipt has 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_key says 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
curl -s "$UTC_API_URL/api/v1/submissions/$SUBMISSION_ID" \
  -H "Authorization: Bearer $UTC_API_KEY"
With the client
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
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.

Warning:

When to offer it

Offer the typed path when the browser cannot decode a QR from the photo, and again when a submission comes back 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_bytes is 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. A content_type outside those three on step 2 is 422 validation_failed.
  • 422 receipt_required: the campaign needs receipt_id.
  • 422 receipt_already_attached: one receipt, one submission. Start a new receipt.
  • 404 not_found: the receipt_id is not one of this campaign's, or was made with a key of the other environment.