API reference
Every call: its headers, its request and reply, the errors it can answer with, and a sample. Generated from the contract the server runs on.
All paths are under your UTC_API_URL. Requests and replies are JSON, lower snake case; timestamps are ISO 8601 in UTC; phones are returned as E.164. No code or phone ever travels in a URL. Replies are never cached (Cache-Control: no-store).
| Call | What it does |
|---|---|
GET /api/v1/campaign | The campaign this key belongs to |
POST /api/v1/codes/check | Pre-check a code before the consumer fills anything in |
POST /api/v1/submissions | Submit a code with a phone number and consent |
GET /api/v1/submissions/{id} | The current state of one submission |
POST /api/v1/submissions/lookup | A phone's submissions: the "check my entries" page |
POST /api/v1/receipts | Start a receipt: a photo to upload, or a typed code and time |
POST /api/v1/receipts/{id}/upload-url | A fresh upload URL when the first one expired |
GET /api/v1/receipts/{id} | Whether the receipt's file has arrived |
The client
Each call below also shows its line with @utc/gateway-client, where utc is:
import { createClient } from "@utc/gateway-client"
const utc = createClient({ baseUrl: process.env.UTC_API_URL!, apiKey: process.env.UTC_API_KEY! })Errors every call shares
They come from the key check and the wrapper around every route. The body is always { "error": { code, message, field?, fix, docs_url, request_id } }; branch on code.
| Status | Code | Fix |
|---|---|---|
| 401 | key_missing | Send Authorization: Bearer <key> from your server. |
| 401 | key_malformed | Keys start with utc_test_ or utc_live_. Check for truncation or whitespace. |
| 401 | key_unknown | Check the key was copied in full and belongs to this environment; ask ops for a new one if it was lost. |
| 401 | key_wrong_environment | Use a utc_live_ key in production and a utc_test_ key everywhere else. |
| 403 | key_revoked | Ask ops for a new key, or switch to the campaign's second live key. |
| 404 | not_found | Check the id, and that it was created with a key for the same campaign and environment. |
| 422 | campaign_mismatch | Omit the header when using a campaign key. |
| 429 | rate_limited | Slow down and retry after the Retry-After seconds. |
| 500 | internal_error | Retry with the same Idempotency-Key, and quote the request id if it keeps failing. |
The campaign this key belongs to
GET/api/v1/campaign
Window, status, limits, capabilities and the whole message catalogue in the request's language. Call it when your page loads to decide what to show: whether a code is needed, whether a receipt is needed, the products and branches to offer.
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
X-Request-Id | No | Your id for the request; echoed back and logged. |
Accept-Language | No | sw for Swahili copy; otherwise English. |
Request
No body.
Reply: 200
| Field | Type | Meaning |
|---|---|---|
slug | string | The campaign's slug. |
name | string | The campaign's name. |
brand | string | The brand's name. |
livemode | boolean | False when the key is a test key. |
status | one of | live, paused, not_started or ended, from the pause switch and the window, now.live, paused, not_started, ended |
window | object | When entries are taken. |
window.start | string or null | ISO 8601, UTC. Null when there is no start. |
window.end | string or null | ISO 8601, UTC. Null when there is no end. |
timezone | string | The timezone days and limits are counted in. |
capabilities | object | What this campaign needs from your page. |
capabilities.precheck | boolean | True when POST /api/v1/codes/check can tell you about a code. |
capabilities.code_required | boolean | True when every submission must carry code. |
capabilities.receipt_required | boolean | True when every submission must carry receipt_id. |
capabilities.typed_receipt | boolean | True when POST /api/v1/receipts accepts type: "typed". |
limits | object | Per-phone limits. Null means no limit. |
limits.phone_daily | integer or null | Submissions per phone per day. |
limits.phone_total | integer or null | Submissions per phone over the campaign. |
receipt | object or null | Receipt campaigns only: what to offer the consumer. Null otherwise. |
receipt.max_bytes | integer | The photo size limit. |
receipt.accepted_types | list of string | The photo types accepted. |
receipt.ranges | list of object | Product ranges, for grouping. |
receipt.ranges[].name | string | The range's name. |
receipt.ranges[].tag | string or null | A short tag line, if any. |
receipt.products | list of object | The participating products. |
receipt.products[].key | string | Send this as claimed.items[].key. |
receipt.products[].range | string | The range it belongs to. |
receipt.products[].label | string | A short label. |
receipt.products[].name | string | The full product name. |
receipt.products[].color | string or null | A colour for the product, if set. |
receipt.products[].image | string or null | An image path, if set. |
receipt.branches | list of object | The participating branches. |
receipt.branches[].key | string | The branch's key. |
receipt.branches[].name | string | The branch's name. |
receipt.branches[].city | string | Its city. |
receipt.branches[].merchant | string | The merchant it belongs to. |
receipt.draw_period | string or null | How often the draw runs, in words, if set. |
messages | map of string | Every message key with its copy in the request's language and this campaign's overrides. |
Errors
None of its own.
Plus the errors every call shares.
Example
curl -s "$UTC_API_URL/api/v1/campaign" \
-H "Authorization: Bearer $UTC_API_KEY"await utc.campaign(){
"slug": "test-native",
"name": "Test: native codes",
"brand": "UTC Test",
"livemode": false,
"status": "live",
"window": {
"start": null,
"end": null
},
"timezone": "Africa/Dar_es_Salaam",
"capabilities": {
"precheck": true,
"code_required": true,
"receipt_required": false,
"typed_receipt": false
},
"limits": {
"phone_daily": null,
"phone_total": null
},
"receipt": null,
"messages": {
"entered": "You're in. Good luck!",
"invalid": "That code isn't valid. Check it and try again.",
"…": "every message key, in the request's language"
}
}Pre-check a code before the consumer fills anything in
POST/api/v1/codes/check
For campaigns whose codes we mint (capabilities.precheck is true). Reads only: nothing is held or redeemed, and the submission checks the code again. A campaign on Seebait codes answers precheck: "unsupported", because only a submission can tell. Send phone when you have it: the consumer's own already-entered code then reads as valid, not already_used. Limited to 30 calls per consumer address range per 10 minutes.
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
Content-Type | Yes | application/json. |
UTC-Client-IP | No, but send it | The consumer's IP address as your server sees it. The per-address limits on these two calls then count per consumer; without it they count per brand server, which is stricter. |
X-Request-Id | No | Your id for the request; echoed back and logged. |
Accept-Language | No | sw for Swahili copy; otherwise English. |
Request
| Field | Type | Required | Meaning |
|---|---|---|---|
code | string | Yes | The code from the QR or as typed.1 to 64 characters |
phone | string | No | The consumer's phone, if known. Their own entered code then reads as valid. |
Reply: 200
| Field | Type | Meaning |
|---|---|---|
precheck | one of | valid, invalid, already_used, or unsupported for a Seebait campaign.valid, invalid, already_used, unsupported |
message_key | one of or null | invalid, already_used or code_held when the code cannot be used; null otherwise.entered, instant_win, invalid, code_required, already_used, daily_limit, blocked, checking, receipt_received, receipt_unreadable, receipt_not_uploaded, receipt_wrong_store, receipt_outside_window, receipt_no_qualifying_product, receipt_already_used, receipt_not_found, receipt_time_mismatch, entry_limit, code_held, campaign_not_started, campaign_ended, campaign_paused, rate_limited, service_unavailable, file_too_large, file_wrong_type |
message | string or null | The copy for message_key; null when it is null. |
Errors
| Status | Code | Fix |
|---|---|---|
| 422 | validation_failed | See field for the first problem and the reference for the expected shape. |
| 422 | sentinel_with_live_key | Sentinel codes and receipts only work with utc_test_ keys. |
Plus the errors every call shares.
Example
curl -s -X POST "$UTC_API_URL/api/v1/codes/check" \
-H "Authorization: Bearer $UTC_API_KEY" \
-H "Content-Type: application/json" \
-H "UTC-Client-IP: 196.41.60.77" \
-d '{"code":"TEST-0-ENTERED","phone":"0712345678"}'await utc.checkCode({ code: "TEST-0-ENTERED", phone: "0712345678" }, { clientIp: "196.41.60.77" }){
"precheck": "valid",
"message_key": null,
"message": null
}Submit a code with a phone number and consent
POST/api/v1/submissions
The one call that enters a consumer. The campaign's configuration decides where the code is checked and whether a receipt is needed. Every outcome about the consumer (bad code, used code, limit reached, campaign closed, receipt declined) is HTTP 200 with a submission and a message_key; only a problem with your request is a 4xx. A second request with the same Idempotency-Key and body returns the first submission with Idempotent-Replayed: true. A new key with the same code and phone returns the earlier submission with replayed: true unless that one was rejected.
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
Content-Type | Yes | application/json. |
Idempotency-Key | Yes | 16 to 128 characters. Mint one per attempt in the browser when the consumer presses submit, and forward it unchanged on every retry of that attempt. |
X-Request-Id | No | Your id for the request; echoed back and logged. |
Accept-Language | No | sw for Swahili copy; otherwise English. |
Request
| Field | Type | Required | Meaning |
|---|---|---|---|
phone | string | Yes | The consumer's Tanzanian mobile number as typed: +255…, 255… or 0…. Stored and returned as E.164.9 to 16 characters |
code | string | No | The crown code or the code we minted. Required whenever capabilities.code_required is true on the campaign.1 to 64 characters |
consent | true | Yes | Must be true: the consumer ticked the consent box. Anything else is 422 consent_required. |
region | string | No | The consumer's region, if you collect it.up to 80 characters |
receipt_id | uuid | No | The id from POST /api/v1/receipts. Required when capabilities.receipt_required is true. |
claimed | object | No | What the consumer says they bought. Recorded for comparison; it never decides anything. |
claimed.items | list of object | No | Product keys from the campaign call, with quantities.up to 40 items |
claimed.items[].key | string | Yes | A product key from receipt.products on the campaign call. |
claimed.items[].qty | integer | Yes | How many.1 to 99 |
claimed.branch | string | No | The branch the consumer chose.up to 120 characters |
Reply: 200
| Field | Type | Meaning |
|---|---|---|
id | uuid | The submission's id. Random; safe to show. |
ref | string | A short reference the consumer can quote. |
campaign | string | The campaign's slug. |
livemode | boolean | False when made with a test key. |
state | one of | pending (still being checked), accepted, rejected, or unknown (the upstream did not answer; we are settling it).pending, accepted, rejected, unknown |
review_status | one of or null | Receipt campaigns only: verified, under_review or declined. Null for code-only submissions and for ones refused before the receipt was read.verified, under_review, declined |
display_status | one of | The one status to show a consumer. Computed by the server from state and review_status.entered, checking, receipt_in_review, not_accepted, needs_attention |
message_key | one of | Which message this is. Branch on this, never on message.entered, instant_win, invalid, code_required, already_used, daily_limit, blocked, checking, receipt_received, receipt_unreadable, receipt_not_uploaded, receipt_wrong_store, receipt_outside_window, receipt_no_qualifying_product, receipt_already_used, receipt_not_found, receipt_time_mismatch, entry_limit, code_held, campaign_not_started, campaign_ended, campaign_paused, rate_limited, service_unavailable, file_too_large, file_wrong_type |
message | string | The copy for message_key in the request's language, with the campaign's overrides. Show it as it is; it is also the fallback for a key your code does not know. |
reasons | list of string | Lower snake case reasons behind the outcome. Each is a message key or an ops-only reason. For logs, not for branching. |
entries | integer | Entries this submission earned. 0 until accepted.0 or more |
instant_win | string or null | The instant prize, in words, when there is one. |
terminal | boolean | True when nothing more will happen without a person acting: accepted or rejected. |
poll_after_seconds | integer or null | Null when terminal. Otherwise how long to wait before calling GET /api/v1/submissions/{id}. |
replayed | boolean | True when this is an earlier submission returned for the same campaign, code and phone, so a refresh never tells a consumer their own code is used. |
receipt | object or null | Present once TRA has returned the receipt's record; null until then and for code-only campaigns. |
receipt.date | string or null | The receipt's date on TRA's record. |
receipt.branch | string or null | The participating branch it was matched to. |
receipt.items_summary | string or null | The qualifying items, in words. |
receipt.tra_verified | boolean | True: this object is only present when TRA's record was found. |
upstream | object or null | Seebait's own code and text, for Seebait campaigns. Unstable: never branch on it. |
upstream.code | number or string or null | Seebait's body.code. |
upstream.message | string or null | Seebait's message. |
created_at | string | ISO 8601, UTC. |
updated_at | string | ISO 8601, UTC. |
Errors
| Status | Code | Fix |
|---|---|---|
| 409 | idempotency_in_progress | Wait for the Retry-After seconds, then resend the same request or poll the submission. |
| 413 | file_too_large | Downsize the photo in the browser (longest side 2,000 px, JPEG) before uploading. |
| 415 | file_wrong_type | Send one of the accepted_types from the upload response. |
| 422 | idempotency_key_required | Mint one key per attempt in the browser (16 to 128 characters) and forward it unchanged. |
| 422 | idempotency_key_invalid | Use a UUID or another random string of that length. |
| 422 | idempotency_key_reused | Mint a new key for a new attempt; resend the same key only with the same body. |
| 422 | validation_failed | See field for the first problem and the reference for the expected shape. |
| 422 | consent_required | Collect the consumer's consent and send consent: true. |
| 422 | phone_invalid | Send +255XXXXXXXXX, 255XXXXXXXXX or 0XXXXXXXXX. |
| 422 | sentinel_with_live_key | Sentinel codes and receipts only work with utc_test_ keys. |
| 422 | receipt_required | Create one with POST /api/v1/receipts and send its id as receipt_id. |
| 422 | receipt_not_uploaded | PUT the file to the upload URL, or request a fresh one with POST /api/v1/receipts/:id/upload-url. |
| 422 | receipt_already_attached | Create a new receipt for a new submission. |
Plus the errors every call shares.
Example
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}'await utc.submit({ phone: "0712345678", code: "TEST-0-ENTERED", consent: true }, { idempotencyKey }){
"id": "3f2b8f0e-7c1a-4d55-9a53-0d7e2f5c9b11",
"ref": "K7M2Q9",
"campaign": "test-native",
"livemode": false,
"state": "accepted",
"review_status": null,
"display_status": "entered",
"message_key": "entered",
"message": "You're in. Good luck!",
"reasons": [],
"entries": 1,
"instant_win": null,
"terminal": true,
"poll_after_seconds": null,
"replayed": false,
"receipt": null,
"upstream": null,
"created_at": "2026-10-13T09:00:00.000Z",
"updated_at": "2026-10-13T09:00:00.000Z"
}The current state of one submission
GET/api/v1/submissions/{id}
Poll this while terminal is false, waiting poll_after_seconds between calls. A submission made with another campaign's key, or with a key of the other environment (test or live), is a 404.
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
X-Request-Id | No | Your id for the request; echoed back and logged. |
Accept-Language | No | sw for Swahili copy; otherwise English. |
Request
No body. {id} in the path is the id from the call that created the record.
Reply: 200
| Field | Type | Meaning |
|---|---|---|
id | uuid | The submission's id. Random; safe to show. |
ref | string | A short reference the consumer can quote. |
campaign | string | The campaign's slug. |
livemode | boolean | False when made with a test key. |
state | one of | pending (still being checked), accepted, rejected, or unknown (the upstream did not answer; we are settling it).pending, accepted, rejected, unknown |
review_status | one of or null | Receipt campaigns only: verified, under_review or declined. Null for code-only submissions and for ones refused before the receipt was read.verified, under_review, declined |
display_status | one of | The one status to show a consumer. Computed by the server from state and review_status.entered, checking, receipt_in_review, not_accepted, needs_attention |
message_key | one of | Which message this is. Branch on this, never on message.entered, instant_win, invalid, code_required, already_used, daily_limit, blocked, checking, receipt_received, receipt_unreadable, receipt_not_uploaded, receipt_wrong_store, receipt_outside_window, receipt_no_qualifying_product, receipt_already_used, receipt_not_found, receipt_time_mismatch, entry_limit, code_held, campaign_not_started, campaign_ended, campaign_paused, rate_limited, service_unavailable, file_too_large, file_wrong_type |
message | string | The copy for message_key in the request's language, with the campaign's overrides. Show it as it is; it is also the fallback for a key your code does not know. |
reasons | list of string | Lower snake case reasons behind the outcome. Each is a message key or an ops-only reason. For logs, not for branching. |
entries | integer | Entries this submission earned. 0 until accepted.0 or more |
instant_win | string or null | The instant prize, in words, when there is one. |
terminal | boolean | True when nothing more will happen without a person acting: accepted or rejected. |
poll_after_seconds | integer or null | Null when terminal. Otherwise how long to wait before calling GET /api/v1/submissions/{id}. |
replayed | boolean | True when this is an earlier submission returned for the same campaign, code and phone, so a refresh never tells a consumer their own code is used. |
receipt | object or null | Present once TRA has returned the receipt's record; null until then and for code-only campaigns. |
receipt.date | string or null | The receipt's date on TRA's record. |
receipt.branch | string or null | The participating branch it was matched to. |
receipt.items_summary | string or null | The qualifying items, in words. |
receipt.tra_verified | boolean | True: this object is only present when TRA's record was found. |
upstream | object or null | Seebait's own code and text, for Seebait campaigns. Unstable: never branch on it. |
upstream.code | number or string or null | Seebait's body.code. |
upstream.message | string or null | Seebait's message. |
created_at | string | ISO 8601, UTC. |
updated_at | string | ISO 8601, UTC. |
Errors
None of its own.
Plus the errors every call shares.
Example
curl -s "$UTC_API_URL/api/v1/submissions/$SUBMISSION_ID" \
-H "Authorization: Bearer $UTC_API_KEY"await utc.getSubmission(submissionId){
"id": "3f2b8f0e-7c1a-4d55-9a53-0d7e2f5c9b11",
"ref": "K7M2Q9",
"campaign": "test-native",
"livemode": false,
"state": "accepted",
"review_status": null,
"display_status": "entered",
"message_key": "entered",
"message": "You're in. Good luck!",
"reasons": [],
"entries": 1,
"instant_win": null,
"terminal": true,
"poll_after_seconds": null,
"replayed": false,
"receipt": null,
"upstream": null,
"created_at": "2026-10-13T09:00:00.000Z",
"updated_at": "2026-10-13T09:00:00.000Z"
}A phone's submissions: the "check my entries" page
POST/api/v1/submissions/lookup
The latest 50 submissions for a phone in this campaign, with a reference, the day, a display status, the message and the entries. A phone we have never seen gets the same shape with an empty list. Limited to 5 calls per phone and 30 per consumer address range per 10 minutes, so forward the consumer's address in UTC-Client-IP.
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
Content-Type | Yes | application/json. |
UTC-Client-IP | No, but send it | The consumer's IP address as your server sees it. The per-address limits on these two calls then count per consumer; without it they count per brand server, which is stricter. |
X-Request-Id | No | Your id for the request; echoed back and logged. |
Accept-Language | No | sw for Swahili copy; otherwise English. |
Request
| Field | Type | Required | Meaning |
|---|---|---|---|
phone | string | Yes | The phone to look up: +255…, 255… or 0….9 to 16 characters |
Reply: 200
| Field | Type | Meaning |
|---|---|---|
submissions | list of object | Newest first, at most 50. Empty for a phone we have not seen. |
submissions[].id | uuid | The submission's id. |
submissions[].ref | string | The reference the consumer was shown. |
submissions[].date | string | The day only (YYYY-MM-DD) in the campaign's timezone. |
submissions[].display_status | one of | The status to show.entered, checking, receipt_in_review, not_accepted, needs_attention |
submissions[].message | string | The message for that submission, in the request's language. |
submissions[].entries | integer | Entries that submission earned. |
total_entries | integer | The sum of entries over accepted submissions. |
timezone | string | The campaign's timezone, e.g. Africa/Dar_es_Salaam. |
Errors
| Status | Code | Fix |
|---|---|---|
| 422 | validation_failed | See field for the first problem and the reference for the expected shape. |
| 422 | phone_invalid | Send +255XXXXXXXXX, 255XXXXXXXXX or 0XXXXXXXXX. |
Plus the errors every call shares.
Example
curl -s -X POST "$UTC_API_URL/api/v1/submissions/lookup" \
-H "Authorization: Bearer $UTC_API_KEY" \
-H "Content-Type: application/json" \
-H "UTC-Client-IP: 196.41.60.77" \
-d '{"phone":"0712345678"}'await utc.lookup({ phone: "0712345678" }, { clientIp: "196.41.60.77" }){
"submissions": [
{
"id": "3f2b8f0e-7c1a-4d55-9a53-0d7e2f5c9b11",
"ref": "K7M2Q9",
"date": "2026-10-13",
"display_status": "entered",
"message": "You're in. Good luck!",
"entries": 1
}
],
"total_entries": 1,
"timezone": "Africa/Dar_es_Salaam"
}Start a receipt: a photo to upload, or a typed code and time
POST/api/v1/receipts
type: "photo" returns a receipt id and a presigned upload target; the browser PUTs the file there. type: "typed" takes the verification code and time printed on the receipt and needs no file. Either way, send the receipt's id as receipt_id on the submission. With an Idempotency-Key, the same key and body returns the same receipt (with a fresh upload URL if the file has not arrived).
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
Content-Type | Yes | application/json. |
Idempotency-Key | No | 16 to 128 characters. Mint one per attempt in the browser when the consumer presses submit, and forward it unchanged on every retry of that attempt. |
X-Request-Id | No | Your id for the request; echoed back and logged. |
Accept-Language | No | sw for Swahili copy; otherwise English. |
Request
When type: "photo":
| Field | Type | Required | Meaning |
|---|---|---|---|
type | "photo" | Yes | photo or typed. |
content_type | one of | Yes | The file's type after you have downsized it. The upload must carry exactly this Content-Type.image/jpeg, image/png, image/webp |
size_bytes | integer | Yes | The file's size in bytes after downsizing. The upload must be exactly this long. Over 5 MB is 413 file_too_large.more than 0 |
qr_payload | string | No | The text of the receipt's QR code, if the browser decoded it. Used only to look the receipt up with TRA.up to 400 characters |
When type: "typed":
| Field | Type | Required | Meaning |
|---|---|---|---|
type | "typed" | Yes | photo or typed. |
code | string | Yes | The receipt verification code printed under the QR.6 to 40 characters |
time | string | Yes | The time printed on the receipt: hh:mm or hh:mm:ss.matches ^\d{1,2}:\d{2}(:\d{2})?$ |
Reply: 200
| Field | Type | Meaning |
|---|---|---|
id | uuid | Send this as receipt_id on the submission. |
type | one of | photo or typed.photo, typed |
state | one of | awaiting_upload, uploaded (a typed receipt starts here) or attached (a submission uses it).awaiting_upload, uploaded, attached |
upload | object or null | The upload target for a photo that has not arrived. Null for a typed receipt, once the file is there, and on GET. |
upload.url | URL | Where the browser PUTs the file. Presigned; valid until expires_at. |
upload.method | "PUT" | Always PUT. |
upload.headers | map of string | Send exactly these headers with the PUT. They are part of the signature. |
upload.expires_at | string | ISO 8601, UTC. After this, ask for a fresh URL. |
upload.max_bytes | integer | The size limit. |
upload.accepted_types | list of string | The file types accepted. |
created_at | string | ISO 8601, UTC. |
Errors
| Status | Code | Fix |
|---|---|---|
| 413 | file_too_large | Downsize the photo in the browser (longest side 2,000 px, JPEG) before uploading. |
| 422 | idempotency_key_invalid | Use a UUID or another random string of that length. |
| 422 | idempotency_key_reused | Mint a new key for a new attempt; resend the same key only with the same body. |
| 422 | validation_failed | See field for the first problem and the reference for the expected shape. |
| 422 | typed_receipt_unsupported | Check capabilities.typed_receipt on GET /api/v1/campaign. |
| 422 | sentinel_with_live_key | Sentinel codes and receipts only work with utc_test_ keys. |
Plus the errors every call shares.
Example
A photo
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}'await utc.createReceipt({ 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"
}A typed code and time
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"}'await utc.createReceipt({ type: "typed", code: "TEST0VERIFIED1", time: "10:15" }){
"id": "9c1d4e7a-2b3f-4a68-8e15-6f0b7d2c4a90",
"type": "typed",
"state": "uploaded",
"upload": null,
"created_at": "2026-10-13T09:00:00.000Z"
}A fresh upload URL when the first one expired
POST/api/v1/receipts/{id}/upload-url
No body. Returns the receipt with a new upload target while the file has not arrived. Once the file is there, state is uploaded and upload is null: the first upload for a receipt is the only one.
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
X-Request-Id | No | Your id for the request; echoed back and logged. |
Accept-Language | No | sw for Swahili copy; otherwise English. |
Request
No body. {id} in the path is the id from the call that created the record.
Reply: 200
| Field | Type | Meaning |
|---|---|---|
id | uuid | Send this as receipt_id on the submission. |
type | one of | photo or typed.photo, typed |
state | one of | awaiting_upload, uploaded (a typed receipt starts here) or attached (a submission uses it).awaiting_upload, uploaded, attached |
upload | object or null | The upload target for a photo that has not arrived. Null for a typed receipt, once the file is there, and on GET. |
upload.url | URL | Where the browser PUTs the file. Presigned; valid until expires_at. |
upload.method | "PUT" | Always PUT. |
upload.headers | map of string | Send exactly these headers with the PUT. They are part of the signature. |
upload.expires_at | string | ISO 8601, UTC. After this, ask for a fresh URL. |
upload.max_bytes | integer | The size limit. |
upload.accepted_types | list of string | The file types accepted. |
created_at | string | ISO 8601, UTC. |
Errors
| Status | Code | Fix |
|---|---|---|
| 422 | receipt_already_attached | Create a new receipt for a new submission. |
| 422 | validation_failed | See field for the first problem and the reference for the expected shape. |
Plus the errors every call shares.
Example
curl -s -X POST "$UTC_API_URL/api/v1/receipts/$RECEIPT_ID/upload-url" \
-H "Authorization: Bearer $UTC_API_KEY"await utc.refreshUploadUrl(receiptId){
"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:11:00.000Z",
"max_bytes": 5242880,
"accepted_types": [
"image/jpeg",
"image/png",
"image/webp"
]
},
"created_at": "2026-10-13T09:00:00.000Z"
}Whether the receipt's file has arrived
GET/api/v1/receipts/{id}
state moves from awaiting_upload to uploaded when the file is in storage, and to attached once a submission uses it. upload is always null here.
Headers
| Header | Required | Meaning |
|---|---|---|
Authorization | Yes | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
X-Request-Id | No | Your id for the request; echoed back and logged. |
Accept-Language | No | sw for Swahili copy; otherwise English. |
Request
No body. {id} in the path is the id from the call that created the record.
Reply: 200
| Field | Type | Meaning |
|---|---|---|
id | uuid | Send this as receipt_id on the submission. |
type | one of | photo or typed.photo, typed |
state | one of | awaiting_upload, uploaded (a typed receipt starts here) or attached (a submission uses it).awaiting_upload, uploaded, attached |
upload | object or null | The upload target for a photo that has not arrived. Null for a typed receipt, once the file is there, and on GET. |
upload.url | URL | Where the browser PUTs the file. Presigned; valid until expires_at. |
upload.method | "PUT" | Always PUT. |
upload.headers | map of string | Send exactly these headers with the PUT. They are part of the signature. |
upload.expires_at | string | ISO 8601, UTC. After this, ask for a fresh URL. |
upload.max_bytes | integer | The size limit. |
upload.accepted_types | list of string | The file types accepted. |
created_at | string | ISO 8601, UTC. |
Errors
None of its own.
Plus the errors every call shares.
Example
curl -s "$UTC_API_URL/api/v1/receipts/$RECEIPT_ID" \
-H "Authorization: Bearer $UTC_API_KEY"await utc.getReceipt(receiptId){
"id": "9c1d4e7a-2b3f-4a68-8e15-6f0b7d2c4a90",
"type": "photo",
"state": "uploaded",
"upload": null,
"created_at": "2026-10-13T09:00:00.000Z"
}