# NGAIGE reference client guide — building against Channel API v1 today

There is no NGAIGE SDK package yet, and this guide does not pretend there is
one. What ships today is the **Channel API v1** — a small, pinned HTTP +
JSON surface — and this guide is the **reference client guide**: the shapes,
conventions and failure grammar your client (any language) must implement,
demonstrated end-to-end with raw HTTPS calls over the five flows nearly
every integrator needs. When per-language SDKs land they will be thin
bindings over exactly these calls; nothing here changes shape.

Human contract guide (full route reference):
[`../api/README.md`](../api/README.md). Machine-readable contract:
[`../api/openapi.v1.json`](../api/openapi.v1.json). This document is the
client-side half: how to drive that contract correctly from application
code.

## In plain English

There is no boxed software kit to download yet — and this page says so in
its first breath. What exists today is the published front door, and this
guide teaches you, in any programming language, exactly how to knock
correctly: how to sign requests, how to repeat a request safely without
sending a campaign twice, what every polite refusal means, and what to do
when told to slow down. It does this by telling one continuous true story —
a bakery joins, brings its permitted list, runs a campaign, and reads its
bill — where every single exchange really happened and the bytes are on the
page. **The one takeaway:** you can build against this product today, in
any language, from this guide alone — and when proper kits arrive they will
be thin wrappers over exactly these calls, so nothing you build now breaks.
*(≈140 words)*

## Why it matters

- **Honesty about what ships.** "No kit yet" is stated plainly at the top —
  the guide sells a door that truly exists, never a box that doesn't.
- **Safe by taught default.** Tenant identity from the key only, replay-safe
  retries and refusal handling are taught as patterns, not fine print — a
  client built to this guide inherits the compliance posture for free.
- **Executed, not narrated.** Every exchange ran against a real boot on the
  recorded date; appendices carry the run log and the hygiene trail.

*(The full client-side reference — conventions, the five flows, and the
executed transcripts — follows unchanged.)*

---

**Every request/response block below was executed for real** 2026-08-27
~01:42–01:44 UTC against a local boot (dev PostgreSQL 16, repo @ `625e07c`,
binary built with go1.27.0, base `http://127.0.0.1:18099`). Secrets are
redacted; all other bytes are verbatim from the wire. Appendix A carries the
execution log with counts; Appendix B the scratch hygiene. The five curl
outputs per flow are the responses this boot actually returned.

The running example is one continuous story a client library would drive
for an SME tenant: onboard **Copper Kettle Bakery**, import its consented
contact list both ways, run a campaign through draft→plan→submit→approve
(with the approval scope separated on its own key), dispatch to four
numbers (one on the STOP list), then read back the report and the masked
suppression ledger.

The five flows at a glance — green marks the compliance seams (consent,
the human approval, the STOP skip). Each flow jumps to its fully executed
section below: [flow 1](#flow-1--onboard-a-business) ·
[flow 2](#flow-2--import-contacts-consent-is-a-wire-precondition) ·
[flow 3](#flow-3--draft--plan--submit--approve-scope-separation-is-the-law) ·
[flow 4](#flow-4--dispatch-the-approval-gate--the-stop-skip) ·
[flow 5](#flow-5--read-the-report--the-masked-suppression-ledger).

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","clusterBkg":"#faf8fd","clusterBorder":"#440088","titleColor":"#1b1230","edgeLabelBackground":"#ffffff"}}}%%
%% truth: docs/sdk/README.md flows 1–5 (each executed against a local boot) · internal/channelapi/channelapi.go Routes()
flowchart TB
    subgraph F1["1 · Onboard a business"]
        direction LR
        A1["name + sender name"] --> A2["201 — the business id<br/>tenant comes from the key"]
    end
    subgraph F2["2 · Import the contact list"]
        direction LR
        B1["the list + exactly<br/>one consent proof"] --> B2["200 — every row accounted:<br/>accepted · refused-consent ·<br/>refused-format · duplicates"]
    end
    subgraph F3["3 · Draft → plan → submit → approve"]
        direction LR
        C1["draft + plan<br/>(machine key)"] --> C2["submit"]
        C2 --> C3["<b>a named human approves</b><br/>(the separate approve key)"]
    end
    subgraph F4["4 · Dispatch"]
        direction LR
        D1["approved only —<br/>the gate is consulted"] --> D2["every number checked<br/>against the STOP ledger"]
        D2 --> D3["<b>sent count + it names<br/>who was skipped</b>"]
    end
    subgraph F5["5 · Read the receipts"]
        direction LR
        E1["the campaign fold:<br/>six counts + cost with verdict"] --> E2["the masked STOP list —<br/>never a raw number"]
    end
    F1 --> F2 --> F3 --> F4 --> F5

    classDef greencall fill:#eafff4,stroke:#0FFC5A,stroke-width:2.5px,color:#0b5c2e
    class B1,C3,D3 greencall
    linkStyle 1 stroke:#0FFC5A,stroke-width:3px
    linkStyle 3 stroke:#0FFC5A,stroke-width:3px
    linkStyle 5 stroke:#0FFC5A,stroke-width:3px
```

![The five flows — the whole product, API-shaped](../api/diagrams/five-flows-index.svg)

*Truth source: this guide's five flows (each executed in Appendix A) and
`internal/channelapi/channelapi.go` `Routes()`.*

## Transport & credentials

### Base URL and wire

- **Base URL:** every route lives under `{base}/api/v1/`. Locally the boot
  listens on loopback (`http://127.0.0.1:18099` below); deployed surfaces
  serve HTTPS at the estate domain (see the status table in
  `../api/README.md` — the staging boundary is mounted and answering
  no-oracle 401s as of 2026-08-27; keyed flows run once a tenant key is minted).
- **Framing:** JSON in, JSON out, UTF-8. `Content-Type: application/json`
  on bodies; refusals are `application/problem+json` (RFC7807 — taxonomy
  below).
- **Correlation:** every response carries an `X-Request-Id` header (visible
  in the executed 429 capture below). Quote it when reporting a problem —
  it is the cross-reference for logs.

### Tenant keys

Authentication is a per-tenant bearer credential:

```http
Authorization: Bearer nk_<key_id>_<secret>
```

The tenant identity comes **from the key only** — no body field, query
parameter or path segment may carry a tenant id. Scopes are per key and
closed (full catalogue in `../api/README.md`); a wrong-scope call is
refused `403 scope-required` naming the missing scope before any object
lookup.

**The first key is an operator act** on the host (`POST /api/v1/keys` only
mints for an already-authenticated `admin:all` holder), via
`ngaige apikey-seed` (executed, secret redacted):

```sh
$ ngaige apikey-seed --tenant 72152492-a481-4375-b767-7c151a5ade90 --scopes admin:all
tenant seeded: 72152492-a481-4375-b767-7c151a5ade90
key: nk_4658f0ee_<64-hex secret — redacted>
store this now — it is never shown again (digest-only at rest)
```

Every later key mints through the API itself. The two keys this guide uses
were minted exactly as your client would (write-scoped automation key,
then an approve-only key for the human gate — why this split matters shows
up in flow 3):

```sh
$ curl -X POST $B/api/v1/keys \
    -H "Authorization: Bearer $ADMIN_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-key-write-001' \
    -d '{"scopes":["onboarding:write","imports:write","campaigns:write","reports:read","suppression:read"]}'
{"key_id":"2e03cb5820bc869b","key":"nk_2e03cb5820bc869b_<secret redacted>","scopes":["onboarding:write","imports:write","campaigns:write","reports:read","suppression:read"],"created_at":"2026-08-27T01:42:56.590155739Z"}
HTTP 201

$ curl -X POST $B/api/v1/keys \
    -H "Authorization: Bearer $ADMIN_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-key-approve-001' \
    -d '{"scopes":["campaigns:approve"]}'
{"key_id":"9f4f1bba7e5e770a","key":"nk_9f4f1bba7e5e770a_<secret redacted>","scopes":["campaigns:approve"],"created_at":"2026-08-27T01:42:58.005719168Z"}
HTTP 201
```

The plaintext key is revealed **once** in the mint response and nowhere
else ever — only an HMAC digest rests in the store. A client library
should therefore treat mint responses as secrets material: persist to your
secret store in the same code path, never log.

**The no-oracle 401 law** (executed): absent header, malformed header,
unknown key id, wrong secret and revoked key all answer the *same bytes*:

```sh
$ curl $B/api/v1/suppression
{"type":"about:blank","title":"unauthorized","status":401}
HTTP 401          # + header WWW-Authenticate: Bearer
```

Do not build "is this key close to right?" UX from the 401 detail — there
is deliberately none to read.

### Conventions your client must implement

1. **`Idempotency-Key` on every mutating POST** — required. Same key +
   same request fingerprint replays the stored status and body
   byte-identically with `X-Idempotent-Replay: true`; same key + different
   body is `409 idempotency-conflict`; missing is `400
   idempotency-key-required` (both executed, taxonomy below). Generate one
   fresh opaque value per *logical user operation* and reuse it across
   your own transports retries (timeouts, 5xx, connection drops) — that is
   the intended retry-safe contract.
2. **Money is decimal strings** with exactly 4 fractional digits
   (`"3.6300"`), computed in code. Never float-parse them into display
   arithmetic.
3. **Server ordering:** authn (401) → scope (403) → rate (429) →
   idempotency → validation (4xx/422) → handler. A 403 therefore tells you
   nothing about whether the object exists; a 404 means no-oracle uniform
   "not found" (which also covers foreign tenants' objects).
4. **Timestamps** are RFC3339 UTC; schedules must be strictly in the
   future.

## Flow 1 — Onboard a business

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","edgeLabelBackground":"#ffffff"}}}%%
%% truth: docs/sdk/README.md Flow 1 (executed: POST /api/v1/clients, 201, tenant from the key) · internal/onboarding/onboarding.go OnboardClient
flowchart LR
    A["the business's<br/>name + sender name"] --> B["one client row<br/>per business"]
    B --> C["201 — keep the<br/>business id"]
    classDef blue fill:#eaf3fc,stroke:#2a78d6,color:#1b1230
    class A blue
```

![Flow 1 — onboard a business](../api/diagrams/flow-1-onboard.svg)

*Truth source: this section's executed calls +
`internal/onboarding/onboarding.go` (`OnboardClient`).*

`POST /api/v1/clients` (scope `onboarding:write`). One business = one
client row; `sender_id` is the handset-visible originator (max 11
alphanumeric chars). Executed:

```sh
$ curl -X POST $B/api/v1/clients \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-client-001' \
    -d '{"business_name":"Copper Kettle Bakery","sender_id":"COPPERKTL"}'
{"id":"16dcb736-aef9-47c9-85b2-cd7459a8009a","tenant_id":"72152492-a481-4375-b767-7c151a5ade90","business_name":"Copper Kettle Bakery","sender_id":"COPPERKTL","country":"","created_at":"2026-08-27T01:43:02.609235238Z"}
HTTP 201
```

Client code keeps `id` — every later step references it. Note the
`tenant_id` in the response came from the key, not the request.

## Flow 2 — Import contacts (consent is a wire precondition)

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","edgeLabelBackground":"#ffffff"}}}%%
%% truth: docs/sdk/README.md Flow 2 (executed: affirmation + evidence variants 200, no carrier 422 nothing stored) · internal/channelapi/handlers.go (exactly-one-carrier) · internal/onboarding/onboarding.go ImportRecipients*
flowchart LR
    A["the contact list"] --> B{{"exactly ONE<br/>consent proof<br/>riding along?"}}
    B -- yes --> C["200 — every row accounted:<br/>accepted · refused-consent ·<br/>refused-format · duplicates"]
    B -- no or both --> D["422 — nothing stored,<br/>zero rows written"]
    classDef greencall fill:#eafff4,stroke:#0FFC5A,stroke-width:2.5px,color:#0b5c2e
    class B,C greencall
    linkStyle 0 stroke:#6b5d86
    linkStyle 1 stroke:#0FFC5A,stroke-width:3px
```

![Flow 2 — import the list with exactly one consent proof](../api/diagrams/flow-2-import.svg)

*Truth source: this section's executed calls +
`internal/channelapi/handlers.go` (the exactly-one-carrier law) +
`internal/onboarding/onboarding.go` (`ImportRecipients`,
`ImportRecipientsWithEvidence`).*

`POST /api/v1/imports` (scope `imports:write`). CSV header is exactly
`msisdn,consent,consented_at`. **The request must carry exactly one consent
proof carrier** — this is the consent carrier law and it is in the wire
contract, not a terms-of-service PDF:

### Variant A — affirmation

`consent_affirmation: true` — the importer attests recorded permission
exists (accepted rows stamp `csv-upload`). Executed:

```sh
$ curl -X POST $B/api/v1/imports \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-import-001' \
    -d '{"client_id":"16dcb736-aef9-47c9-85b2-cd7459a8009a","csv":"msisdn,consent,consented_at\n+31655500200,true,2026-08-20T09:05:00Z\n+31655500201,true,2026-08-20T09:06:00Z","consent_affirmation":true}'
{"import_id":"667c6366-8f7f-4d9b-87c8-9e9c2887d9cf","total":2,"accepted":2,"rejected_consent":0,"rejected_format":0,"duplicates":0}
HTTP 200
```

### Variant B — evidence

`consent_evidence_ref` + `evidence_captured_at` — reference a **previously
saved consent evidence row** (saved upstream, e.g. when the signed sheet's
photo landed in the intake journey); the sheet's capture instant becomes
every accepted row's consent stamp. The ref is the evidence row's id — it
must exist first. Executed (evidence row `bd8bb5d4-…` saved beforehand,
see Appendix A note):

```sh
$ curl -X POST $B/api/v1/imports \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-import-002' \
    -d '{"client_id":"16dcb736-aef9-47c9-85b2-cd7459a8009a","csv":"msisdn,consent,consented_at\n+31655500202,true,2026-08-15T12:00:00Z","consent_evidence_ref":"bd8bb5d4-8dfb-405b-9c2c-678ca0813e0e","evidence_captured_at":"2026-08-15T12:00:00Z"}'
{"import_id":"03d4b970-7ae9-4958-8d01-a59e8e39c8ad","total":1,"accepted":1,"rejected_consent":0,"rejected_format":0,"duplicates":0}
HTTP 200
```

### The law when no carrier is present

No carrier (or both carriers, or an evidence ref without its capture
instant) is a 422 and **nothing is stored** — zero import-port calls
happen. Executed refusal:

```sh
$ curl -X POST $B/api/v1/imports \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-import-003' \
    -d '{"client_id":"16dcb736-aef9-47c9-85b2-cd7459a8009a","csv":"msisdn,consent,consented_at\n+31655500204,true,2026-08-21T10:00:00Z"}'
{"type":"urn:ngaige:problem:v1:consent-required","title":"consent proof required","status":422,"detail":"provide exactly one consent proof carrier: consent_affirmation=true (the importer attests recorded permission exists) or consent_evidence_ref (a previously saved consent evidence row); nothing is stored without one"}
HTTP 422
```

Row-level hygiene is reported in the response: `total = accepted +
rejected_consent + rejected_format + duplicates` always accounts for every
row. Rows marked `consent:false` are refused individually.

## Flow 3 — Draft → plan → submit → approve (scope separation is the law)

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","edgeLabelBackground":"#ffffff"}}}%%
%% truth: docs/sdk/README.md Flow 3 (executed: draft 201 → plan 200 → submit 200 → approve 403 with the write key, 200 with the approve key) · internal/campaign/service.go
flowchart LR
    A["draft<br/>(machine key)"] --> B["plan — echoes<br/>the draft,<br/>cost in code"]
    B --> C["submit —<br/>waiting on a human"]
    C --> D["<b>a named human<br/>approves</b><br/>(separate approve key)"]
    classDef greencall fill:#eafff4,stroke:#0FFC5A,stroke-width:2.5px,color:#0b5c2e
    class D greencall
    linkStyle 2 stroke:#0FFC5A,stroke-width:3px
```

![Flow 3 — draft, plan, submit, then the named human approves](../api/diagrams/flow-3-campaign.svg)

*Truth source: this section's executed calls +
`internal/campaign/service.go`.*

The campaign lifecycle is four routes. The first three take
`campaigns:write`; the fourth takes **its own `campaigns:approve` scope on
purpose** — so an automation key can draft, plan, submit and even dispatch
without ever holding the human-decision power. Execute with two keys:
`$WRITE_KEY` for machine steps, `$APPROVE_KEY` (minted above with only
`campaigns:approve`) in the human's hands.

**Draft** (`POST /api/v1/campaigns/drafts`). `schedule` must be strictly
in the future; the name is a per-tenant handle — re-posting the same name
re-answers the stored draft with 200, never a second row. Executed:

```sh
$ curl -X POST $B/api/v1/campaigns/drafts \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-draft-001' \
    -d '{"name":"weekend-bread-drop","goal":"sell the weekend sourdough surplus","audience":"consented regulars from the August sheet and counter signups","message":"Copper Kettle Bakery: fresh sourdough drop Saturday 8am — regulars get first loaf half price. Show this text. STOP to opt out.","schedule":"2026-08-29T01:43:26Z"}'
{"id":"6c3cef0b-74c7-4b54-b337-9a1ea9f387c0","tenant_id":"72152492-a481-4375-b767-7c151a5ade90","name":"weekend-bread-drop","goal":"sell the weekend sourdough surplus","audience":"consented regulars from the August sheet and counter signups","message":"Copper Kettle Bakery: fresh sourdough drop Saturday 8am — regulars get first loaf half price. Show this text. STOP to opt out.","schedule":"2026-08-29T01:43:26Z","state":"DRAFT","created_at":"2026-08-27T01:43:26.410054227Z","updated_at":"2026-08-27T01:43:26.410054227Z"}
HTTP 201
```

**Plan** (`POST …/{id}/plan`) asks the sovereign model router for the
`campaign-plan-v1` document; the plan *echoes the draft verbatim* (a plan
that drifts from its draft is refused) and estimates are computed in code
off the ledger rate. This is a model call — budget several seconds (the
executed call took 7.5s; the router's first-choice lane of the day, k3,
answered — the hierarchy is dated config, see the note in
`../api/README.md`). Executed (trimmed:
two review-note free-texts shown as the lane filled them):

```sh
$ curl -X POST $B/api/v1/campaigns/6c3cef0b-74c7-4b54-b337-9a1ea9f387c0/plan \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Idempotency-Key: sdk-plan-001'
{"campaign_id":"6c3cef0b-74c7-4b54-b337-9a1ea9f387c0","plan":{"schema_version":"campaign-plan-v1","goal":"sell the weekend sourdough surplus","audience":"consented regulars from the August sheet and counter signups","message":"Copper Kettle Bakery: fresh sourdough drop Saturday 8am — regulars get first loaf half price. Show this text. STOP to opt out.","schedule":"2026-08-29T01:43:26Z","segments":[{"name":"August sheet regulars","size":87},{"name":"Counter signups","size":34}],"message_variants":["Copper Kettle Bakery: fresh sourdough this Saturday from 8am. Regulars get the first loaf half price — show this text at the counter. Reply STOP to opt out.","Saturday sourdough drop at Copper Kettle Bakery, 8am until sold out. Your first loaf is half price as a regular — just show this text. Reply STOP to opt out."],"budget_line":"","risk_notes":"Scheduled send is 01:43 UTC, which may land in the middle of the night local time — confirm the local send hour before approving. Text only contacts with recorded consent; segment sizes are placeholders to verify against the consent list.","estimated_messages":121,"estimated_cost_eur":"3.6300","generated_at":"2026-08-27T01:43:35.781345906Z"}}
HTTP 200
```

Note what the lane saw: the draft's brief text only. No contact row, no
phone number ever reaches a plan prompt (by construction —
`internal/agent/planengine`). Segments and the estimate are advisory
review material for the human; the dispatch machine, not the plan, decides
reality.

**Submit** (`POST …/{id}/submit`) moves DRAFT → PENDING_APPROVAL:

```sh
$ curl -X POST $B/api/v1/campaigns/6c3cef0b-74c7-4b54-b337-9a1ea9f387c0/submit \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Idempotency-Key: sdk-submit-001'
{"campaign_id":"6c3cef0b-74c7-4b54-b337-9a1ea9f387c0","state":"PENDING_APPROVAL"}
HTTP 200
```

**Approve** (`POST …/{id}/approve`) records the human decision — and here
the scope separation shows honestly, both halves executed. The write key
(the automation that did everything so far) *cannot* approve:

```sh
$ curl -X POST $B/api/v1/campaigns/6c3cef0b-74c7-4b54-b337-9a1ea9f387c0/approve \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-approve-403' \
    -d '{"actor":"automation:cron"}'
{"type":"urn:ngaige:problem:v1:scope-required","title":"forbidden","status":403,"detail":"this route requires scope campaigns:approve"}
HTTP 403
```

The restricted second key — the one your approval UI or operator console
holds — approves, naming its human (`actor` is required and echoed into
the audit trail):

```sh
$ curl -X POST $B/api/v1/campaigns/6c3cef0b-74c7-4b54-b337-9a1ea9f387c0/approve \
    -H "Authorization: Bearer $APPROVE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-approve-001' \
    -d '{"actor":"owner:marta"}'
{"campaign_id":"6c3cef0b-74c7-4b54-b337-9a1ea9f387c0","state":"APPROVED","approved_by":"owner:marta","approved_at":"2026-08-27T01:43:39.513786427Z"}
HTTP 200
```

Client architecture guidance: keep the approve key in a separate custody
path (approval UI session, operator console) from the automation key. The
API makes machine-minted approvals impossible by scope, but only if you
actually split custody this way.

## Flow 4 — Dispatch (the approval gate + the STOP skip)

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","edgeLabelBackground":"#ffffff"}}}%%
%% truth: docs/sdk/README.md Flow 4 (executed: 409 before approval; 200 after — sent 3, suppressed 1 named) · internal/dispatch/service.go (gate first, STOP check inside the send path)
flowchart LR
    A["the campaign<br/>must be APPROVED<br/>(409 before)"] --> B["every number checked<br/>against the STOP ledger<br/>inside the send path"]
    B --> C["sent count +<br/><b>it names exactly<br/>who was skipped</b>"]
    classDef greencall fill:#eafff4,stroke:#0FFC5A,stroke-width:2.5px,color:#0b5c2e
    class A greencall
    linkStyle 1 stroke:#0FFC5A,stroke-width:3px
```

![Flow 4 — approved only, every number checked, the skip named](../api/diagrams/flow-4-dispatch.svg)

*Truth source: this section's executed calls +
`internal/dispatch/service.go` (gate first; the STOP check inside the send
path).*

`POST /api/v1/dispatch-requests` (scope `campaigns:write`) runs the gated
dispatch over the sandbox SMS gateway (Phase One has no real SMS; receipts
are the seeded sandbox's, signed and verifiable).

**The gate is consulted, never re-implemented.** Dispatch against a
campaign that is not APPROVED is a 409 — executed against a second,
never-submitted draft:

```sh
$ curl -X POST $B/api/v1/dispatch-requests \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-disp-notapproved' \
    -d '{"campaign_id":"f415b640-96ce-454b-9b81-a5e949ab13ad","targets":["+31655500200"]}'
{"type":"urn:ngaige:problem:v1:not-approved","title":"not approved","status":409}
HTTP 409
```

**The STOP skip is visible in the response.** The approved campaign's
dispatch below carries four targets; `+31655500203` is on this tenant's
STOP ledger (an inbound STOP recorded that morning — seeded for this run,
see Appendix A). The dispatch machine checks every target against the
tenant's durable STOP ledger *inside the send path* and skips suppressed
numbers; the API's `suppressed_msisdn` is the read-only prediction from
the same seam, in verbatim submission order. Targets do not have to be
imported recipients first — the gate that matters at send time is the
STOP ledger. Executed:

```sh
$ curl -X POST $B/api/v1/dispatch-requests \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: sdk-disp-001' \
    -d '{"campaign_id":"6c3cef0b-74c7-4b54-b337-9a1ea9f387c0","targets":["+31655500200","+31655500201","+31655500202","+31655500203"]}'
{"campaign_id":"6c3cef0b-74c7-4b54-b337-9a1ea9f387c0","recipients":4,"sent":3,"pending_retry":0,"terminal_failed":0,"suppressed":1,"suppressed_msisdn":["+31655500203"]}
HTTP 200
```

Four recipients, three sent, one suppressed — and the response names
exactly which one. STOP rows are permanent: there is no unsuppress path in
Phase One.

## Flow 5 — Read the report + the masked suppression ledger

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","edgeLabelBackground":"#ffffff"}}}%%
%% truth: docs/sdk/README.md Flow 5 (executed: report fold sent/delivered/failed/expired/pending/suppressed + cost verdict; masked suppression list, count = rows)
flowchart LR
    A["the campaign fold —<br/>six counts + cost<br/>against the estimate,<br/>with a verdict"] --> B["the masked STOP list —<br/>first and last digits only,<br/>never a raw number"]
    classDef teal fill:#e9f8f3,stroke:#1baf7a,color:#1b1230
    class B teal
    linkStyle 0 stroke:#0FFC5A,stroke-width:3px
```

![Flow 5 — the fold and the masked opt-out ledger](../api/diagrams/flow-5-report.svg)

*Truth source: this section's executed calls (the six-count fold with the
cost verdict; the masked ledger whose count always equals its row count).*

**Report:** `GET /api/v1/campaigns/{id}/report` (scope `reports:read`) is
the same fold every surface serves: sent / delivered / failed / expired /
pending / suppressed plus the cost block (actual vs the plan's estimate,
with a verdict). Executed after the dispatch above:

```sh
$ curl $B/api/v1/campaigns/6c3cef0b-74c7-4b54-b337-9a1ea9f387c0/report \
    -H "Authorization: Bearer $WRITE_KEY"
{"campaign_id":"6c3cef0b-74c7-4b54-b337-9a1ea9f387c0","tenant_id":"72152492-a481-4375-b767-7c151a5ade90","campaign_name":"weekend-bread-drop","sent":3,"delivered":0,"failed":0,"expired":0,"pending":0,"suppressed":1,"cost":{"basis":"sent","messages":3,"rate_eur":"0.0300","actual_cost_eur":"0.0900","estimated_cost_eur":"3.6300","delta_cost_eur":"-3.5400","verdict":"under"}}
HTTP 200
```

A never-sent campaign reports honestly zeroed with null estimate fields
and `verdict:"no_estimate"` (executed shape in `../api/README.md`).

**Suppression:** `GET /api/v1/suppression` (scope `suppression:read`)
lists the tenant's own STOP rows sorted `(received_at, msisdn)`. The
masking law is in the wire shape — first digits + last 3 kept, every
middle digit replaced one-for-one; raw numbers never touch this read
surface. `count` always equals `len(stops)`. Executed:

```sh
$ curl $B/api/v1/suppression -H "Authorization: Bearer $WRITE_KEY"
{"stops":[{"msisdn_masked":"+316•••••203","source_channel":"sms-sandbox","received_at":"2026-08-27T08:14:00Z"}],"count":1}
HTTP 200
```

If your UI needs to tell a user "is this number stopped?", do not scrape
around the mask — the dispatch response is the sanctioned seam that names
the skipped target, because you submitted it yourself.

## Message lifecycles — the moving parts in time order

The five flows above are request/response stories. Some of what your calls
set in motion happens between requests (the gateway's delivery receipts)
or off your path entirely (a handset's inbound text, an operator's key
act). The five lifecycles in time order, from the client seat — each one
pictured twice in this product's docs: here in client words, and from the
machine's own side in [`../api/README.md`](../api/README.md).

The gated send, behind your one call — the counts in the 200 answer are
these moves, folded:

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/dispatch/service.go (approval gate consulted first; per-target STOP check inside the send path; row stored before the send attempt; dispatch.requested/sent/suppressed) · internal/channelapi/handlers.go (the dispatch route + the suppressed_msisdn read-only prediction in verbatim submission order) · docs/sdk/README.md Flow 4 (executed: 409 before approval; 4 targets → sent 3, suppressed 1 named) · internal/events/events.go
%% covers: send
sequenceDiagram
    autonumber
    participant Y as Your client<br/>(write key)
    participant A as Channel API<br/>front door
    participant D as Dispatch machine
    participant S as STOP ledger
    participant G as Sandbox SMS gateway

    Y->>A: POST dispatch-requests (campaign + targets, Idempotency-Key)
    A->>D: release for send — only if the campaign is APPROVED
    Note over Y,D: not approved yet? the answer was 409 there and then —<br/>drive the human step first (Flow 3)
    D->>S: check EVERY target, inside the send path
    S-->>D: one of yours is stopped
    D->>D: skip that one — audited "dispatch.suppressed", never a send
    D->>G: send the rest — each message row stored first,<br/>"dispatch.requested", then the gateway call
    G-->>D: accepted — a receipt id per message ("dispatch.sent")
    D-->>A: the counts
    A-->>Y: 200 — sent count, and it NAMES exactly who was skipped
```

![The gated send behind one dispatch call](../api/diagrams/seq-client-send.svg)

*Truth source: `internal/dispatch/service.go` (gate consulted, never
re-implemented; the per-target STOP check inside the send path; the row
stored before the send attempt), `internal/channelapi/handlers.go` (the
`suppressed_msisdn` read-only prediction in verbatim submission order),
`internal/events/events.go` (`dispatch.requested` / `dispatch.sent` /
`dispatch.suppressed`). The matrix matches Flow 4's executed calls.*

Delivery receipts, second — you never receive one in v1; the gateway signs
each receipt to the product, and you read the folded result in the report:

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/dlr/webhook.go (shared-secret signature verified BEFORE the body is parsed — a forged or tampered receipt is ErrBadSignature and never changes state; ErrMalformedReceipt applies nothing) · internal/dispatch/service.go HandleReceipt (verified receipts join their message row by receipt id; delivered/failed/expired; the first terminal answer stands) · internal/events/events.go (dlr.received) · docs/sdk/README.md Flow 5 (the report read is how clients observe it)
%% covers: dlr-callback
sequenceDiagram
    autonumber
    participant G as Sandbox SMS gateway
    participant W as Delivery-receipt listener<br/>(the DLR webhook)
    participant D as Dispatch machine
    participant L as Hash-chained audit log
    participant Y as Your client<br/>(write key)
    participant A as Channel API<br/>front door

    Note over Y,A: there is no pushed callback to your side in v1 —<br/>you read outcomes · you are never called
    G->>W: a signed delivery receipt per message
    W->>W: verify the signature BEFORE parsing —<br/>a forged or tampered body is dropped, never applied
    W->>D: only a verified receipt crosses, in the one receipt shape
    D->>D: join it to its message row by receipt id —<br/>delivered · failed · expired · the first terminal answer stands
    D->>L: "dlr.received"

    Y->>A: GET the campaign report — later, at your pace
    A-->>Y: 200 — delivered / failed / expired folded in with sent + suppressed
```

![Signed receipts land in the product; your client reads the fold](../api/diagrams/seq-client-dlr.svg)

*Truth source: `internal/dlr/webhook.go` (cryptographic pre-parse
verification — `ErrBadSignature` and `ErrMalformedReceipt` never reach a
state change), `internal/dispatch/service.go` `HandleReceipt` (verified
receipts join by receipt id; the first terminal answer stands),
`internal/events/events.go` (`dlr.received`). The client-facing law: v1 is
pull — poll the report, back off on 429 as shown above; do not build a
webhook listener you will never be called on.*

What a customer's inbound text does — you never see the text itself, only
its effects on the surfaces you already read:

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/growth/keyword.go (reserved STOP/HELP-family words are never keywords; lead-only grammar; conversation guard) · internal/growth/service.go CaptureInbound (STOP-beats-growth: stopped sender = evidence-only keyword.captured{suppressed}; otherwise keyword-optin consent row + idempotent join + growth.join + the welcome the lane sends; anything unmatched lands nothing) · internal/suppression/service.go (the STOP branch) · docs/api/README.md §Inbound (the machine-side picture of this same seam) · internal/events/events.go
%% covers: inbound
sequenceDiagram
    autonumber
    participant P as A customer's phone
    participant N as The product's shared inbox seam
    participant G as Keyword adjudicator
    participant S as STOP ledger
    participant C as Consent + audience core

    P->>N: a text arrives ("CAKES", "stop", or anything else)
    N->>G: the normalised text + sender
    alt it is a STOP/HELP-family word
        G->>S: those words belong to the suppression/help machinery — never a business
        Note over P,S: your NEXT dispatch skips this number by construction —<br/>your masked ledger read shows one more row
    else a registered keyword LEADS the message
        G->>S: stopped already? then evidence only — nothing joins, nothing is texted back
        G->>C: not stopped → consent stamped "keyword-optin" + the subscriber join<br/>(a repeat text re-answers, never duplicates)
        Note over P,C: the customer gets one warm welcome naming the business<br/>and the leave-anytime word STOP
    else anything else
        G-->>N: nothing lands — no writes, no events, no reply
    end
    Note over N: none of this is pushed to your client in v1 —<br/>you observe it through the report and the masked ledger
```

![An inbound customer text, adjudicated — effects only, never a push](../api/diagrams/seq-client-inbound.svg)

*Truth source: `internal/growth/keyword.go` (the reserved set; lead-only
grammar; the conversation guard), `internal/growth/service.go`
`CaptureInbound` (STOP-beats-growth; the `keyword-optin` consent stamp,
the idempotent join, the `growth.join` receipt, the welcome line),
`internal/suppression/service.go` (the STOP branch),
`internal/events/events.go` (`keyword.captured`, `growth.join`). The
machine-side picture of this same seam is in
[`../api/README.md`](../api/README.md) §Inbound.*

The STOP law, as your client meets it — three surfaces, one permanent
ledger, no client-side undo:

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/suppression/service.go (durable write first, idempotent replay, tenant-scoped, NO unsuppress path in Phase One) · internal/dispatch/service.go (every later send skips by construction; the pre-stop row's number is scrubbed — history stays honest) · internal/channelapi/handlers.go (the masked GET /api/v1/suppression read surface; dispatch's suppressed_msisdn names your own skipped target) · docs/sdk/README.md Flows 4–5 (executed: sent 3 suppressed 1 named; one masked row, count = rows) · internal/events/events.go (suppression.stopped, dispatch.suppressed)
%% covers: stop
sequenceDiagram
    autonumber
    participant P as A customer's phone
    participant M as Suppression machine
    participant B as STOP ledger
    participant Y as Your client<br/>(write key)
    participant A as Channel API<br/>front door
    participant D as Dispatch machine

    rect rgba(15,252,90,.16)
        P->>M: "stop" — any casing, any spacing
        M->>B: written durable FIRST, then acknowledged —<br/>a repeated STOP is an honest no-op
        Note over M,B: permanent — there is no unsuppress call to make<br/>(and none for you to expose)
    end

    Y->>A: your next dispatch lists that number again
    A->>D: release for send
    D->>B: checked inside the send path — it is stopped
    A-->>Y: 200 — suppressed: 1, and it NAMES the number you submitted
    Note over Y,D: do not retry it, do not route around it —<br/>every later send skips it by construction

    Y->>A: GET /api/v1/suppression
    A-->>Y: 200 — masked rows only ("+316•••••203") · count always equals rows
```

![The STOP law from the client seat — named in your dispatch, masked in your read](../api/diagrams/seq-client-stop.svg)

*Truth source: `internal/suppression/service.go` (durable first;
idempotent replay; tenant-scoped; no unstop),
`internal/dispatch/service.go` (every later send skips by construction;
pre-STOP rows get their numbers scrubbed),
`internal/channelapi/handlers.go` (the masked ledger read;
`suppressed_msisdn` names your own submitted target). Executed versions of
the two client calls are Flows 4 and 5 above.*

Key rotation, from the custody side — the safe order is mint → deploy →
revoke, and a 401 is a custody verdict, never a retry instruction:

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/channelapi/handlers.go (POST /api/v1/keys mints the SUCCESSor for an admin:all holder — plaintext once, digest-only at rest) · internal/channelapi/auth.go (every auth failure class — including a revoked key — is the same uniform 401 bytes, consuming no rate budget) · internal/channelapi/pgstore/store.go (revoked_at stamping; the digest row retained as audit) · cmd/ngaige/apikeyseed.go (a tenant's first key is an operator act) · migrations/000013_channelapi.up.sql
%% covers: key-rotation
sequenceDiagram
    autonumber
    participant H as Your admin custody<br/>(admin key, human-held)
    participant A as Channel API<br/>front door
    participant V as Your secret store
    participant Y as Your running client
    participant O as Operator staff<br/>(on the host)

    H->>A: POST /api/v1/keys — mint the SUCCESSOR, scopes from the catalogue
    A-->>H: 201 — the successor's ONLY plaintext showing
    H->>V: persist it now, in the same code path — never to a log line
    H->>Y: redeploy every caller onto the successor
    Note over Y,A: both keys work meanwhile — cut over at your pace,<br/>then verify the successor answers before retiring the old one
    Y->>A: traffic on the successor
    A-->>Y: answers as before
    H->>O: ask the operator to revoke the old key (no public revoke route in v1)
    O->>A: stamp revoked — the digest row stays as audit history
    Y->>A: a late call somewhere still on the old key
    A-->>Y: 401 — the same bytes as a never-known key:<br/>fix that caller's custody · there is no retry that helps
```

![Rotation from the client seat — mint, deploy, revoke; a 401 means fix custody](../api/diagrams/seq-client-key-rotation.svg)

*Truth source: `internal/channelapi/handlers.go` (`POST /api/v1/keys` —
one-shot plaintext, digest-only at rest), `internal/channelapi/auth.go`
(every auth failure class is the uniform 401, consuming no rate budget),
`internal/channelapi/pgstore/store.go` (`revoked_at`; the retained digest
row), `cmd/ngaige/apikeyseed.go` (a tenant's first key is an operator
act), `migrations/000013_channelapi.up.sql` (`api_keys`). The operator
view of this same lifecycle is in [`../api/README.md`](../api/README.md)
§The life of a key.*

## Error handling: the refusal grammar

Every refusal is `application/problem+json` (`type/title/status` +
optional `detail`). Status code is the machine contract; `type` URN is the
stable programmatic discriminator; `detail` is for humans and may be
refined without a version bump — do not parse it.

| Status | `type` (URN suffix `urn:ngaige:problem:v1:` where given) | When it happens | Client action |
| --- | --- | --- | --- |
| 400 | `idempotency-key-required` | mutating POST without the header | bug in your client: add the header (executed above) |
| 401 | `about:blank` | absent/malformed/unknown/wrong/revoked credential | stop; fix custody. No retry logic keys off this (executed above) |
| 403 | `scope-required` | key lacks the route scope; `detail` names the missing scope | route the call to the right custody path (executed: flow 3) |
| 404 | `about:blank` | unknown path, unknown id, or **foreign tenant's id** (uniform by law) | treat as "no such object" — never as "wrong tenant" (executed, Appendix A) |
| 405 | `about:blank` | wrong method on a registered path (+ `Allow` header) | fix the verb |
| 409 | `idempotency-conflict` | Idempotency-Key reused with a different body | fetch the object fresh; the original already landed (executed, Appendix A) |
| 409 | `not-approved` | dispatch before APPROVED | drive the approval step first (executed: flow 4) |
| 422 | `consent-required` | import without exactly one proof carrier | add the carrier; nothing was stored (executed: flow 2) |
| 422 | `about:blank` (validation) | malformed fields (bad sender id, past schedule, incomplete carrier fields…) | repair the payload; `detail` says which field |
| 429 | `rate-limited` | per-key budget exhausted (10 rps / burst 20); header `Retry-After` in whole seconds | back off exactly as told (executed below) |
| 5xx | `about:blank` | internal fault | retry with your Idempotency-Key (the ledger makes replays safe) |

Executed 429 (response headers + body — from pacing the write key with a
parallel burst):

```http
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json; charset=utf-8
Retry-After: 1
X-Request-Id: 704b1d9db442d02517d6feece758b673

{"type":"urn:ngaige:problem:v1:rate-limited","title":"rate limited","status":429,"detail":"per-key rate budget exhausted; retry after 1s"}
```

### Idempotency guidance (client-law)

- One `Idempotency-Key` per **logical operation**, not per HTTP attempt.
  Creating a client? Generate the key when the user hits "create"; reuse
  that same value across every retry of that creation.
- Never reuse a key across different logical operations — the ledger scope
  is (tenant, key value) and durable: reuse with a different body is the
  409 conflict above.
- GETs are naturally idempotent and take no header.
- Do not build your own dedupe on top: replay answers are byte-identical
  with `X-Idempotent-Replay: true`, so your "did it land?" uncertainty is
  answered by retrying the same call, not by listing/reading to check.

### 429 backoff — executed pattern

The budget is per key; unknown keys never consume it. Honour
`Retry-After`, retry the identical request (with its Idempotency-Key for
mutations), and keep jitter out of it — the header already carries the
whole-second wait. This loop was executed for real against this guide's
boot (the pacing burst + retry transcript, verbatim):

```text
== pacing burst to exhaust the per-key budget (10 rps, burst 20) ==
     20 200
     10 429
== retry-with-backoff loop ==
attempt 1 -> 429; Retry-After: 1s — sleeping 1s before attempt 2
attempt 2 -> 200 (served)
{"stops":[{"msisdn_masked":"+316•••••203","source_channel":"sms-sandbox","received_at":"2026-08-27T08:14:00Z"}],"count":1}
```

Language-agnostic shape (pattern, not a transcript):

```text
resp = request(method, url, headers, body)
while resp.status == 429 and attempts < MAX:
    sleep(resp.header("Retry-After", default=1) seconds)
    resp = request(method, url, headers, body)   # identical request, same Idempotency-Key
```

For 5xx and transport faults: retry the identical request with bounded
exponential backoff + jitter; the idempotency ledger makes mutation
replays safe by construction.

## Two-realm note for MNO embedders: what "your data stays operator-side" means

NGAIGE in an MNO embedding runs as two realms on purpose: the **operator
realm** (the MNO's network, where the NGAIGE core, the Postgres system of
record, the consent evidence store, the STOP ledger, the dispatch machine
and the billing ledger live) and the **sovereign AI realm** (the estate's
self-hosted model lanes the assistant and plan engine think through).
There is no third realm and no third-party AI.

Concretely, for an application embedding this API:

1. **What crosses to the AI realm is business-authored brief text only** —
   goal, audience description, message text, schedule. The plan engine's
   prompt boundary pins this by construction: no contact row, no phone
   number, no tenant-identifying fact can reach a model prompt from the
   plan path, and the refusal taxonomy never asks a model for a verdict.
2. **What never crosses:** recipient lists, phone numbers, consent
   evidence, STOP rows, dispatch targets, delivery receipts, money
   records. Per-number decisions (consent stamps, the STOP skip,
   suppression masking) happen server-side in the operator realm, in code,
   not in a model call.
3. **Your app's obligations follow:** the tenant key is operator-realm
   secret material (digest-only at rest server-side; you hold the
   plaintext) — keep it in your own realm's secret store, not in client
   devices or logs. Every raw number you submit through dispatch targets,
   and every response you cache, is operator-realm personal data in your
   custody: do not forward payloads through third-party relays or
   analytics that persist bodies, prefer the API's masked surfaces (the
   suppression list) over building your own number stores, and treat
   `X-Request-Id` as the only identifier you need when escalating an
   issue.
4. **Route topology is your compliance surface:** call the API endpoint
   the operator gives you, directly. If you need a BFF or proxy, it lives
   operator-side too — the two-realm promise your customers were sold
   (numbers never leave the operator) is routinely violated by embedding
   code, not by the core.

## Appendix A — execution log

Executed 2026-08-27 ~01:42–01:44 UTC against a local boot, binary built
from repo @ `625e07c` with go1.27.0:

```sh
$ go build -o .scratch/ngaige ./cmd/ngaige          # rc=0
$ ngaige migrate                                    # rc=0 ("migrate: schema current")
$ env DATABASE_URL=<dev DSN, search_path=sdkdocs schema> \
      NGAIGE_HTTP_ADDR=127.0.0.1:18099 \
      NGAIGE_CHANNELAPI_ENABLED=true \
      NGAIGE_CHANNELAPI_KEY_PEPPER=<temp 64-hex> \
      NGAIGE_LLM_BASE_URL=http://127.0.0.1:4000/v1 \
      NGAIGE_LLM_API_KEY=<estate router key> \
      NGAIGE_LLM_TRY_TIMEOUT=180s \
      NGAIGE_SMS_COST_PER_MESSAGE_EUR=0.0300 \
      ngaige api &                                  # banner: "CHANNEL API v1 mounted
                                                    #  at /api/v1/ (per-tenant keys in DB,
                                                    #  1 at rest; ...)" — healthz 200
$ ngaige apikey-seed --tenant 72152492-a481-4375-b767-7c151a5ade90 \
      --scopes admin:all                            # rc=0, plaintext once
```

Environment note: the shared dev cluster's `public` schema was being
concurrently reset by other work during this run, so the boot, migrate and
seeds ran against a dedicated `sdkdocs` schema (DSN `options=search_path`)
in the same cluster — a documentation-run isolation, not a product
behaviour. All commands and responses above are unaffected by it (the
verification story's object graph never touched `public`).

HTTP executions (in order; the doc quotes their response bytes):

| # | Call | Status | Note |
| --- | --- | --- | --- |
| 1 | `POST /api/v1/keys` (write scope set, idem `sdk-key-write-001`) | 201 | write key minted |
| 2 | `POST /api/v1/keys` (`campaigns:approve` only, idem `sdk-key-approve-001`) | 201 | approve key minted |
| 3 | `POST /api/v1/clients` (sdk-client-001) | 201 | client `16dcb736…` |
| 4 | `POST /api/v1/imports` + `consent_affirmation:true` (sdk-import-001) | 200 | total 2 / accepted 2 |
| 5 | `POST /api/v1/imports` + `consent_evidence_ref` (nonexistent ref) | **500** | mismatch found — see below |
| 6 | `POST /api/v1/imports` + saved evidence row id (sdk-import-002) | 200 | total 1 / accepted 1 |
| 7 | `POST /api/v1/imports` with no carrier (sdk-import-003) | 422 | `consent-required`, nothing stored |
| 8 | `POST /api/v1/campaigns/drafts` (weekend-bread-drop) | 201 | campaign `6c3cef0b…` |
| 9 | `POST …/plan` | 200 | router's first-choice lane of the day (k3), 7.547s wall |
| 10 | `POST …/submit` | 200 | `PENDING_APPROVAL` |
| 11 | `POST …/approve` with the **write** key | 403 | `scope-required` naming `campaigns:approve` |
| 12 | `POST …/approve` with the approve key, `actor:"owner:marta"` | 200 | `APPROVED` |
| 13 | `POST /api/v1/dispatch-requests` (4 targets, 1 stopped) | 200 | sent 3, suppressed 1, named |
| 14 | `GET …/report` | 200 | sent 3, suppressed 1, verdict `under` |
| 15 | `GET /api/v1/suppression` | 200 | 1 masked row, `count=1` |
| 16 | `GET /api/v1/suppression`, no Authorization | 401 | uniform no-oracle bytes |
| 17 | `POST /api/v1/clients`, no Idempotency-Key | 400 | `idempotency-key-required` |
| 18 | `POST /api/v1/clients`, idem `sdk-client-001` + different body | 409 | `idempotency-conflict` |
| 19 | `POST /api/v1/campaigns/<unknown-uuid>/submit` | 404 | uniform bytes |
| 20 | `POST /api/v1/campaigns/drafts` (unsubmitted-demo, sdk-draft-002/003) | 201 → 200 | second draft `f415b640…`; re-post by name re-answers (idempotency-by-handle) |
| 21 | `POST /api/v1/dispatch-requests` on the DRAFT campaign | 409 | `not-approved` |
| 22 | 30 parallel `GET /api/v1/suppression` (write key) | mixed | 20×200 / 10×429; 429 headers + body captured verbatim above |
| 23 | backoff script: retry honours `Retry-After: 1` | 429 → 200 | executed transcript quoted above |

Plus 3 CLI/SQL acts: `apikey-seed` (rc=0), one `INSERT INTO
consent_evidence` standing in for the intake journey's earlier capture of
the signed sheet photo (client side of the evidence variant — the row the
import's ref must point at), one `INSERT INTO suppression_stops` seeding
the morning's inbound STOP (`+31655500203`) that the dispatch example
skips. Both seeds mirror what the real inbound/intake seams write.

### Doc-vs-impl mismatches found during this run (reported, not patched)

1. **A nonexistent (or non-UUID) `consent_evidence_ref` escapes the
   refusal taxonomy as a bare 500** —
   `{"type":"about:blank","title":"internal error","status":500}` — instead
   of a mapped 4xx (the FK to `consent_evidence` rejects the stamp). The
   contract wording ("a previously saved consent evidence row id") is
   honoured — a valid ref to a saved row imports fine (execution #6) — but
   an integrator pointing at a missing ref gets a retry-ambiguous 5xx for
   what is a client error. Owner: channelapi import handler → onboarding
   error mapping. (This is independent of, and additional to, the two
   mismatches the API guide already reported: cost-rate-env bare 500 and
   the 5s default lane try-timeout vs real first-choice-lane (k3) latency.
   Both workarounds —
   setting `NGAIGE_SMS_COST_PER_MESSAGE_EUR` and `NGAIGE_LLM_TRY_TIMEOUT` —
   were applied for this boot and remain required.)
2. **Migration 000016 is outside the migrate registry.** A fresh-schema
   `ngaige migrate` reports "schema current" with 000016
   (`assistant_connect`) unapplied (14 rows in `schema_migrations`,
   000001–000015 less the skipped 000007 number). Harmless to Channel API
   (the columns serve assistant connect journeys) but a fresh deploy
   relying on "schema current" silently misses a landed migration. Owner:
   migrations registry.

## Appendix B — scratch hygiene

The run used throwaway material: one scratch tenant UUID
(`72152492-a481-4375-b767-7c151a5ade90`), one `sdkdocs` schema in the dev
cluster (dropped after the run), three minted keys, one temporary pepper,
one seeded evidence row and one seeded STOP row. The API process was
killed and the scratch schema dropped (`DROP SCHEMA sdkdocs CASCADE`), so
no key or object id from this guide resolves anywhere. Redacted secrets in
this document are therefore dead values even where partially visible.
