> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# The issuer-challenge model

> How Orbit proves a counterparty is real before trusting a registration: the four challenge classes, the surfaces that issue them, the pending-to-resolved lifecycle with per-class expiry, the payload each solver returns, retry and terminal-failure semantics, and a worked walkthrough for the dashboard and the API.

# The issuer-challenge model

Before Orbit trusts certain registrations, it asks the counterparty to
prove something only the real counterparty can prove: that a human is at
the keyboard, that the party controls the phone number or email address
being registered, or that the business is the entity it claims to be.
Each such proof request is an **issuer challenge**. A sender ID does not
go live on a form submission alone; a marketplace publisher listing does
not appear on a self-declaration alone. The registrar issues a challenge,
the counterparty solves it, and only then does the registration bind.

This page is the canonical explainer for the model every issuing surface
shares. It names the challenge classes, the surfaces that fire them, the
state machine each challenge moves through, the payload a solver must
return, and what happens when a challenge expires or fails. The
per-surface procedures (which documents a DLT submission needs, how the
10DLC wizard walks a brand) live in their own guides; this page tells you
how to reason about any challenge any surface issues.

If you are looking for the proof a *session* is live before a destructive
operation, that is a different mechanism: see
[Destructive-operation re-auth challenge](/concepts/re-auth-challenge-model).
Issuer challenges prove a counterparty at registration time; re-auth
challenges prove a person at the keyboard at operation time.

## The challenge definition

The registrar issues four challenge classes. Each asks for one specific
proof and nothing else:

| Class | What the registrar asks for | What a solve proves |
| - | - | - |
| `captcha` | Solve an interactive puzzle on the issuing page | A human is at the keyboard, not an automated registration loop |
| `phone-verify` | Return a one-time code delivered to the phone number under registration | The registrant controls that phone number right now |
| `email-verify` | Return a one-time code (or follow a signed link) sent to the address under registration | The registrant controls that mailbox |
| `kyc-doc-upload` | Submit the compliance documents the class demands (business registration, authorization letter, or equivalent) | The business is the legal entity it claims, reviewed by compliance |

A challenge always names its class, its issuer (the surface that fired
it), and its subject (the sender ID, the WhatsApp Business account, the
publisher submission, the key being created). A solve is only accepted
for the exact challenge it answers; a code delivered for one subject
cannot verify a different one.

## When a challenge fires

The following surfaces issue challenges as part of their normal flow.
The class column is what the surface typically issues; a surface may
stack two classes when its risk warrants it (for example, a sender-ID
registration from a fresh account).

| Trigger surface | Typical class | What the solve unblocks |
| - | - | - |
| Account signup | `captcha` | Account creation against automated abuse |
| Sender-ID registration | `captcha`, `phone-verify` | The sender ID moves from pending to approved for traffic |
| WhatsApp Business verification | `phone-verify` | The WhatsApp Business account binds to your organization |
| US 10DLC brand and campaign registration | `phone-verify` | The brand vetting request proceeds to the carrier registry |
| India DLT submission | `kyc-doc-upload` | The header and template submission proceeds to the operator |
| Bring-your-own-key (BYOK) key creation | `captcha` | The customer-managed key is accepted for encryption |
| Marketplace publisher submission | `kyc-doc-upload` | The listing enters review and can go public |

The rule across all surfaces is the same: the challenge fires at the
moment state would become trusted, and the trusted state is withheld
until the challenge resolves. Nothing downstream (traffic on the sender
ID, a public listing, an active key) proceeds on an open challenge.

## The state enum and transitions

Every challenge moves through one state machine, regardless of class:

```mermaid theme={null}
stateDiagram-v2
    [*] --> pending : challenge issued
    pending --> challenged : solve payload submitted
    challenged --> resolved : proof verified
    challenged --> pending : attempt rejected, budget remains
    pending --> expired : TTL elapsed
    challenged --> expired : TTL elapsed during review
    resolved --> [*]
    expired --> [*]
```

* **`pending`** — issued and waiting for a solve. The TTL clock starts
  here.
* **`challenged`** — a solve payload has been submitted and is being
  checked (code comparison, captcha verification, or compliance review
  for `kyc-doc-upload`).
* **`resolved`** — terminal success. The issuing surface binds the proof
  to the registration and proceeds.
* **`expired`** — terminal failure. The TTL elapsed before a verified
  solve; nothing further can be submitted against this challenge.

TTLs are set per class to match how long a genuine counterparty needs:

| Class | Timebox | Why |
| - | - | - |
| `captcha` | Minutes | A human solves at the keyboard or not at all |
| `phone-verify` | Minutes per code | A one-time code is stale quickly; codes are re-issued, not reused |
| `email-verify` | Longer than phone, still under an hour | Mail delivery latency is real but mailboxes are not long-term holding areas |
| `kyc-doc-upload` | Days | A human reviewer reads the documents; the window covers review, not just submission |

A resolved challenge is then **bound to the request** that asked for the
registration: the surface records which challenge id cleared it, and the
registration carries that binding forward. A challenge resolved last
month does not satisfy a registration attempted today.

## Payload contract per class

Every challenge, whichever class, carries the same envelope when issued:

```json theme={null}
{
  "id": "chal_9f2e7c1b",
  "type": "phone-verify",
  "state": "pending",
  "issuer": "sender-id-registration",
  "subject": { "sender_id": "ACME" },
  "request_id": "req_5d8a2e40",
  "issued_at": "2026-10-13T09:14:02Z",
  "expires_at": "2026-10-13T09:24:02Z",
  "attempts_remaining": 3
}
```

A solver answers with the class-specific payload plus two binding
fields:

* **`request_id`** — copied verbatim from the challenge. The consume step
  rejects any solve whose `request_id` does not match the challenge it
  targets; this is what stops a code issued for one subject from being
  replayed against another.
* **`challenge_id`** — the `id` of the challenge being solved.

Per-class solver fields:

| Class | Solver payload | Signature expectation |
| - | - | - |
| `captcha` | The widget token from the issuing page | Token is single-use and verified against the captcha provider; the solve call itself authenticates with the session or API key of the registering tenant |
| `phone-verify` | `code` — the one-time code as delivered | Compared in constant time; the code never means anything outside its `request_id` |
| `email-verify` | `code`, or the signed link's verification token | Link tokens are signed and bind the address; a forwarded link only proves control of the mailbox it was sent to |
| `kyc-doc-upload` | Document references for each required document | Uploads are stored per tenant and reviewed by compliance; the solve transitions the challenge to `challenged` until review completes |

The authentication on the solve call is the tenant's own credential
(session or API key). The challenge payload is not itself a credential;
it is a claim that a proof was produced, checked against the stored
challenge.

## Expiry and retry semantics

Each challenge carries a retry budget, reported as
`attempts_remaining` and decremented on every rejected attempt:

| Class | Retry behaviour on a bad attempt |
| - | - |
| `captcha` | A fresh puzzle is issued; the budget is generous but finite |
| `phone-verify` | Wrong codes decrement the budget; a new code can be requested, which voids the previous one |
| `email-verify` | Same as phone; re-sends are rate-limited |
| `kyc-doc-upload` | A rejected submission returns the reviewer's reason; resubmission is allowed while the window is open |

Terminal failure modes, in order of how you will meet them:

* **`expired`** — the TTL elapsed. Mint a fresh challenge; nothing
  carries over except whatever documents you already uploaded.
* **Budget exhausted** — too many rejected attempts. The challenge
  closes and the surface imposes a cooldown before a new one can be
  issued; this is what makes brute-forcing a six-digit code uneconomic.
* **Review denied** (`kyc-doc-upload`) — the reviewer rejected the
  documents. The response carries the reason; correct the submission
  and start a new challenge.

A terminal challenge never revives. Every retry path is "issue a new
challenge and solve that one."

## Tenant-side handling

If you build on the dashboard, most of this is handled for you: the
wizard for each surface renders the right challenge UI (puzzle, code
entry, or upload form), shows the remaining time and attempts, and
re-issues on expiry. You interact with it as a user, not as an
integrator.

If you integrate over the API, two patterns work, and the right one
depends on the class:

* **Poll** for short classes. After submitting a solve for `captcha`,
  `phone-verify`, or `email-verify`, re-read the registration status;
  verification completes in seconds, so a short poll interval against
  the registration endpoint is sufficient.
* **Subscribe** for `kyc-doc-upload`. Review takes days, so polling is
  the wrong shape. The resolution arrives as a webhook event on your
  tenant's event stream: a `challenge.resolved` (or terminal
  `challenge.expired` / denial) event names the challenge id and the
  registration it unblocked. Register a webhook endpoint for the
  surface's events and drive your state machine off it.

In both patterns, treat the challenge envelope as the source of truth
for `expires_at` and `attempts_remaining` rather than assuming your own
clock or counter agrees with the registrar's.

## Failure-mode troubleshooting

| Symptom | Cause | What to do |
| - | - | - |
| Solve rejected with an invalid-signature error | The captcha token or signed email link failed verification, or was already consumed | Re-solve at the issuing surface; tokens are single-use and short-lived |
| Solve rejected as expired | The TTL elapsed between issue and solve, or during review | Read `expires_at` on the challenge and mint a fresh one; do not retry the old id |
| Solve rejected with a request mismatch | The `request_id` in the solve does not match the challenge being answered, typically a code reused across subjects | Copy `request_id` verbatim from the challenge you are solving; one subject, one challenge |
| `401`-class rejection on the solve call | The solve was authenticated as a different tenant than the one the challenge was issued to | Challenges are tenant-scoped; solve with the credential of the tenant that registered |
| Budget-exhausted rejection | Too many wrong attempts | Wait out the cooldown, then issue a new challenge; check the delivery destination before retrying codes |

## Worked walkthroughs

### Dashboard: WhatsApp Business verification (`phone-verify`)

1. In **Channels → WhatsApp**, start the Business verification for the
   phone number you are binding.
2. The wizard issues a `phone-verify` challenge and sends a one-time
   code to that number over WhatsApp itself; the page shows the
   countdown and the attempts remaining.
3. Enter the code. The wizard submits the solve with the correct
   `request_id` and shows the challenge move to `resolved`.
4. The wizard proceeds to bind the Business account. If the code
   expired, the wizard re-issues the challenge in place; you never need
   to restart the whole verification.

### API: sender-ID registration (`captcha` + solve)

Register a sender ID from a fresh account; the registration comes back
with an open challenge instead of an approval:

```http theme={null}
POST /api/v1/messaging/sender-ids
Authorization: Bearer <tenant API key>

{ "sender_id": "ACME" }
```

```json theme={null}
{
  "status": "challenge_required",
  "challenges": [
    {
      "id": "chal_9f2e7c1b",
      "type": "captcha",
      "state": "pending",
      "request_id": "req_5d8a2e40",
      "expires_at": "2026-10-13T09:24:02Z",
      "attempts_remaining": 3
    }
  ]
}
```

Solve the puzzle at the issuing surface, then submit the solve against
the same `request_id`:

```http theme={null}
POST /api/v1/messaging/sender-ids/ACME/challenges/chal_9f2e7c1b/solve
Authorization: Bearer <tenant API key>

{ "request_id": "req_5d8a2e40", "token": "<captcha token>" }
```

On a verified solve the challenge transitions to `resolved`, the
registration proceeds to approval, and a re-read of the sender ID shows
it without any open challenges. On rejection, `attempts_remaining`
decrements and the same sequence runs again with a fresh token; on
expiry, the whole challenge is re-issued.

## See also

* [Destructive-operation re-auth challenge](/concepts/re-auth-challenge-model) —
  the sibling model: proving a live session before irreversible
  operations, with its own mint-carry-consume flow.
* [US 10DLC registration](/guides/10dlc-registration) — the carrier
  registry flow whose verification step issues the challenges above.
* [Agent marketplace](/agents/marketplace) — where publisher submissions
  and their `kyc-doc-upload` challenges resolve into public listings.
* [Account security model](/concepts/account-security-model) — the
  session and 2FA posture that issuer challenges sit beside, not
  inside.
* [Compliance is tenant-owned](/concepts/compliance-tenant-owned-model) —
  which controls in these flows are yours to configure and which the
  registrar owns.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.