Skip to the page
Sections

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

StateMeaningTerminal
pendingRecorded and still being checked. It will settle without the consumer doing anything.No
acceptedIt counts. entries says for how much.Yes
rejectedIt does not count. message_key says why.Yes
unknownThe upstream did not give a usable answer. We settle it; the consumer is told "Checking".No

review_status (receipt campaigns)

Review statusMeaning
verifiedTRA returned the receipt and every rule passed.
under_reviewWaiting for TRA, or for a person.
declinedA 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.

StateReview statusDisplay statusEnglishSwahili
acceptednoneenteredEnteredUmeingia
acceptedverifiedenteredEnteredUmeingia
pendingnonecheckingCheckingInakaguliwa
pendingunder_reviewreceipt_in_reviewReceipt in reviewRisiti inakaguliwa
unknownnonecheckingCheckingInakaguliwa
rejectednonenot_acceptedNot acceptedHaijakubaliwa
rejecteddeclinednot_acceptedNot acceptedHaijakubaliwa

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.

FromToMoved byWhen
pendingacceptedSeebait's answerSeebait accepted the code
pendingrejectedSeebait's answerSeebait rejected the code
pendingunknownSeebait's answerSeebait timed out, failed, or answered with something unrecognised
pendingunknownThe unknown sweeper (a job)A pending Seebait row was never sent and the single send did not settle
pendingacceptedThe unknown sweeper (a job)A pending Seebait row with no upstream_sent_at was sent once and accepted
pendingrejectedThe unknown sweeper (a job)A pending Seebait row with no upstream_sent_at was sent once and rejected
unknownacceptedThe unknown sweeper (a job)The one resubmission was accepted, or answered already-used with no other accepted submission for the code
unknownrejectedThe unknown sweeper (a job)The one resubmission was rejected
unknownacceptedOps, by handSettled by hand from the attention queue
unknownrejectedOps, by handSettled by hand from the attention queue
pendingacceptedThe submission requestNative code redeemed; or receipt verified by TRA on the interactive attempt
pendingrejectedThe submission requestNative code invalid or used; or a receipt rule failed on TRA's record
pendingacceptedThe TRA re-check (a job)The re-check job got TRA's record and every rule passed
pendingrejectedThe TRA re-check (a job)The re-check job got TRA's record and a rule failed
pendingacceptedA reviewerA reviewer approved a receipt that TRA has returned
pendingrejectedA reviewerA reviewer declined the receipt; the in-store code is released

Headers

Every header the API reads or sends.

You sendOnMeaning
AuthorizationEvery callBearer <key>. The campaign key, utc_test_… or utc_live_…. It selects the campaign and the environment. Sent from your server only.
Idempotency-KeyRequired on POST /api/v1/submissions; accepted on POST /api/v1/receipts16 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-IdEvery 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-CampaignEvery 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-IPPOST /api/v1/submissions/lookup and POST /api/v1/codes/checkThe 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-LanguageEvery 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 sendOnMeaning
X-Request-IdEvery replyThe request id: yours if it was usable, otherwise ours. Quote it when asking about a request. It is also error.request_id.
Idempotent-Replayed200 from POST /api/v1/submissionstrue when this Idempotency-Key and body were seen before and the stored submission is being returned. Absent otherwise.
Retry-After429 rate_limited and 409 idempotency_in_progressWhole 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.
In the browser
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_progress with Retry-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.

Warning:

One consequence

This also means a receipt campaign cannot attach a second receipt to a code that is still being checked. A second submission with the same code and phone returns the first one, whatever 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_received and 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 timeoutMs lower 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_key your code does not know, show message: the server always sends the copy.
  • For an error.code you do not know, go by the HTTP status.
  • A removal would be announced with Deprecation and Sunset headers 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.