Skip to the page
Sections

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).

CallWhat it does
GET /api/v1/campaignThe campaign this key belongs to
POST /api/v1/codes/checkPre-check a code before the consumer fills anything in
POST /api/v1/submissionsSubmit a code with a phone number and consent
GET /api/v1/submissions/{id}The current state of one submission
POST /api/v1/submissions/lookupA phone's submissions: the "check my entries" page
POST /api/v1/receiptsStart a receipt: a photo to upload, or a typed code and time
POST /api/v1/receipts/{id}/upload-urlA 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:

On your server
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.

StatusCodeFix
401key_missingSend Authorization: Bearer <key> from your server.
401key_malformedKeys start with utc_test_ or utc_live_. Check for truncation or whitespace.
401key_unknownCheck the key was copied in full and belongs to this environment; ask ops for a new one if it was lost.
401key_wrong_environmentUse a utc_live_ key in production and a utc_test_ key everywhere else.
403key_revokedAsk ops for a new key, or switch to the campaign's second live key.
404not_foundCheck the id, and that it was created with a key for the same campaign and environment.
422campaign_mismatchOmit the header when using a campaign key.
429rate_limitedSlow down and retry after the Retry-After seconds.
500internal_errorRetry 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

HeaderRequiredMeaning
AuthorizationYesBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
X-Request-IdNoYour id for the request; echoed back and logged.
Accept-LanguageNosw for Swahili copy; otherwise English.

Request

No body.

Reply: 200

FieldTypeMeaning
slugstringThe campaign's slug.
namestringThe campaign's name.
brandstringThe brand's name.
livemodebooleanFalse when the key is a test key.
statusone oflive, paused, not_started or ended, from the pause switch and the window, now.live, paused, not_started, ended
windowobjectWhen entries are taken.
window.startstring or nullISO 8601, UTC. Null when there is no start.
window.endstring or nullISO 8601, UTC. Null when there is no end.
timezonestringThe timezone days and limits are counted in.
capabilitiesobjectWhat this campaign needs from your page.
capabilities.precheckbooleanTrue when POST /api/v1/codes/check can tell you about a code.
capabilities.code_requiredbooleanTrue when every submission must carry code.
capabilities.receipt_requiredbooleanTrue when every submission must carry receipt_id.
capabilities.typed_receiptbooleanTrue when POST /api/v1/receipts accepts type: "typed".
limitsobjectPer-phone limits. Null means no limit.
limits.phone_dailyinteger or nullSubmissions per phone per day.
limits.phone_totalinteger or nullSubmissions per phone over the campaign.
receiptobject or nullReceipt campaigns only: what to offer the consumer. Null otherwise.
receipt.max_bytesintegerThe photo size limit.
receipt.accepted_typeslist of stringThe photo types accepted.
receipt.rangeslist of objectProduct ranges, for grouping.
receipt.ranges[].namestringThe range's name.
receipt.ranges[].tagstring or nullA short tag line, if any.
receipt.productslist of objectThe participating products.
receipt.products[].keystringSend this as claimed.items[].key.
receipt.products[].rangestringThe range it belongs to.
receipt.products[].labelstringA short label.
receipt.products[].namestringThe full product name.
receipt.products[].colorstring or nullA colour for the product, if set.
receipt.products[].imagestring or nullAn image path, if set.
receipt.brancheslist of objectThe participating branches.
receipt.branches[].keystringThe branch's key.
receipt.branches[].namestringThe branch's name.
receipt.branches[].citystringIts city.
receipt.branches[].merchantstringThe merchant it belongs to.
receipt.draw_periodstring or nullHow often the draw runs, in words, if set.
messagesmap of stringEvery 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
curl -s "$UTC_API_URL/api/v1/campaign" \
  -H "Authorization: Bearer $UTC_API_KEY"
With the client
await utc.campaign()
Reply
{
  "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

HeaderRequiredMeaning
AuthorizationYesBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
Content-TypeYesapplication/json.
UTC-Client-IPNo, but send itThe 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-IdNoYour id for the request; echoed back and logged.
Accept-LanguageNosw for Swahili copy; otherwise English.

Request

FieldTypeRequiredMeaning
codestringYesThe code from the QR or as typed.1 to 64 characters
phonestringNoThe consumer's phone, if known. Their own entered code then reads as valid.

Reply: 200

FieldTypeMeaning
precheckone ofvalid, invalid, already_used, or unsupported for a Seebait campaign.valid, invalid, already_used, unsupported
message_keyone of or nullinvalid, 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
messagestring or nullThe copy for message_key; null when it is null.

Errors

StatusCodeFix
422validation_failedSee field for the first problem and the reference for the expected shape.
422sentinel_with_live_keySentinel codes and receipts only work with utc_test_ keys.

Plus the errors every call shares.

Example

curl
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"}'
With the client
await utc.checkCode({ code: "TEST-0-ENTERED", phone: "0712345678" }, { clientIp: "196.41.60.77" })
Reply
{
  "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

HeaderRequiredMeaning
AuthorizationYesBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
Content-TypeYesapplication/json.
Idempotency-KeyYes16 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-IdNoYour id for the request; echoed back and logged.
Accept-LanguageNosw for Swahili copy; otherwise English.

Request

FieldTypeRequiredMeaning
phonestringYesThe consumer's Tanzanian mobile number as typed: +255…, 255… or 0…. Stored and returned as E.164.9 to 16 characters
codestringNoThe crown code or the code we minted. Required whenever capabilities.code_required is true on the campaign.1 to 64 characters
consenttrueYesMust be true: the consumer ticked the consent box. Anything else is 422 consent_required.
regionstringNoThe consumer's region, if you collect it.up to 80 characters
receipt_iduuidNoThe id from POST /api/v1/receipts. Required when capabilities.receipt_required is true.
claimedobjectNoWhat the consumer says they bought. Recorded for comparison; it never decides anything.
claimed.itemslist of objectNoProduct keys from the campaign call, with quantities.up to 40 items
claimed.items[].keystringYesA product key from receipt.products on the campaign call.
claimed.items[].qtyintegerYesHow many.1 to 99
claimed.branchstringNoThe branch the consumer chose.up to 120 characters

Reply: 200

FieldTypeMeaning
iduuidThe submission's id. Random; safe to show.
refstringA short reference the consumer can quote.
campaignstringThe campaign's slug.
livemodebooleanFalse when made with a test key.
stateone ofpending (still being checked), accepted, rejected, or unknown (the upstream did not answer; we are settling it).pending, accepted, rejected, unknown
review_statusone of or nullReceipt 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_statusone ofThe 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_keyone ofWhich 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
messagestringThe 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.
reasonslist of stringLower snake case reasons behind the outcome. Each is a message key or an ops-only reason. For logs, not for branching.
entriesintegerEntries this submission earned. 0 until accepted.0 or more
instant_winstring or nullThe instant prize, in words, when there is one.
terminalbooleanTrue when nothing more will happen without a person acting: accepted or rejected.
poll_after_secondsinteger or nullNull when terminal. Otherwise how long to wait before calling GET /api/v1/submissions/{id}.
replayedbooleanTrue 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.
receiptobject or nullPresent once TRA has returned the receipt's record; null until then and for code-only campaigns.
receipt.datestring or nullThe receipt's date on TRA's record.
receipt.branchstring or nullThe participating branch it was matched to.
receipt.items_summarystring or nullThe qualifying items, in words.
receipt.tra_verifiedbooleanTrue: this object is only present when TRA's record was found.
upstreamobject or nullSeebait's own code and text, for Seebait campaigns. Unstable: never branch on it.
upstream.codenumber or string or nullSeebait's body.code.
upstream.messagestring or nullSeebait's message.
created_atstringISO 8601, UTC.
updated_atstringISO 8601, UTC.

Errors

StatusCodeFix
409idempotency_in_progressWait for the Retry-After seconds, then resend the same request or poll the submission.
413file_too_largeDownsize the photo in the browser (longest side 2,000 px, JPEG) before uploading.
415file_wrong_typeSend one of the accepted_types from the upload response.
422idempotency_key_requiredMint one key per attempt in the browser (16 to 128 characters) and forward it unchanged.
422idempotency_key_invalidUse a UUID or another random string of that length.
422idempotency_key_reusedMint a new key for a new attempt; resend the same key only with the same body.
422validation_failedSee field for the first problem and the reference for the expected shape.
422consent_requiredCollect the consumer's consent and send consent: true.
422phone_invalidSend +255XXXXXXXXX, 255XXXXXXXXX or 0XXXXXXXXX.
422sentinel_with_live_keySentinel codes and receipts only work with utc_test_ keys.
422receipt_requiredCreate one with POST /api/v1/receipts and send its id as receipt_id.
422receipt_not_uploadedPUT the file to the upload URL, or request a fresh one with POST /api/v1/receipts/:id/upload-url.
422receipt_already_attachedCreate a new receipt for a new submission.

Plus the errors every call shares.

Example

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}'
With the client
await utc.submit({ phone: "0712345678", code: "TEST-0-ENTERED", consent: true }, { idempotencyKey })
Reply
{
  "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

HeaderRequiredMeaning
AuthorizationYesBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
X-Request-IdNoYour id for the request; echoed back and logged.
Accept-LanguageNosw for Swahili copy; otherwise English.

Request

No body. {id} in the path is the id from the call that created the record.

Reply: 200

FieldTypeMeaning
iduuidThe submission's id. Random; safe to show.
refstringA short reference the consumer can quote.
campaignstringThe campaign's slug.
livemodebooleanFalse when made with a test key.
stateone ofpending (still being checked), accepted, rejected, or unknown (the upstream did not answer; we are settling it).pending, accepted, rejected, unknown
review_statusone of or nullReceipt 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_statusone ofThe 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_keyone ofWhich 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
messagestringThe 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.
reasonslist of stringLower snake case reasons behind the outcome. Each is a message key or an ops-only reason. For logs, not for branching.
entriesintegerEntries this submission earned. 0 until accepted.0 or more
instant_winstring or nullThe instant prize, in words, when there is one.
terminalbooleanTrue when nothing more will happen without a person acting: accepted or rejected.
poll_after_secondsinteger or nullNull when terminal. Otherwise how long to wait before calling GET /api/v1/submissions/{id}.
replayedbooleanTrue 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.
receiptobject or nullPresent once TRA has returned the receipt's record; null until then and for code-only campaigns.
receipt.datestring or nullThe receipt's date on TRA's record.
receipt.branchstring or nullThe participating branch it was matched to.
receipt.items_summarystring or nullThe qualifying items, in words.
receipt.tra_verifiedbooleanTrue: this object is only present when TRA's record was found.
upstreamobject or nullSeebait's own code and text, for Seebait campaigns. Unstable: never branch on it.
upstream.codenumber or string or nullSeebait's body.code.
upstream.messagestring or nullSeebait's message.
created_atstringISO 8601, UTC.
updated_atstringISO 8601, UTC.

Errors

None of its own.

Plus the errors every call shares.

Example

curl
curl -s "$UTC_API_URL/api/v1/submissions/$SUBMISSION_ID" \
  -H "Authorization: Bearer $UTC_API_KEY"
With the client
await utc.getSubmission(submissionId)
Reply
{
  "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

HeaderRequiredMeaning
AuthorizationYesBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
Content-TypeYesapplication/json.
UTC-Client-IPNo, but send itThe 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-IdNoYour id for the request; echoed back and logged.
Accept-LanguageNosw for Swahili copy; otherwise English.

Request

FieldTypeRequiredMeaning
phonestringYesThe phone to look up: +255…, 255… or 0….9 to 16 characters

Reply: 200

FieldTypeMeaning
submissionslist of objectNewest first, at most 50. Empty for a phone we have not seen.
submissions[].iduuidThe submission's id.
submissions[].refstringThe reference the consumer was shown.
submissions[].datestringThe day only (YYYY-MM-DD) in the campaign's timezone.
submissions[].display_statusone ofThe status to show.entered, checking, receipt_in_review, not_accepted, needs_attention
submissions[].messagestringThe message for that submission, in the request's language.
submissions[].entriesintegerEntries that submission earned.
total_entriesintegerThe sum of entries over accepted submissions.
timezonestringThe campaign's timezone, e.g. Africa/Dar_es_Salaam.

Errors

StatusCodeFix
422validation_failedSee field for the first problem and the reference for the expected shape.
422phone_invalidSend +255XXXXXXXXX, 255XXXXXXXXX or 0XXXXXXXXX.

Plus the errors every call shares.

Example

curl
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"}'
With the client
await utc.lookup({ phone: "0712345678" }, { clientIp: "196.41.60.77" })
Reply
{
  "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

HeaderRequiredMeaning
AuthorizationYesBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
Content-TypeYesapplication/json.
Idempotency-KeyNo16 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-IdNoYour id for the request; echoed back and logged.
Accept-LanguageNosw for Swahili copy; otherwise English.

Request

When type: "photo":

FieldTypeRequiredMeaning
type"photo"Yesphoto or typed.
content_typeone ofYesThe file's type after you have downsized it. The upload must carry exactly this Content-Type.image/jpeg, image/png, image/webp
size_bytesintegerYesThe 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_payloadstringNoThe 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":

FieldTypeRequiredMeaning
type"typed"Yesphoto or typed.
codestringYesThe receipt verification code printed under the QR.6 to 40 characters
timestringYesThe time printed on the receipt: hh:mm or hh:mm:ss.matches ^\d{1,2}:\d{2}(:\d{2})?$

Reply: 200

FieldTypeMeaning
iduuidSend this as receipt_id on the submission.
typeone ofphoto or typed.photo, typed
stateone ofawaiting_upload, uploaded (a typed receipt starts here) or attached (a submission uses it).awaiting_upload, uploaded, attached
uploadobject or nullThe upload target for a photo that has not arrived. Null for a typed receipt, once the file is there, and on GET.
upload.urlURLWhere the browser PUTs the file. Presigned; valid until expires_at.
upload.method"PUT"Always PUT.
upload.headersmap of stringSend exactly these headers with the PUT. They are part of the signature.
upload.expires_atstringISO 8601, UTC. After this, ask for a fresh URL.
upload.max_bytesintegerThe size limit.
upload.accepted_typeslist of stringThe file types accepted.
created_atstringISO 8601, UTC.

Errors

StatusCodeFix
413file_too_largeDownsize the photo in the browser (longest side 2,000 px, JPEG) before uploading.
422idempotency_key_invalidUse a UUID or another random string of that length.
422idempotency_key_reusedMint a new key for a new attempt; resend the same key only with the same body.
422validation_failedSee field for the first problem and the reference for the expected shape.
422typed_receipt_unsupportedCheck capabilities.typed_receipt on GET /api/v1/campaign.
422sentinel_with_live_keySentinel codes and receipts only work with utc_test_ keys.

Plus the errors every call shares.

Example

A photo

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}'
With the client
await utc.createReceipt({ type: "photo", content_type: "image/jpeg", size_bytes: 183520 })
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"
}

A typed code and time

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"}'
With the client
await utc.createReceipt({ type: "typed", code: "TEST0VERIFIED1", time: "10:15" })
Reply
{
  "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

HeaderRequiredMeaning
AuthorizationYesBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
X-Request-IdNoYour id for the request; echoed back and logged.
Accept-LanguageNosw for Swahili copy; otherwise English.

Request

No body. {id} in the path is the id from the call that created the record.

Reply: 200

FieldTypeMeaning
iduuidSend this as receipt_id on the submission.
typeone ofphoto or typed.photo, typed
stateone ofawaiting_upload, uploaded (a typed receipt starts here) or attached (a submission uses it).awaiting_upload, uploaded, attached
uploadobject or nullThe upload target for a photo that has not arrived. Null for a typed receipt, once the file is there, and on GET.
upload.urlURLWhere the browser PUTs the file. Presigned; valid until expires_at.
upload.method"PUT"Always PUT.
upload.headersmap of stringSend exactly these headers with the PUT. They are part of the signature.
upload.expires_atstringISO 8601, UTC. After this, ask for a fresh URL.
upload.max_bytesintegerThe size limit.
upload.accepted_typeslist of stringThe file types accepted.
created_atstringISO 8601, UTC.

Errors

StatusCodeFix
422receipt_already_attachedCreate a new receipt for a new submission.
422validation_failedSee field for the first problem and the reference for the expected shape.

Plus the errors every call shares.

Example

curl
curl -s -X POST "$UTC_API_URL/api/v1/receipts/$RECEIPT_ID/upload-url" \
  -H "Authorization: Bearer $UTC_API_KEY"
With the client
await utc.refreshUploadUrl(receiptId)
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: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

HeaderRequiredMeaning
AuthorizationYesBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
X-Request-IdNoYour id for the request; echoed back and logged.
Accept-LanguageNosw for Swahili copy; otherwise English.

Request

No body. {id} in the path is the id from the call that created the record.

Reply: 200

FieldTypeMeaning
iduuidSend this as receipt_id on the submission.
typeone ofphoto or typed.photo, typed
stateone ofawaiting_upload, uploaded (a typed receipt starts here) or attached (a submission uses it).awaiting_upload, uploaded, attached
uploadobject or nullThe upload target for a photo that has not arrived. Null for a typed receipt, once the file is there, and on GET.
upload.urlURLWhere the browser PUTs the file. Presigned; valid until expires_at.
upload.method"PUT"Always PUT.
upload.headersmap of stringSend exactly these headers with the PUT. They are part of the signature.
upload.expires_atstringISO 8601, UTC. After this, ask for a fresh URL.
upload.max_bytesintegerThe size limit.
upload.accepted_typeslist of stringThe file types accepted.
created_atstringISO 8601, UTC.

Errors

None of its own.

Plus the errors every call shares.

Example

curl
curl -s "$UTC_API_URL/api/v1/receipts/$RECEIPT_ID" \
  -H "Authorization: Bearer $UTC_API_KEY"
With the client
await utc.getReceipt(receiptId)
Reply
{
  "id": "9c1d4e7a-2b3f-4a68-8e15-6f0b7d2c4a90",
  "type": "photo",
  "state": "uploaded",
  "upload": null,
  "created_at": "2026-10-13T09:00:00.000Z"
}