Skip to the page
Sections

Add a brand

From a new campaign in the config to a live frontend.

Every step to put a new brand's campaign on the API, in order. The steps marked ops need access to the databases and hosts; the operator's version of this page is docs/runbooks/add-a-brand.md in the repository.

1. Describe the campaign

Add an entry to packages/campaigns/src/campaigns.json. It is the source of truth for brands and campaigns; nothing is edited in the database by hand.

One campaign
{
  "slug": "acme-summer",
  "name": "Acme Summer 2027",
  "brand": "Acme",
  "country": "TZ",
  "mechanic": "crown-code",
  "entrySource": "native",
  "seebait": null,
  "window": { "start": "2027-01-10", "end": "2027-03-31" },
  "platformStatus": "live",
  "smsSender": "ACME",
  "messages": { "entered": { "en": "You're in the Acme draw. Good luck!" } },
  "surfaces": []
}
  • slug: lower-case letters, digits and hyphens. It is the campaign's name in keys, reports and storage paths; it never changes.
  • mechanic: crown-code (a code is enough) or receipt (a code and a till receipt). A receipt campaign needs a receipt block: window, merchants and branches, products and their receipt patterns, caps. Copy the shape from spier.
  • entrySource: native (we mint the codes) or seebait (crown codes checked by Seebait). A Seebait campaign needs seebait.shortCode and seebait.confirmed: true: the short code selects the campaign at Seebait, and an unconfirmed one is not pushed.
  • window: first and last day, in Dar es Salaam. Outside it submissions answer campaign_not_started or campaign_ended.
  • platformStatus: live or paused.
  • smsSender: the approved sender ID, 11 characters at most (step 8).
  • messages: your own wording for any message key, in en, sw or both. Keys that are not in the contract are refused.

2. Push it

ops
pnpm campaigns:push --env staging --dry-run   # shows the diff, writes nothing
pnpm campaigns:push --env staging
pnpm campaigns:push --env production          # asks you to type "production"

The push validates every campaign before it writes, shows what will change, and never deletes. For staging and production set DATABASE_URL to that environment's utc_api URL first; the script never guesses a remote database.

3. Make the keys

ops
pnpm keys create --campaign acme-summer --label "Acme web (test)" --env staging
pnpm keys create --campaign acme-summer --label "Acme web" --live --env production
pnpm keys create --campaign acme-summer --label "Acme web (spare)" --live --env production

A key is shown once. Only its prefix and a hash are kept, so it cannot be shown again; a lost key is replaced with pnpm keys rotate <prefix>. Two live keys let you swap one with no gap. The script needs UTC_KEY_HMAC_SECRET from the same environment the API runs with.

4. Mint codes (native campaigns)

ops
pnpm codes:mint --campaign acme-summer --label "Launch wobblers" --count 500 \
  --entry-base https://acme.example.co.tz/ --out acme-launch.csv
pnpm codes:mint --campaign acme-summer --label "QA" --count 20 --test \
  --entry-base https://acme.example.co.tz/ --out acme-test.csv

The CSV is the only copy of the codes; the database keeps hashes. --entry-base is printed inside every QR, so it must be the site's final public address before anything goes to print. A --test batch can be redeemed only with a test key.

5. The frontend's Vercel project

  • A new Vercel project with its root directory at apps/<brand>, region fra1, connected to the repository.
  • Two environment variables, on the server only: UTC_API_URL (Manager's address for that environment) and UTC_API_KEY. Never prefix either with NEXT_PUBLIC_.
  • Preview deployments get the staging URL and a test key; production gets a live key.
  • The site's public address, on the brand's domain, before codes are minted (step 4).

6. Firewall rules

Per-address limits on the brand site's own routes are Vercel Firewall rules on the brand's project. Set a rate limit on the route that submits and the route that looks a phone up, per IP address. They stop a flood before it reaches the API key's ceiling, which is shared by every consumer of the site.

The API has its own limits behind them: the key's requests-per-minute ceiling, and the lookup and code-check limits per consumer address, which only work if your server forwards UTC-Client-IP.

7. Allow uploads from the site (receipt campaigns)

The browser PUTs receipt photos straight to the storage bucket, so the bucket's CORS rule must list the site's origin: PUT, the Content-Type header. Ops adds the production origin and the preview origin to the bucket for each environment (ensureBucket in @utc/receipt-io). Without it the upload fails in the browser with a CORS error and nothing reaches the API. The local stand-in does not check, so prove one upload on staging.

8. SMS sender ID

If the campaign texts consumers (a winner, a settled receipt), register the sender ID with Africa's Talking for Tanzanian networks. Approval takes days, so start early. Put the approved ID in smsSender and push.

9. Build the frontend

Start from apps/spier, the reference frontend for a code-and-receipt campaign, and packages/gateway-client/README.md for the wiring: a route on your server that holds the key, the idempotency key minted in the browser, the fake client for tests.

10. Check, then go live

  1. On staging, with the test key: the campaign call shows the right window and capabilities.
  2. A sentinel submission is accepted, and one of each outcome your page handles shows the right screen.
  3. For a receipt campaign: a real photo uploads from the staging site in a browser.
  4. A code from the test batch redeems once, and a second phone gets already_used.
  5. On production, with the live key: the campaign call answers with livemode: true. Do not submit a sentinel: it answers 422 sentinel_with_live_key.
  6. Ops can see the campaign in the console and the test rows are not in its numbers.