Concepts
What a submission is, the states it moves through, and the rules that make a retry safe.
A submission and its entries
A submission is one attempt by one phone to enter one campaign: a code, a phone number and consent, and a receipt where the campaign needs one. Every submission that reaches the ledger gets an id and a short ref the consumer can quote, whatever its outcome.
Entries are what an accepted submission is worth in the draw. A code-only submission earns 1. A receipt submission earns one per qualifying item on TRA's record of the receipt, up to the campaign's caps. entries is 0 until the submission is accepted.
So a phone can have several submissions, some rejected, and its chance in the draw is the sum of entries over the accepted ones. That sum is total_entries on the lookup call.
States and display status
state is where the submission is. Receipt campaigns add review_status, which says where the receipt is. display_status is the one thing to show a consumer; the server computes it so every page, report and email agrees.
state
| State | Meaning | Terminal |
|---|---|---|
pending | Recorded and still being checked. It will settle without the consumer doing anything. | No |
accepted | It counts. entries says for how much. | Yes |
rejected | It does not count. message_key says why. | Yes |
unknown | The upstream did not give a usable answer. We settle it; the consumer is told "Checking". | No |
review_status (receipt campaigns)
| Review status | Meaning |
|---|---|
verified | TRA returned the receipt and every rule passed. |
under_review | Waiting for TRA, or for a person. |
declined | A rule failed on TRA's record, or a reviewer declined it. |
display_status
What the consumer is shown for each combination. The labels are the defaults in English and Swahili.
| State | Review status | Display status | English | Swahili |
|---|---|---|---|---|
accepted | none | entered | Entered | Umeingia |
accepted | verified | entered | Entered | Umeingia |
pending | none | checking | Checking | Inakaguliwa |
pending | under_review | receipt_in_review | Receipt in review | Risiti inakaguliwa |
unknown | none | checking | Checking | Inakaguliwa |
rejected | none | not_accepted | Not accepted | Haijakubaliwa |
rejected | declined | not_accepted | Not accepted | Haijakubaliwa |
The values are entered, checking, receipt_in_review, not_accepted, needs_attention. needs_attention is what ops see for an unknown submission; the API never sends it to a frontend.
Every allowed move
This table is the data the server enforces: a move that is not here is refused. A terminal state changes again only if a person acts.
| From | To | Moved by | When |
|---|---|---|---|
pending | accepted | Seebait's answer | Seebait accepted the code |
pending | rejected | Seebait's answer | Seebait rejected the code |
pending | unknown | Seebait's answer | Seebait timed out, failed, or answered with something unrecognised |
pending | unknown | The unknown sweeper (a job) | A pending Seebait row was never sent and the single send did not settle |
pending | accepted | The unknown sweeper (a job) | A pending Seebait row with no upstream_sent_at was sent once and accepted |
pending | rejected | The unknown sweeper (a job) | A pending Seebait row with no upstream_sent_at was sent once and rejected |
unknown | accepted | The unknown sweeper (a job) | The one resubmission was accepted, or answered already-used with no other accepted submission for the code |
unknown | rejected | The unknown sweeper (a job) | The one resubmission was rejected |
unknown | accepted | Ops, by hand | Settled by hand from the attention queue |
unknown | rejected | Ops, by hand | Settled by hand from the attention queue |
pending | accepted | The submission request | Native code redeemed; or receipt verified by TRA on the interactive attempt |
pending | rejected | The submission request | Native code invalid or used; or a receipt rule failed on TRA's record |
pending | accepted | The TRA re-check (a job) | The re-check job got TRA's record and every rule passed |
pending | rejected | The TRA re-check (a job) | The re-check job got TRA's record and a rule failed |
pending | accepted | A reviewer | A reviewer approved a receipt that TRA has returned |
pending | rejected | A reviewer | A reviewer declined the receipt; the in-store code is released |
Headers
Every header the API reads or sends.
| You send | On | Meaning |
|---|---|---|
Authorization | Every call | Bearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only. |
Idempotency-Key | Required on POST /api/v1/submissions; accepted on POST /api/v1/receipts | 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 | Every call (optional) | Your own id for the request: 8 to 128 characters of letters, digits, ., _, : or -. It is written to our logs and the submission row and echoed back. Anything else is replaced with an id we mint. |
UTC-Campaign | Every call (optional) | With a campaign key the campaign comes from the key, so leave this out. If sent, it must equal the key's campaign or the call is 422 campaign_mismatch. |
UTC-Client-IP | POST /api/v1/submissions/lookup and POST /api/v1/codes/check | 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. |
Accept-Language | Every call (optional) | sw for Swahili; anything else is English. Sets the language of message and of messages on the campaign call. ?locale=en|sw on the URL does the same and wins. |
| We send | On | Meaning |
|---|---|---|
X-Request-Id | Every reply | The request id: yours if it was usable, otherwise ours. Quote it when asking about a request. It is also error.request_id. |
Idempotent-Replayed | 200 from POST /api/v1/submissions | true when this Idempotency-Key and body were seen before and the stored submission is being returned. Absent otherwise. |
Retry-After | 429 rate_limited and 409 idempotency_in_progress | Whole seconds to wait before sending the same request again. |
Test and live
A key is utc_test_… or utc_live_…, and that decides the environment of everything made with it. livemode on a submission, a receipt and the campaign call says which.
- A test key works on every deployment. It never reaches Seebait or TRA: sentinel codes and receipts give known outcomes instead. It redeems only codes from a test batch.
- A live key works only on production. Elsewhere it answers 401
key_wrong_environment. - The two never see each other's records: a submission made with a test key is a 404 to a live key for the same campaign, and the reverse.
- A sentinel sent with a live key is 422
sentinel_with_live_key.
Each campaign has its own keys. Ops can make two live keys for one campaign so a key can be replaced with no gap. A key has a requests-per-minute ceiling (600 unless ops changed it); over it, calls answer 429 rate_limited with Retry-After.
Idempotency: one key per attempt
Idempotency-Key makes a retry safe. It is required on POST /api/v1/submissions and accepted on POST /api/v1/receipts. It is 16 to 128 characters; a UUID is fine.
What to mint, and where
- In the browser, when the consumer presses submit, mint one key for that attempt.
- Keep it while that attempt is retried: a dropped connection, a timeout, a second press of the same button.
- On your server, forward it unchanged. Do not mint a new one per request to us: that defeats it.
- A new attempt gets a new key: the consumer edits the code or the phone and presses submit again.
import { mintIdempotencyKey } from "@utc/gateway-client/idempotency" // safe in a browser: no key, no client
let attemptKey: string | null = null
async function onSubmit(form: { phone: string; code: string; consent: boolean }) {
attemptKey ??= mintIdempotencyKey() // the same key if the network drops and we send again
const res = await fetch("/api/enter", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": attemptKey },
body: JSON.stringify(form),
})
if (res.ok) attemptKey = null // recorded: the next press is a new attempt
return res.json()
}What comes back
- The same key and the same body: the stored submission as it is now, with the header
Idempotent-Replayed: true. Nothing new is written. - The same key with a different body: 422
idempotency_key_reused. - The same key while the first request is still running (a receipt submission waiting on TRA): 409
idempotency_in_progresswithRetry-After. Once the first request is older than the 8-second budget, the stored pending submission comes back instead. - On receipts, the same key and body returns the same receipt. That reply does not carry
Idempotent-Replayed.
The body is compared after tidying: the phone as E.164, the code in its normal form. +255712345678 and 0712345678 are the same body.
Replay: a refresh never says "already used"
Separate from the key. When a new key arrives with the same campaign, code and phone as an earlier submission that is pending, unknown or accepted, no new submission is made: the earlier one comes back with replayed: true. A consumer who refreshes the page and submits again sees their own entry, not "that code has already been used".
If the earlier submission was rejected, a new one is made. And a different phone with the same code gets already_used or code_held, as it should.
One consequence
receipt_id it carries. See the typed fallback in Code and receipt.The time budget
Each layer gives the one below it less time than it has itself, so a slow answer is still an answer.
- The API aims to answer a submission inside 8 seconds. For a receipt it waits at most 2 seconds for its turn with TRA and gives TRA 3 seconds; past that it answers
receipt_receivedand a job finishes the check. - Your server, through the client, allows 15 seconds per attempt, and retries a timeout or a 5xx up to twice with the same key. Set
timeoutMslower if your own route has less time than that. - The browser should wait longer than your server does, and keep the form and the key if it gives up, so the consumer can press submit again.
When a submission is not terminal, the reply carries poll_after_seconds: 5 while a code is settling, 15 while a receipt waits on TRA or an upstream result is unknown, 300 once a receipt is waiting for a person. Tell the consumer the page can be closed; Check my entries is how they come back.
An upload URL is valid for 5 minutes.
Pause
Ops can pause a campaign without a deploy. While paused, status on the campaign call is paused, and a submission answers HTTP 200 with state: "rejected" and message_key: "campaign_paused". It is an outcome, not an error, so show the message. The same shape covers a campaign before its window (campaign_not_started) and after it (campaign_ended).
Read status when your page loads and show the right screen first; the submission's answer is the backstop.
Versioning
v1 changes are additive only. We may add a path, a field on a reply, an optional field on a request, a message key, an error code, a value to an enumeration. We will not remove or rename one, change a type, or make an optional request field required.
- Ignore fields you do not know.
- For a
message_keyyour code does not know, showmessage: the server always sends the copy. - For an
error.codeyou do not know, go by the HTTP status. - A removal would be announced with
DeprecationandSunsetheaders on the affected call, 90 days ahead. No call carries them today.
The contract is one set of schemas in packages/gateway. The OpenAPI file is generated from it, and the build fails if the file drifts from the code or a change is not additive.