# Referrals

Referral programs, partners, conversions, commissions and payouts.

Run a referral or affiliate program and tell Bolt Claw when a referred customer
signs up, pays, or asks for their money back. Bolt Claw works out which partner
referred them, what that partner earned, holds the commission through your
refund window, and takes it back if the sale is refunded.

Three jobs, and they belong on separate keys:

- **Reporting what happened** — `POST /v1/referrals/conversions` — needs
  `conversions:write`. The **Referral tracking** preset is exactly that one
  scope: the key your checkout server holds, which can read nothing. Report
  from your **server**, never from a browser: anything shipped to a visitor
  can be read by that visitor.
- **Running the program** — programs, partners, commissions, payouts and
  statistics — needs `referrals:read` (and `referrals:write` to change
  anything). The **Referrals** preset is those two.
- **Showing your own users their referrals** — a [referral panel](https://boltclaw.ai/docs/referral-panel)
  inside your product, or your own screen built on the same data — needs
  `referrals:embed` alone. The **Referral panel** preset is that one scope: the
  key your own server holds to mint a panel session for a signed-in user and to
  create the referrers behind it, which can touch no program, partner or payout.
  Never put it in a browser or an app.

| Method & path | Scope |
|---|---|
| [`POST /v1/referrals/conversions`](https://boltclaw.ai/docs/api/referrals#post-v1referralsconversions) | `conversions:write` |
| [`GET /v1/referrals/programs`](https://boltclaw.ai/docs/api/referrals#get-v1referralsprograms), `GET /v1/referrals/programs/{id}` | `referrals:read` |
| [`POST /v1/referrals/programs`](https://boltclaw.ai/docs/api/referrals#post-v1referralsprograms), `PATCH /v1/referrals/programs/{id}` | `referrals:write` |
| [`GET /v1/referrals/programs/{id}/partners`](https://boltclaw.ai/docs/api/referrals#get-v1referralsprogramsidpartners) | `referrals:read` |
| `POST /v1/referrals/partners/{id}/approve`, `…/reject` | `referrals:write` |
| [`GET /v1/referrals/programs/{id}/commissions`](https://boltclaw.ai/docs/api/referrals#get-v1referralsprogramsidcommissions) | `referrals:read` |
| `POST /v1/referrals/commissions/{id}/adjust` | `referrals:write` |
| [`GET /v1/referrals/programs/{id}/payouts`](https://boltclaw.ai/docs/api/referrals#get-v1referralsprogramsidpayouts) | `referrals:read` |
| `POST /v1/referrals/payouts/{id}/paid` | `referrals:write` |
| [`GET /v1/referrals/programs/{id}/stats`](https://boltclaw.ai/docs/api/referrals#get-v1referralsprogramsidstats) | `referrals:read` |
| [`GET /v1/referrals/programs/{id}/rewards`](https://boltclaw.ai/docs/api/referrals#get-v1referralsprogramsidrewards), `GET …/groups` | `referrals:read` |
| `POST /v1/referrals/programs/{id}/rewards`, `PATCH`/`DELETE /v1/referrals/rewards/{id}` | `referrals:write` |
| `POST /v1/referrals/programs/{id}/groups`, `PATCH`/`DELETE /v1/referrals/groups/{id}` | `referrals:write` |
| `PATCH /v1/referrals/partners/{id}` (a partner's group) | `referrals:write` |
| `POST /v1/referrals/partners/{id}/bonuses` | `referrals:write` |
| [`POST /v1/referrals/programs/{id}/embed-tokens`](https://boltclaw.ai/docs/api/referrals#post-v1referralsprogramsidembed-tokens) | `referrals:embed` (or `referrals:write`, never an assistant's key) |
| [`GET /v1/referrals/programs/{id}/referrers`](https://boltclaw.ai/docs/api/referrals#get-v1referralsprogramsidreferrers) | `referrals:read` or `referrals:embed` |
| `POST /v1/referrals/programs/{id}/referrers` (create or update by `external_id`) | `referrals:embed` or `referrals:write` |
| `GET /v1/referrals/programs/{id}/referrers/{external_id}` | `referrals:read` or `referrals:embed` |
| `PATCH`/`DELETE /v1/referrals/referrers/{id}` (approve or ban; erase) | `referrals:write` |
| `POST /v1/referrals/programs/{id}/embed/revoke-sessions`, `…/embed/rotate-key` | `referrals:write` |
| [`GET /v1/referrals/programs/{id}/grants`](https://boltclaw.ai/docs/api/referrals#get-v1referralsprogramsidgrants), `GET /v1/referrals/grants/{id}` | `referrals:read` |
| `POST /v1/referrals/grants/{id}/fulfil`, `…/fail` | `referrals:write` |
| [`GET /v1/referrals/offers/{code}`](https://boltclaw.ai/docs/api/referrals#get-v1referralsofferscode) | `referrals:read` or `referrals:embed` |
| `GET /v1/referrals/programs/{id}/customers/{external_id}/referral` | `referrals:read` or `referrals:embed` |
| `POST /v1/referrals/programs/{id}/stripe-codes` | `referrals:write` |
| `GET /v1/referrals/programs/{id}/integration-status` | `referrals:read` |
| `POST /v1/referrals/programs/{id}/integration-status/tag-check` | `referrals:write` |
| `POST /v1/referrals/programs/{id}/test-sale` | `referrals:write` |
| `DELETE /v1/referrals/programs/{id}/test-data` | `referrals:write` (an AI assistant is always asked first) |
| `POST /v1/referrals/clicks` | `conversions:write` |

Every amount is an **integer in the currency's minor units** — cents for USD,
yen for JPY — in a field ending `_minor` (the conversion request's `amount` is
the one exception to the suffix, and the same unit). Responses are the same
objects the dashboard shows, in snake_case; lists are
[cursor-paginated](https://boltclaw.ai/docs/pagination), newest first, 50 to a page by default and at
most 100.

**This API is the merchant's side of a program.** It never carries a partner's
account id or email (a partner's email appears only if they chose to share it
with your program), and a partner never sees your account either — they know
you as the program's name. An id that is not on your account is a `404`,
whatever it is.

## Referrers: your own users

A **referrer** is one of *your* users who holds a referral link, usually through the
[referral panel](https://boltclaw.ai/docs/referral-panel). They are not partners: they have no Bolt Claw
account, never apply, and are not counted against the partner allowance (referrers
have their own, `maxRefReferrers`, Free 100,000). You identify one by your own id for
them, `external_id`, which is 1 to 200 printable characters and must be
percent-encoded in a path. `GET .../referrers/{external_id}` returns exactly what the
panel shows that person, so your own screen can show the same thing.

## Rewards that are not cash, and the friend's side

A reward rule can give a free period, credit, a perk, a gift card or (to the
friend) a discount, as well as a commission; it can reward the person who shared
(`beneficiary: promoter`) or the friend who arrived through the link
(`referee`). What such a rule gives is a **grant**, which you give in your own
system and record with `POST /v1/referrals/grants/{id}/fulfil`, or which Bolt
Claw gives through your Stripe with a restricted key. See
[Rewards that are not cash](https://boltclaw.ai/docs/rewards-not-cash),
[The friend's welcome](https://boltclaw.ai/docs/referral-welcome) and
[Stripe codes and credits](https://boltclaw.ai/docs/referral-stripe-codes).

## Carrying the referral to your checkout

A partner's link, `https://boltclaw.ai/r/<code>`, sends the visitor to your
product URL with `?via=<code>` added. Put this tag on your site to keep that
code until checkout:

```html
<script src="https://boltclaw.ai/referrals/v1.js" defer
        data-cookie-days="30" data-attribution="last" data-param="via"></script>
```

| Attribute | |
|---|---|
| `data-cookie-days` | How long the code is kept, 1–365 days. Default `30`. |
| `data-attribution` | `last` (the default): the newest link a visitor follows wins. `first`: the first one is kept. Match your program's attribution model. |
| `data-param` | The query parameter to read. Default `via`. |

It stores the code in a first-party cookie on **your** domain, `bc_ref`
(mirrored to `localStorage`, for in-app browsers that drop cookies). **It makes
no network request of any kind** and sets nothing on boltclaw.ai. It gives you:

- `BoltClawReferral.code()` — the code, or `null`.
- `BoltClawReferral.attach(form)` — adds a hidden `boltclaw_ref` field to a form.
- `BoltClawReferral.clientReferenceId()` — the code for Stripe Checkout's
  `client_reference_id`, or `undefined`.

`v1.js` never changes behaviour under you. **`v2.js`** does everything it
does, the same way, and adds what you ask for: the friend's welcome banner,
Stripe Payment Links, codes from another tool's links and a consent mode. With
none of those switched on it, too, makes no request. See
[The friend's welcome](https://boltclaw.ai/docs/referral-welcome).

## `event_id` makes retries safe

Report every event with an id of your own that never changes — an order id, a
refund id. **The same `event_id` sent again returns `200` with
`"duplicate": true` and the conversion as it was first recorded**, and changes
nothing: no second commission, no second refund. So after a timeout, retry with
the same id; never mint a new one. An `event_id` is unique per program.

A `409 CONFLICT` here means another event for the same customer was being
recorded at that very moment, and nothing was recorded for yours. Send the same
request again, with the same `event_id`. A new id is a new conversion, and a
new conversion can pay a partner twice for one sale.

## Who gets the credit

In order: a `referral.code` of this program, then an active `discount_code`
of this program, then whoever the customer is already attributed to. Under
**first-click**, a customer once attributed stays with that partner. Under
**last-click**, a newer code moves the customer to its partner for that event
and the ones after it; what was already recorded keeps its commission either way.

A conversion is recorded whether or not anyone referred it (`"attributed":
false`). A partner earns only while their membership is **approved**: a pending,
rejected or banned partner's conversions are recorded and earn nothing.

## Commissions, holds and refunds

A commission is a percentage of the sale (rounded **down** to the minor unit)
or a flat amount, as the program's [reward rules](https://boltclaw.ai/docs/rewards) say, and either
once per customer, for a number of months, or for life. A program can pay a
different rate per product, price, plan or interval, more to a group or level of
partners, its own rate to one partner, a bonus at a milestone, and a ceiling per
customer or per month: send a sale's `items` and each line is matched to the most
specific rule, and every commission says why it is that amount (`basis_text`).
It starts **`pending`** for the program's hold period and becomes **`approved`**
when the hold ends, unless it has been flagged for review.

A refund takes back the same share of the commission as the share of the sale
refunded, against the **running total** refunded: two refunds of 30% each take
back 60%. A full refund, or a dispute, takes back all of it (`reversed`). If
the commission had already been paid out, the money has left, so a **negative
commission** is recorded instead and comes off the partner's next payout.

## Stripe

If your checkout runs on Stripe, you can connect your Stripe account from the
dashboard instead (in one click with the Bolt Claw Stripe App, or with a webhook
and its signing secret), and paid checkouts, subscription invoices, refunds and
disputes are reported for you. Eleven other billing systems connect the same way,
from Paddle and Shopify to RevenueCat and Gumroad: see
[Connect your billing system](https://boltclaw.ai/docs/referral-setup).
Each one's conversions carry it as their `source`. Put the code in the Checkout Session's
`client_reference_id` (or `metadata.boltclaw_ref`); for a subscription, also in
`subscription_data.metadata.boltclaw_ref`, so the first invoice is credited
whichever event Stripe delivers first. To tie Stripe customers to the ids you
use here, set `metadata.boltclaw_customer_id` to your own customer id.

A subscription invoice carries its lines, so a rule for one product, price or
billing interval applies to it with nothing else to set up (each line's share
of what was paid, tax and invoice discounts spread across the lines). When
Stripe sends only some of an invoice's lines, the invoice is matched as a
whole. A one-time Checkout carries no lines: set `metadata.boltclaw_product`,
`metadata.boltclaw_price` or `metadata.boltclaw_plan` on the session and the
sale is matched as that one line.

Refunds are matched to their payment by payment intent. On Stripe API versions
from `2025-03-31`, an invoice event no longer carries one, so select
`invoice_payment.paid` too (the Stripe App receives it for you): it links the
invoice to the payment that paid it, and a refund of a **subscription invoice**
is then matched whichever event arrives first. Without it, report such a refund
here with `refund_of` set to the `invoice.paid` event's id.

## The setup status, and test data

`GET /v1/referrals/programs/{id}/integration-status` answers, part by part (the
website tag, billing, signups, sales, codes, the in-app panel and the friend's
welcome), whether each is `connected`, `warnings` or `not_connected`, with one
sentence saying what was seen and one saying what to do, and lists every
connected billing system without its credential. It counts live data only.
`POST .../integration-status/tag-check` fetches the product page once to look
for the website tag.

**Test data** is recorded with `"test": true` on a conversion,
`POST /v1/referrals/programs/{id}/test-sale` runs a whole test sale (a click, a
signup, a sale with two items, a partial refund) and answers every step, and a
test-mode or sandbox event from a billing system is test data too. Test data is
credited to the program's own test partner whoever's code it carried, is never
paid, never counted and emails nobody, and reaches only webhook endpoints with
`test_events` on. Conversions, commissions and rewards carry `test`. A test
`event_id` and a live one, or a test customer and a live one, never collide.
`DELETE /v1/referrals/programs/{id}/test-data` deletes it all and touches nothing
live. [Connect your billing system](https://boltclaw.ai/docs/referral-setup) has the whole picture.

**A click your own server saw.** For a partner's link that opens straight on
your site, `POST /v1/referrals/clicks` with the link's `code` and what your
server saw (`ip`, `user_agent`, `referrer`) records it as a click on
`boltclaw.ai/r/<code>` would be: hashed and classed, never stored, counted
toward the click allowance, and earning nothing by itself.

**The onboarding answers.** A program takes `setup_payments` (how you take
payments), `setup_signups` (how people sign up) and `audience_eu_uk`, and
returns them as `setup`; they choose the steps the dashboard lists.

## What stays in the dashboard

On purpose, and permanently for this API:

- **Payout exports.** A payout file carries partners' PayPal, Wise or bank
  details, for money you owe them. It is downloaded by a signed-in person and
  audited; a key cannot fetch one.
- **Connecting a billing system.** Every integration needs a credential pasted
  or shown once (a signing secret, a read-only key, the Stripe App's install),
  and a secret never passes through an API call or a chat with a model. A
  credential goes in once and never comes back out. The setup status reads
  every integration, without its credential.
- **Everything partner-side.** Applying, links, codes, placements and payout
  details belong to the partner's own session. This API is yours.

Also dashboard-only today: invites, discount codes, links, the Stripe
connection, pausing or archiving a program, banning a partner, approving a
commission early, creating or cancelling a payout batch, and the fraud review
queue.

## Errors here

The usual [codes](https://boltclaw.ai/docs/errors), and what they mean here:

| Status | `code` | |
|---|---|---|
| 400 | `VALIDATION_ERROR` | A field is wrong; `error` names it. |
| 402 | `PLAN_LIMIT` | `error` starts `REF_PROGRAM_LIMIT_REACHED` (a new program), `REF_PARTNER_LIMIT_REACHED` (an approval) or `REF_REFERRER_LIMIT_REACHED` (a new referrer, or approving one again). |
| 403 | `INSUFFICIENT_SCOPE` | The key lacks `referrals:read`, `referrals:write`, `referrals:embed` or `conversions:write`; or an AI assistant's key tried to mint a panel session. |
| 404 | `NOT_FOUND` | No such program, partner, commission or payout **on your account** — including one that is somebody else's. |
| 409 | `CONFLICT` | Not in a state that allows this (an archived program, a partner already decided, a payout no longer open, a payout total that refunds changed), or a conversion recorded at the same instant for the same customer — send the same request again. |

## Operations

### `POST /v1/referrals/conversions`

**Report a conversion**

Scope `conversions:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A lead, sale, refund or dispute. `event_id` is YOUR id for the event and the idempotency
key: to retry, send the SAME `event_id`. A repeat answers HTTP `200` with
`duplicate: true` and changes nothing. A `409 CONFLICT` means another event for this
customer was being recorded at that moment: send the same request again. Never make up a
new `event_id` to retry: that is a new conversion, and can pay a partner twice.

A repeat answers with the conversion as it was first recorded. A lead or a sale needs
`customer`; a sale needs `amount` and `currency`; a refund or dispute needs `refund_of`
(the sale's `event_id`), and a refund its `amount`. Amounts are integers in minor units.
Needs `conversions:write`, which does nothing else, so it can sit in a checkout server.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `event` | `ConversionEvent` | (**required**; one of `lead`, `sale`, `refund`, `dispute`) |
| `event_id` | `string` | YOUR id for this event, and the idempotency key. Reporting it again changes nothing, so a retry sends the same one; a new one is a new conversion. (**required**; min 1; max 255) |
| `occurred_at` | `date-time` |  |
| `customer` | `object` |  |
| `referral` | `object` |  |
| `amount` | `integer` | Minor units. (>= 0) |
| `currency` | `string` |  |
| `refund_of` | `string` | The sale's event_id. (min 1; max 255) |
| `items` | `SaleItem[]` | A sale's lines, so a reward rule can pay per product, price, plan or interval. Their `amount`s must add up to the sale's `amount` (`400` otherwise). Leave them out and the sale is matched as a whole. (<= 100 items) |
| `test` | `boolean` | Record this as TEST data: credited to the program's test partner whoever's code it carries, never payable, never counted toward a limit or a total, emailing nobody, and sent only to webhook endpoints that receive test events. A test `event_id` and a live one never collide, and neither do test and live customers. Deleted with `DELETE /v1/referrals/programs/{id}/test-data`. |

```json
{
  "program_id": "3f8c1e42-9b7a-4d61-a0f5-2c9e1b7d4a83",
  "event": "lead",
  "event_id": "<event_id>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Conversion` | Already reported; the first conversion, unchanged. |
| 201 | `Conversion` | Recorded. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

**Report a signup** — answers `201`

`event_id` is your own id for the event and makes the call safe to retry: reporting the same one again answers `200` with `duplicate: true` and changes nothing.

```bash
curl -X POST https://api.boltclaw.ai/v1/referrals/conversions \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
  "program_id": "4e8a2b71-39cd-4f06-8a52-71b0d4c9e283",
  "event": "lead",
  "event_id": "signup-10492",
  "customer": {
    "id": "cus_10492"
  }
}'
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/referrals/conversions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "program_id": "4e8a2b71-39cd-4f06-8a52-71b0d4c9e283",
    "event": "lead",
    "event_id": "signup-10492",
    "customer": {
      "id": "cus_10492"
    }
  }),
});

// 201
const data = await res.json();
```

### `GET /v1/referrals/programs`

**List programs**

Scope `referrals:read`.

The workspace's referral programs.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ProgramList` | A page of programs. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs`

**Create a program**

Scope `referrals:write`. Accepts `Idempotency-Key`. **Can make something publicly visible.**

A referral program with a public join page partners apply through. Values are checked
here: an https `product_url`, `cookie_days` from 1 to 365, a known `attribution_model`,
`payout_schedule` and `join_mode` (`400`). The plan sets how many programs an
organisation may run.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `name` | `string` | (**required**; max 200) |
| `product_url` | `string` | An https URL. (**required**; max 4096) |
| `currency` | `string` | (max 8) |
| `cookie_days` | `integer` | 1 to 365. |
| `attribution_model` | `string` | `last_click` or `first_click`. (max 20) |
| `hold_days` | `integer` |  |
| `min_payout_minor` | `integer` |  |
| `payout_schedule` | `string` | `manual` or `monthly`. (max 20) |
| `join_mode` | `string` | `apply` or `invite`. (max 20) |
| `auto_approve` | `boolean` |  |
| `terms` | `string \| null` | (max 20000) |
| `preview_title` | `string \| null` | (max 400) |
| `preview_description` | `string \| null` | (max 1000) |
| `customer_discount` | `string \| null` | (max 1000) |
| `discount_code_prefix` | `string \| null` | (max 40) |
| `partner_monthly_cap_minor` | `integer \| null` | The most one partner earns in a calendar month (UTC), in minor units; null for no ceiling. |
| `embed_enabled` | `boolean` | Turns the in-app referral panel on or off. Off, no session can be minted and the panel does not load. |
| `embed_origins` | `string[]` | The sites allowed to frame the panel, like `https://app.example.com`. Replaces the list. https only (`http://localhost:3000` for development), no wildcard, no path, at most 10. (<= 10 items) |
| `embed_config` | `EmbedConfig` | The panel look, copy and sharing, replacing the old settings whole. Unknown keys are `400`. |
| `approve_referrers` | `boolean` | True (default): a new referrer is approved at once. False: they are `pending` until you approve them. |
| `welcome_config` | `any` | The welcome's look and words, replacing the old settings whole; null for the defaults. Unknown keys are `400`. |
| `welcome_show_name` | `boolean` | Show a referrer's name to the friend (default true). An affiliate's is shown only when they agreed. |
| `welcome_page` | `boolean` | A one-screen welcome at the link before your product (default false). |
| `allowed_hosts` | `string[]` | Extra hosts your website tag may read the offer from, like `www.example.com`, beside the product URL's own and the panel's framing origins. Replaces the list. Host names only, at most 10. An AI assistant's key cannot change it. (<= 10 items) |
| `late_code_days` | `integer` | How many days after sign-up a referral code may still be entered (default 14; 0 for never after). (>= 0; <= 365) |
| `lead_grant_daily_cap` | `integer` | The most rewards a day (UTC) signups alone may earn across the program (default 100). (>= 0; <= 100000) |
| `invite_after_purchase` | `boolean` | Give a friend who makes their first payment a referral link of their own (default false). |
| `ask_snooze_days` | `integer` | How long "Not right now" in the panel lasts (default 30). (>= 1; <= 365) |
| `setup_payments` | `SetupPayments` | (one of `stripe`, `paddle`, `polar`, `whop`, `revenuecat`, `woocommerce`, `fastspring`, `lemonsqueezy`, `shopify`, `chargebee`, `recurly`, `gumroad`, `own_system`, `not_sure`, `null`) |
| `setup_signups` | `SetupSignups` | (one of `own_backend`, `supabase`, `firebase`, `clerk`, `authjs`, `nocode`, `checkout_only`, `null`) |
| `audience_eu_uk` | `boolean` | Your audience includes the EU or the UK (default false). The setup status then asks for the tag's consent mode. |
| `application_questions` | `string[]` | Up to three questions a partner answers when they apply (each 1 to 200 characters, no hidden characters). Answers are shown on their application; an empty list asks nothing. (<= 3 items) |
| `disclosure_text` | `string` | The sentence partners add when they share (plain text, 10 to 280 characters). It may be edited, never emptied: partners must tell people they earn from the link (FTC, ASA). A new program starts with one for its currency's region. (min 10; max 280) |
| `leaderboard` | `LeaderboardFields` | The leaderboard promoters see, merged onto the current setting; null switches it off. You always see your own. |
| `waitlist` | `WaitlistFields` | A waitlist program's settings, merged onto the current ones. |
| `kind` | `string` | Start from a template: `affiliate`, `refer_a_friend`, `creator`, `ambassador`, `reseller`, `shop` or `waitlist`. The program gets that kind's settings and reward rules (and levels, for `reseller`; the waitlist itself, for `waitlist`), with anything you send yourself taking precedence, in one step. Left out, the program starts with no rules, as before. (max 40) |

```json
{
  "name": "<name>",
  "product_url": "<product_url>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Program` | Created. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

**Create a referral program** — answers `201`

`name` and `product_url` are the only required fields; everything else takes a sensible default you can change later.

```bash
curl -X POST https://api.boltclaw.ai/v1/referrals/programs \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Acme partners",
  "product_url": "https://example.com"
}'
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/referrals/programs", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "Acme partners",
    "product_url": "https://example.com"
  }),
});

// 201
const data = await res.json();
```

### `GET /v1/referrals/programs/{id}`

**Read a program**

Scope `referrals:read`. Another workspace's answers `404`.

The program, its join link and how many partners are waiting for a decision.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Program` | The program. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/programs/{id}`

**Update a program**

Scope `referrals:write`. Another workspace's answers `404`. **Can make something publicly visible.**

Changes the fields you send; a field left out keeps its value. Values are checked as on create.

Sending `terms` saves new program terms and raises `terms_version`: every partner must accept
the new version before their next payout. An AI assistant's key is asked first when the body
carries `terms`, unless your organisation lets assistants save terms without asking. Draft
terms from the program's own settings with `POST /v1/referrals/programs/{id}/terms/draft`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `name` | `string` | (max 200) |
| `product_url` | `string` | An https URL. (max 4096) |
| `currency` | `string` | (max 8) |
| `cookie_days` | `integer` | 1 to 365. |
| `attribution_model` | `string` | `last_click` or `first_click`. (max 20) |
| `hold_days` | `integer` |  |
| `min_payout_minor` | `integer` |  |
| `payout_schedule` | `string` | `manual` or `monthly`. (max 20) |
| `join_mode` | `string` | `apply` or `invite`. (max 20) |
| `auto_approve` | `boolean` |  |
| `terms` | `string \| null` | (max 20000) |
| `preview_title` | `string \| null` | (max 400) |
| `preview_description` | `string \| null` | (max 1000) |
| `customer_discount` | `string \| null` | (max 1000) |
| `discount_code_prefix` | `string \| null` | (max 40) |
| `partner_monthly_cap_minor` | `integer \| null` | The most one partner earns in a calendar month (UTC), in minor units; null for no ceiling. |
| `embed_enabled` | `boolean` | Turns the in-app referral panel on or off. Off, no session can be minted and the panel does not load. |
| `embed_origins` | `string[]` | The sites allowed to frame the panel, like `https://app.example.com`. Replaces the list. https only (`http://localhost:3000` for development), no wildcard, no path, at most 10. (<= 10 items) |
| `embed_config` | `EmbedConfig` | The panel look, copy and sharing, replacing the old settings whole. Unknown keys are `400`. |
| `approve_referrers` | `boolean` | True (default): a new referrer is approved at once. False: they are `pending` until you approve them. |
| `welcome_config` | `any` | The welcome's look and words, replacing the old settings whole; null for the defaults. Unknown keys are `400`. |
| `welcome_show_name` | `boolean` | Show a referrer's name to the friend (default true). An affiliate's is shown only when they agreed. |
| `welcome_page` | `boolean` | A one-screen welcome at the link before your product (default false). |
| `allowed_hosts` | `string[]` | Extra hosts your website tag may read the offer from, like `www.example.com`, beside the product URL's own and the panel's framing origins. Replaces the list. Host names only, at most 10. An AI assistant's key cannot change it. (<= 10 items) |
| `late_code_days` | `integer` | How many days after sign-up a referral code may still be entered (default 14; 0 for never after). (>= 0; <= 365) |
| `lead_grant_daily_cap` | `integer` | The most rewards a day (UTC) signups alone may earn across the program (default 100). (>= 0; <= 100000) |
| `invite_after_purchase` | `boolean` | Give a friend who makes their first payment a referral link of their own (default false). |
| `ask_snooze_days` | `integer` | How long "Not right now" in the panel lasts (default 30). (>= 1; <= 365) |
| `setup_payments` | `SetupPayments` | (one of `stripe`, `paddle`, `polar`, `whop`, `revenuecat`, `woocommerce`, `fastspring`, `lemonsqueezy`, `shopify`, `chargebee`, `recurly`, `gumroad`, `own_system`, `not_sure`, `null`) |
| `setup_signups` | `SetupSignups` | (one of `own_backend`, `supabase`, `firebase`, `clerk`, `authjs`, `nocode`, `checkout_only`, `null`) |
| `audience_eu_uk` | `boolean` | Your audience includes the EU or the UK (default false). The setup status then asks for the tag's consent mode. |
| `application_questions` | `string[]` | Up to three questions a partner answers when they apply (each 1 to 200 characters, no hidden characters). Answers are shown on their application; an empty list asks nothing. (<= 3 items) |
| `disclosure_text` | `string` | The sentence partners add when they share (plain text, 10 to 280 characters). It may be edited, never emptied: partners must tell people they earn from the link (FTC, ASA). A new program starts with one for its currency's region. (min 10; max 280) |
| `leaderboard` | `LeaderboardFields` | The leaderboard promoters see, merged onto the current setting; null switches it off. You always see your own. |
| `waitlist` | `WaitlistFields` | A waitlist program's settings, merged onto the current ones. |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Program` | The program. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/partners`

**List a program's partners**

Scope `referrals:read`. Another workspace's answers `404`.

Partners by their name in this program, with all-time stats. An email appears only where the partner chose to share it. `q` keeps the partners whose display name contains it, ignoring case.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `status` | query | `PartnerStatus` | (one of `pending`, `approved`, `rejected`, `banned`) |
| `q` | query | `string` | 1 to 80 characters, without `*`. Keeps partners whose display name contains it, ignoring case. (min 1; max 80) |
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PartnerList` | A page of partners. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/partners/{id}/approve`

**Approve a partner**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Approves an application. The partner gets their links, starts earning, and is emailed at
once. Approving a partner who is already approved changes nothing and emails nobody, so a
retry is safe. Takes no body.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Partner` | The partner. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/partners/{id}/reject`

**Reject a partner**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Rejects an application. Takes no body.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Partner` | The partner. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/commissions`

**List a program's commissions**

Scope `referrals:read`. Another workspace's answers `404`.

Commissions, newest first, each with its conversion and partner.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `status` | query | `CommissionStatus` | (one of `pending`, `approved`, `payable`, `paid`, `reversed`, `fraud`) |
| `partner_id` | query | `uuid` |  |
| `from` | query | `string` | (max 64) |
| `to` | query | `string` | (max 64) |
| `adjustments` | query | `string` | `true` for adjustments only, `false` for earned commissions only. (one of `true`, `false`) |
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `CommissionList` | A page of commissions. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/commissions/{id}/adjust`

**Adjust a commission**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Adds an adjustment on the same conversion, never an edit in place: a signed amount in
minor units (not 0) and your reason, which is recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `amount_minor` | `integer` | Signed minor units; not 0. (**required**) |
| `reason` | `string` | Trimmed; recorded in the activity log. (**required**; min 1; max 500) |

```json
{
  "amount_minor": 1,
  "reason": "<reason>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Commission` | The adjustment. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/payouts`

**List a program's payouts**

Scope `referrals:read`. Another workspace's answers `404`.

Payouts, newest first. Payout details and export files stay in the dashboard.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `status` | query | `PayoutStatus` | `unclaimed`: the provider holds it until the promoter claims it (PayPal's sign-up link, a reward email that bounced). `returned`: it came back before it was paid, and its commissions are back in Ready to pay. (one of `draft`, `processing`, `unclaimed`, `paid`, `failed`, `returned`, `cancelled`) |
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutList` | A page of payouts. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/payouts/{id}/paid`

**Mark a payout paid**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

You paid outside BoltClaw; this records it with your reference. Send `expected_minor` (the
amount you were shown): if refunds have moved the total since, nothing is marked and the
answer is `409 CONFLICT` naming the new figure. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `reference` | `string` | Your payment reference; trimmed. (**required**; min 1; max 200) |
| `expected_minor` | `integer` | The amount you were shown. (>= 0) |

```json
{
  "reference": "<reference>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Payout` | The payout. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/payout-providers`

**List a program's payout providers**

Scope `referrals:read`. Another workspace's answers `404`.

The payout accounts this program can pay through, by name and state: your own PayPal,
Wise, Stripe, Tremendous, Trolley or Tango account, or your own payout system (`webhook`).
Never a credential. Connecting or disconnecting one is done in the dashboard, on the
Payouts tab.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutProviderList` | The connections. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/payout-providers/{id}/test`

**Test a payout provider connection**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Makes one harmless read at the provider with the stored credential and records whether it
works, and why not. Moves no money.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutProvider` | The connection, as the test left it. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/payout-settings`

**Get a program's payout settings**

Scope `referrals:read`. Another workspace's answers `404`.

How the program pays: its provider, the providers a promoter may choose among, the
schedule, sending on a schedule (available only after a legal review), and the largest
payout sent without a second confirmation.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutSettings` | The settings. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/programs/{id}/payout-settings`

**Change how a program pays**

Scope `referrals:write`. Another workspace's answers `404`.

Choose the provider that pays promoters, the providers they may choose among (each must be
connected), the day of the month a monthly batch is made, and an optional largest payout.
`auto_send` can be turned on only once BoltClaw allows it, after a legal review; until
then a person sends every batch. Recorded in the activity log.

These are a send's own rails, so a key that is not an AI assistant's needs
`referrals:payouts` as well as `referrals:write`, as sending does; an assistant needs a
person's approval.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `provider` | `PayoutProviderName` | How a payout is paid. `manual`: a file you pay from yourself. `webhook`: your own payout system. The rest: your own account at that provider. BoltClaw never holds the money. (one of `manual`, `webhook`, `paypal`, `wise`, `stripe_connect`, `tremendous`, `trolley`, `tango`) |
| `providers` | `PayoutProviderName[]` | (<= 8 items) |
| `auto_send` | `boolean` |  |
| `payout_day` | `integer` | (>= 1; <= 28) |
| `max_payout_minor` | `integer \| null` | (>= 1) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutSettings` | The settings. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/payout-preview`

**See who is ready to be paid**

Scope `referrals:read`. Another workspace's answers `404`.

Ready to pay, before any batch: per currency and provider, the promoters a batch would pay
and the provider's estimated fees (charged by that provider to your account; BoltClaw
charges nothing), and every promoter who would wait, with why: payout details changed in
the last 48 hours, payouts locked, an open fraud flag, below your minimum, or details the
provider needs that they have not given yet.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutPreview` | Ready to pay. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/payout-batches`

**Make a payout batch**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Turns every approved commission that can be paid into draft payouts, one per promoter per
currency, each with the provider that pays it. Nothing is sent: the batch waits for
`send`, a file export, or marking each payout paid. Below the minimum, negative, flagged
or held waits for the next batch.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutBatchResult` | Nothing could be paid now; no batch was made. |
| 201 | `PayoutBatchResult` | A batch was made. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/payout-batches/{id}`

**Get a payout batch, ready to send**

Scope `referrals:read`. Another workspace's answers `404`.

Every payout in the batch with its provider, whether it can be sent now (and why not),
the provider's estimated fee, and how it went once sent. `sendable` is exactly what `send`
would send: its payout ids, its totals per currency (send them back as
`expected_totals`), and the payouts large enough to need confirming by name.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutBatch` | The batch. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/payout-batches/{id}/send`

**Send a payout batch through your payout provider**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Pays the batch's payouts from YOUR provider account (PayPal, Wise, Stripe, Tremendous,
Trolley, Tango) or asks your own payout system for each (`referral.payout.requested`).
BoltClaw never holds the money. `expected_totals` must equal what the batch would send
now (`GET /referrals/payout-batches/{id}`, `sendable.totals`), or nothing is sent; a
payout in `sendable.large` must be named in `confirm_large`. Each payout is sent once,
with an idempotency key made from its id; results arrive as `referral.payout.*` events.

Needs `referrals:payouts`, the key your own payout server holds, or an AI assistant with a
person's approval: `referrals:write` alone never sends money. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `payout_ids` | `uuid[]` | Send only these; every payout in `sendable` when absent. (<= 5000 items) |
| `expected_totals` | `object` | What you expect to send, per currency, in minor units: `{"USD": 12000}`. Must equal `sendable.totals`. (**required**) |
| `confirm_large` | `uuid[]` | (<= 5000 items) |

```json
{
  "expected_totals": {}
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutSendResult` | What happened to each payout. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/payout-batches/{id}/cancel-auto-send`

**Stop a scheduled send**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Stops a batch that was going to be sent by itself after its 24-hour notice. A stop: it
always works, and the batch stays for a person to send. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `AutoSendCancelled` | Whether a scheduled send was stopped. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/payouts/{id}`

**Get a payout**

Scope `referrals:read`. Another workspace's answers `404`.

One payout, with how it was sent and what its provider said (`delivery`).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Payout` | The payout. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/payouts/{id}/details`

**Read where a requested payout goes**

Scope `referrals:payouts`. Another workspace's answers `404`.

For YOUR OWN payout system, after `referral.payout.requested`: the promoter's own payout
details for this one payout, while it waits to be paid. Refused (409) while the promoter's
details changed in the last 48 hours or they locked their payouts. Every read is recorded
in the activity log, as a payout export is. Needs `referrals:payouts`, which an AI
assistant's key never holds.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutDetails` | Where to send it. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/payouts/{id}/result`

**Report what your payout system did**

Scope `referrals:payouts`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

For YOUR OWN payout system: the payout was `paid` (with your reference), `failed` or
`returned` (with a reason). Failed and returned put its commissions back in Ready to pay.
The same answer again changes nothing; a payout already settled otherwise is 409. Needs
`referrals:payouts`, which an AI assistant's key never holds.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `status` | `string` | (**required**; one of `paid`, `failed`, `returned`) |
| `reference` | `string` | Your own payment reference (paid only). (max 200) |
| `reason` | `string` | Why it failed or came back (required unless paid). (one of `invalid_destination`, `recipient_not_ready`, `insufficient_funds`, `returned`, `other`) |

```json
{
  "status": "paid"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Payout` | The payout. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/tax`

**Get a program's tax settings**

Scope `referrals:read`. Another workspace's answers `404`.

Whether this program's payouts wait for a tax form (`tax_forms`) or for the promoter's own
invoice (`invoice_required`), and whether you issue self-billed invoices for your EU and UK
business promoters (`self_billing`). Also whether your own tax details are complete, never
the details themselves (your EIN, legal name and addresses are entered in the dashboard),
and the table of which payout providers count toward your 1099-NEC, each with why and its
source. BoltClaw prepares these forms; it does not give tax advice or file anything for
you. See [Tax forms and 1099s](https://boltclaw.ai/docs/referral-tax).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `TaxSettings` | The settings. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/programs/{id}/tax`

**Change whether a program's payouts wait for tax forms or invoices**

Scope `referrals:write`. Another workspace's answers `404`.

Send only what you change. `tax_forms`: `off`; `us_only` (a US person needs a W-9, and
anyone else signs a short statement that they are not a US person); or `all` (everyone
needs a W-9, W-8BEN or W-8BEN-E). A promoter whose required form is missing, expired or
replaced waits in Ready to pay until they sign. `invoice_required`: each payout waits for
the promoter's own invoice (a promoter who self-bills never waits for one).
`self_billing`: you issue the invoices for promoters who accept your self-billing
agreement; turning it on needs your invoicing details first (`409` until they are
complete). An archived program is `409`. Recorded in the activity log with the values
before and after.

Turning a requirement off lets money that waited for a form be paid, so a key needs
`referrals:payouts` as well as `referrals:write` for this call, as changing how payouts
are sent does; an AI assistant needs a person's approval.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `tax_forms` | `TaxRequirement` | Whether a program's payouts wait for a tax form. `off`: never. `us_only`: a US person needs a W-9, and anyone else signs a short statement that they are not a US person. `all`: everyone needs a W-9, W-8BEN or W-8BEN-E. (one of `off`, `us_only`, `all`) |
| `invoice_required` | `boolean` |  |
| `self_billing` | `boolean` | Turning it on needs your invoicing details first (`409` until then). |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `TaxSettings` | The settings. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/tax-forms`

**List each promoter's tax form status**

Scope `referrals:read`. Another workspace's answers `404`.

Every approved or pending promoter of the program with their latest tax form for your
business: its type (W-9, W-8BEN, W-8BEN-E, or the statement that they are not a US
person), revision, status, when it was signed and when it expires. STATUS ONLY: never a
tax number, a legal name or anything the form says. Opening a form is done in the
dashboard, with two-step verification. `requirement_met` is false while their payouts
wait for a form; `waiting_since` and `forfeitable_minor` say since when, and how much.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `TaxFormStatusList` | A page of promoters, by membership id. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/partners/{id}/forfeit`

**Forfeit a promoter's money held for a missing tax form**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Gives up, for good, every approved commission of this promoter that is waiting for a tax
form in this program: they will not be paid it, and no payout will ever include it. Only
money held for a missing, expired or replaced form can be forfeited (`409` otherwise, and
when nothing is waiting); what they earn afterwards is not touched. Send a `reason` of 3 to
500 characters: it is recorded in the activity log with what was given up. BoltClaw never
forfeits anything by itself.

Needs `referrals:payouts` as well as `referrals:write`, as sending money does, or an AI
assistant with a person's approval: `referrals:write` alone never gives a promoter's money
up.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `reason` | `string` | Why, recorded in the activity log. (**required**; min 3; max 500) |

```json
{
  "reason": "<reason>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ForfeitResult` | What was given up. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/tax-years/{year}`

**Get a year's 1099-NEC data**

Scope `referrals:read`. Another workspace's answers `404`.

Everyone your referral programs paid in a calendar year, as one person per account, with
how each was paid (per provider and currency), their tax form's status, and whether they
belong on your 1099-NEC (`included`) and why, in a sentence. Warnings say what to fix
before you file: no form, no tax number, backup withholding (24%) may apply, a replaced
form, an expired W-8, money in another currency. Only US dollars paid through the
providers your 1099 table counts are reportable, against the year's threshold
(`threshold_minor`). No tax number and no legal name: the files themselves (the IRS
IRIS CSV, a general CSV and Copy B) are made in the dashboard, with two-step verification.
BoltClaw never files, never withholds and never checks a tax number with the IRS.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `year` | path | `string` | A calendar year, four digits (`2026`). A year before 2026, or after this one, is `400`; anything that is not four digits is `404`. (**required**; min 4; max 4) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `TaxYear` | The year. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/payouts/{id}/invoices`

**List a payout's invoices**

Scope `referrals:read`. Another workspace's answers `404`.

The invoices for one payout, newest first: the self-billed invoice you issued in the
promoter's name, the promoter's own uploaded invoice, and any they replaced. Each says
whether it was accepted, disputed (with the promoter's reason) or replaced. Read a PDF with
`POST /referrals/invoices/{id}/link`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `InvoiceList` | The payout's invoices. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/invoices/{id}/link`

**Get a download link for an invoice**

Scope `referrals:read`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A link to one invoice's PDF, good for ten minutes and one download. It names a promoter's
business, so it needs the payouts permission and every link is recorded in the activity
log. Treat the link as a secret; ask again for a new one.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `FileLink` | The link. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/payouts/{id}/statement-link`

**Get a download link for a payout statement**

Scope `referrals:read`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A link to the payout's statement as a PDF (who paid whom, how much, when and for which
period), good for ten minutes and one download. The statement is what a promoter without
a self-billed or uploaded invoice keeps. Treat the link as a secret; ask again for a new
one.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `FileLink` | The link. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/payouts/{id}/invoice`

**Issue a paid payout's self-billed invoice**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

For a program that self-bills: issues the self-billed invoice of a paid payout that has
none (its promoter's or your own invoicing details were missing when it was paid). A
payout that already has one answers that invoice, so a repeat changes nothing. `409` with
what is missing when it cannot be issued: the payout is not paid, the promoter does not
self-bill with this program, your invoicing details are incomplete, or the promoter is in
your own country and your domestic VAT rate is not set. Moves no money.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Invoice` | The invoice. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/invoices/{id}/reissue`

**Issue a corrected invoice for a disputed one**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A promoter disputed a self-billed invoice within its 14 days. Fix what they said was wrong
(ask them to correct their own details, or correct yours in the dashboard), then call this:
the disputed invoice becomes `void` and a new one is issued, with the next number in the
promoter's series, from the details as they are now. It starts its own 14 days. Only a
disputed self-billed invoice can be replaced (`409` otherwise). Recorded in the activity
log. Moves no money.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Invoice` | The corrected invoice. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/brand`

**Get the brand your partners see**

Scope `referrals:read`.

Your brand as partners see it on your own partner portal, the join page, the in-app panel,
the friend's welcome and their emails: its name, logos, colours, corner radius, support
address, sender name and whether "Powered by BoltClaw" shows. Without `program_id`, the
workspace's brand, which every program uses; with it, that program's own override, where a
field left `null` follows the workspace's brand. `effective` is what partners of that
program actually see, both merged. `support_email_verified` says whether the support
address was confirmed with a code: only then is it the reply-to of partners' emails. See
[White-label portal](https://boltclaw.ai/docs/referral-portal).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `program_id` | query | `uuid` | A program's own override. Leave it out for the workspace's brand. |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Brand` | The brand. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/brand`

**Change the brand your partners see**

Scope `referrals:write`.

Send only what you change; `null` clears a field (a program's override then follows the
workspace's brand again). `name` up to 80 characters; `accent` and `background` as
`#rrggbb`; `radius` from 0 to 24; `sender_name` up to 60 characters, the name partners'
emails come from (the address stays BoltClaw's); `support_email`, which becomes the
reply-to of partners' emails only after you confirm it with a code (changing it unconfirms
it); `show_powered_by`, off unless you turn it on. A logo or icon is uploaded first with
`POST /referrals/brand/uploads`, then saved here by sending the `path` it answered as
`logo_light_path`, `logo_dark_path` or `favicon_path` (or `null` to remove it). With
`program_id`, the change is that program's override. Recorded in the activity log with the
names of the fields changed. An AI assistant asks a person first, unless your organisation
lets assistants change it without asking.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid \| null` | Change this program's override instead of the workspace's brand. |
| `name` | `string \| null` | No control characters and no angle brackets. (max 80) |
| `accent` | `string \| null` |  |
| `background` | `string \| null` |  |
| `radius` | `integer \| null` | (>= 0; <= 24) |
| `support_email` | `email \| null` | Unconfirmed until its code comes back. |
| `show_powered_by` | `boolean \| null` |  |
| `sender_name` | `string \| null` | No control characters and none of these: " < > @ , ; : \ (max 60) |
| `logo_light_path` | `string \| null` | The `path` an upload answered, or null to remove the logo. |
| `logo_dark_path` | `string \| null` | The `path` an upload answered, or null to remove the logo for dark backgrounds. |
| `favicon_path` | `string \| null` | The `path` an upload answered, or null to remove the icon. |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Brand` | The brand. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/brand/uploads`

**Get an upload URL for a logo or icon**

Scope `referrals:write`. Accepts `Idempotency-Key`.

A signed URL for one file of your brand: `logo_light`, `logo_dark` or `favicon`, as a PNG,
JPEG or WebP image of at most 2 MiB (no SVG). `PUT` the file's bytes to `upload_url` with
its content type, then save it with `PATCH /referrals/brand`, sending `path` as the slot's
field. Nothing changes until that PATCH: an upload that is never saved is not shown to
anyone. The URL works for one file and expires (`expires_at`); treat it as a secret.
`upload_url` is null in a replay of the same `Idempotency-Key`: ask for a new one.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid \| null` | For a program's override. |
| `slot` | `BrandFileSlot` | `logo_light`: the logo on light backgrounds. `logo_dark`: on dark ones. `favicon`: the browser tab's icon. (**required**; one of `logo_light`, `logo_dark`, `favicon`) |
| `content_type` | `string` | (**required**; one of `image/png`, `image/jpeg`, `image/webp`) |

```json
{
  "slot": "logo_light",
  "content_type": "image/png"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `BrandUpload` | Where to upload. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/brand/support-email/verification`

**Send a code to confirm the support address**

Scope `referrals:write`. Accepts `Idempotency-Key`.

Emails a six-digit code to the support address saved on the brand (the workspace's, or the
program's with `program_id`), good for 15 minutes. Confirm it with
`POST /referrals/brand/support-email/verification/confirm`; until then partners' emails
have no reply-to. At most five codes a day for one brand and twenty for the workspace
(`429` past that). `409` when the address is already confirmed, `400` when none is saved.
The answer repeats the address the code went to.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid \| null` | A program's override. |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `BrandEmailCodeSent` | The code was sent. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/brand/support-email/verification/confirm`

**Confirm the support address with its code**

Scope `referrals:write`. Accepts `Idempotency-Key`.

The six-digit code from the email `POST /referrals/brand/support-email/verification` sent.
Each code has five tries; a wrong, used or expired one is `400` (send a new one). Once
confirmed, the address is the reply-to of partners' emails about the program, until it is
changed. Recorded in the activity log.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid \| null` |  |
| `code` | `string` | The six digits from the email. (**required**) |

```json
{
  "code": "<code>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Brand` | The brand, with the address confirmed. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/domains`

**List your own link and portal domains**

Scope `referrals:read`.

Every domain of your own connected to your referral programs: a links domain
(`go.example.com`, where links read `https://go.example.com/<code>`) or a partner portal
(`partners.example.com`), each with where it stands and the exact DNS records still to set.
See [Your own domain](https://boltclaw.ai/docs/referral-domains).

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `DomainList` | The domains. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/domains`

**Connect one of your own domains**

Scope `referrals:write`. Accepts `Idempotency-Key`.

Claims a subdomain of yours (`go.example.com` for links, `partners.example.com` for the
partner portal; never a domain's root, a wildcard or an IP address) for every program
(`all_programs`, the default) or the ones in `program_ids`. Nothing is served yet: the
answer's `records` are the exact DNS records to add at your DNS host, in order. First a
TXT record, `_boltclaw.<host>` with the value `bc-verify=<token>`, which proves the domain
is yours; once it is seen, the CNAME record our hosting provider recommends for it appears
in `records` (it differs per domain: never use one from another domain), and sometimes a
second TXT record our hosting provider asks for. Check progress with
`POST /referrals/domains/{id}/check`. A domain counts against your plan's domains from the
moment it is added (`402` past it); `409` when it is already yours, or connected to
another account.

An AI assistant always asks a person first: it decides where your links and portal will
answer. In the dashboard, adding a domain also needs two-step verification; over the API
that approval stands in for it.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `host` | `string` | A subdomain of yours, such as go.example.com. Never a domain's root, a wildcard or an IP address. (**required**; max 300) |
| `kind` | `DomainKind` | `links`: your referral links answer on it (`https://go.example.com/<code>`). `portal`: your partner portal and join pages, and links too. (**required**; one of `links`, `portal`) |
| `all_programs` | `boolean \| null` | Serve every program, new ones included. True when left out. |
| `program_ids` | `array \| null` | The programs it serves, when `all_programs` is false (1 to 50). (<= 50 items) |

```json
{
  "host": "<host>",
  "kind": "links"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Domain` | The domain, with the records to set. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/domains/{id}`

**Get one of your domains**

Scope `referrals:read`. Another workspace's answers `404`.

One domain: where it stands (`status`, and `problem`, a sentence saying what is in the
way), the exact DNS records to set and whether each is in place, whether a CAA record on
your domain keeps the certificate from being issued, and, once active, its address.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Domain` | The domain. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `DELETE /v1/referrals/domains/{id}`

**Remove one of your domains**

Scope `referrals:write`. Another workspace's answers `404`.

The domain stops answering at once: links on it stop working (each still works at its
`https://boltclaw.ai/r/<code>` address) and its portal pages are gone. It is then taken off
our hosting provider, so no record of ours is left pointing at it; remove your own DNS
records for it too. Recorded in the activity log. An AI assistant always asks a person
first.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `DomainRemoved` | Removed. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/domains/{id}/check`

**Check a domain's DNS records now**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Looks at the domain's records now instead of waiting for the next check (pending domains
are looked at every ten minutes, active ones every day), and moves it on when they are in
place: the TXT record proves the domain is yours, the CNAME record routes it, and then the
certificate is issued. At most once every 15 seconds per domain: a check asked sooner
answers the domain as it stands. Nothing is ever removed by a check.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Domain` | The domain, as it stands after the check. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/listing`

**Get a program's directory listing**

Scope `referrals:read`. Another workspace's answers `404`.

What the partner directory shows about the program, and whether it is listed. The
directory is a page inside BoltClaw that only signed-in accounts can open; it lists only
programs whose business switched listing on, and never shows who promotes a program or
how many do. `missing` names every condition not met yet (an active program anybody may
apply to, terms, a reward for partners, an organisation at least a week old and not
suspended, and a real sale through your billing system or a finished setup checklist);
listing is refused until it is empty. `preview` is the card partners will see. `state` is
`listed`, `unlisted`, `held` (reports from three different accounts hid it until
BoltClaw reviews it) or `delisted`. See [Recruiting partners](https://boltclaw.ai/docs/referral-growth).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ListingSettings` | The listing. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/programs/{id}/listing`

**Change a program's directory listing**

Scope `referrals:write`. Another workspace's answers `404`.

Sets what the partner directory shows (`category`, `pitch` of at most 280 characters,
`countries`, `languages`, the logo's `logo_path` from
`POST /referrals/programs/{id}/listing/logo`) and whether the program is `listed`. The
reward summary, cookie window and payout schedule come from the program itself, never
from text you type. Turning `listed` on is `409` with every reason while a condition is
not met. An AI assistant asks a person first, because listing publishes the program to
every signed-in partner; your organisation may let assistants change it without asking.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `listed` | `boolean` |  |
| `category` | `DirectoryCategory` | (one of `software`, `ai_tools`, `education`, `finance`, `health`, `ecommerce`, `creator_tools`, `agencies`, `other`) |
| `pitch` | `string \| null` | Plain text: no HTML. (max 280) |
| `countries` | `array \| null` | Two-letter country codes the program welcomes partners from. (<= 60 items) |
| `languages` | `array \| null` | Two-letter language codes, like en or de. (<= 20 items) |
| `logo_path` | `string \| null` | The `path` from the logo upload, or null to remove it. (max 200) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ListingSettings` | The listing, as it stands now. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/listing/logo`

**Get a URL to upload the listing's logo**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A signed URL for one PNG, JPEG or WebP file of at most 2 MiB. `PUT` the file's bytes
there with its content type, then save it with `PATCH /referrals/programs/{id}/listing`,
sending the answer's `path` as `logo_path`. The file is accepted only if its bytes are an
image of that type. `upload_url` is null in a replay of the same `Idempotency-Key`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `content_type` | `string` | (**required**; one of `image/png`, `image/jpeg`, `image/webp`) |

```json
{
  "content_type": "image/png"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `ImageUpload` | Where to upload the file. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/broadcasts`

**List a program's broadcasts**

Scope `referrals:read`. Another workspace's answers `404`.

The program's messages to its promoters, newest first (at most 50): drafts, ones being
sent and sent ones, with who they went to and how many were emailed, muted or failed.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `BroadcastList` | The broadcasts. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/broadcasts`

**Write a broadcast to a program's promoters**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A draft, to everyone approved in the program, one group or level, or a list of up to 500
promoters you pick. Nothing is sent until `POST /referrals/broadcasts/{id}/send`. The
`body` is a small Markdown subset: paragraphs, **bold**, *italic*, `[links](https://...)`
and lists; no HTML, no images, https links only, and every link is shown with its domain.
`{name}`, `{link}` and `{code}` are filled with each promoter's own display name, referral
link and discount code. Subject at most 120 characters, body at most 5,000.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `subject` | `string` | (**required**; max 120) |
| `body` | `string` | Markdown subset: paragraphs, **bold**, *italic*, [links](https://...) and lists. No HTML, no images, https links only. `{name}`, `{link}` and `{code}` are filled per promoter. (**required**; max 5000) |
| `audience` | `BroadcastAudience` |  |

```json
{
  "subject": "<subject>",
  "body": "<body>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Broadcast` | The draft. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/broadcasts/{id}`

**Get one broadcast**

Scope `referrals:read`. Another workspace's answers `404`.

One broadcast: its subject and Markdown, who it goes to, the hosts it links to, whether
its current text was test-sent, and once sent, how many promoters it reached: emailed,
muted (they still see it on their program page), your own users in the in-app panel
(`referrers`, never emailed by BoltClaw), failed and skipped.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Broadcast` | The broadcast. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/broadcasts/{id}`

**Change a draft broadcast**

Scope `referrals:write`. Another workspace's answers `404`.

Changes a draft's subject, body or audience. A sent broadcast is a record and is `409`:
write a new one.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `subject` | `string` | (max 120) |
| `body` | `string` | (max 5000) |
| `audience` | `BroadcastAudience` |  |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Broadcast` | The draft. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/broadcasts/{id}/test`

**Send yourself a test of a draft**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Emails the draft, as it reads now, to the address of the person who made this API key
and nobody else, with sample values in `{name}`, `{link}` and `{code}`. At most ten per
draft, one every 15 seconds. The dashboard sends a broadcast only after a test of its
current text.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `BroadcastTested` | The draft, with where the test went. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/broadcasts/{id}/send`

**Send a broadcast to a program's promoters**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Sends the draft. Every approved promoter it names sees it on their program page; those
with a BoltClaw account are emailed (unless they muted the program's messages: every email
has a one-click unsubscribe), a few a minute; your own users in the in-app panel are never
emailed by BoltClaw, so `referral.broadcast.sent` tells your webhook, and
`GET /referrals/broadcasts/{id}/referrers` names them for your own message. A program may
send 2 broadcasts in any 24 hours and 10 in any 30 days (`429` past either). An AI
assistant always asks a person first: it messages people.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Broadcast` | The broadcast, sending or sent. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/broadcasts/{id}/referrers`

**List your own users a broadcast reached**

Scope `referrals:read`. Another workspace's answers `404`.

The referrers (your own users, made by your server) a sent broadcast reached, 200 a page,
by your own id for each (`external_id`), with their name, link and code, so your system can
send them the message itself. BoltClaw emails none of them.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `BroadcastReferrerList` | A page of referrers. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/imports/preview`

**Check a partner list before importing it**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Checks up to 500 rows (as `rows`, or a `csv` with a header row: `name`, `email`, and
optionally `code`, `group`, `rate`, `balance`, `currency`) against the program and writes
nothing: rows that would fail and why (a code taken, a person already invited or
promoting, a group that does not exist, a balance in another currency). Addresses are
hashed on arrival and never stored. Send a larger file in parts. See
[Recruiting partners](https://boltclaw.ai/docs/referral-growth).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `rows` | `ImportRow[]` | (<= 500 items; >= 1 items) |
| `csv` | `string` | A CSV with a header row; at most 500 rows a call. (max 65000) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ImportPreview` | What an import would do. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/imports`

**List a program's imports**

Scope `referrals:read`. Another workspace's answers `404`.

The partner lists brought into the program, newest first, with how many people each invited and how many claimed.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ImportList` | The imports. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/imports`

**Import a partner list**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Makes an invite reserved for each row, all or nothing (`409` with the first row's reason
when any fails: preview first). Each invite holds that person's vanity code (no one else
can take it meanwhile), group, custom rate and opening balance. BoltClaw sends nobody an
email: the answer has each person's personal link, and you send it from your own email.
When they sign in through it they are approved (if your plan has room) with their code,
group and rate, and an opening balance becomes a bonus paid by hand ("Balance brought
over") only then. `import_id` continues an import begun by an earlier part of the same
file. A file with opening balances also needs `referrals:payouts` on the key. An AI
assistant always asks a person first. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `rows` | `ImportRow[]` | (<= 500 items; >= 1 items) |
| `csv` | `string` | A CSV with a header row; at most 500 rows a call. (max 65000) |
| `import_id` | `uuid` | Continue an import begun by an earlier part of the file. |

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `ImportCommitted` | The import, with each person's link. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/imports/{id}/invites`

**List an import's personal links**

Scope `referrals:write`. Another workspace's answers `404`.

Everyone in one import with their personal link and whether they claimed it, 1,000 a page,
for the file you send from your own email. The links are credentials (each joins one
person), so this needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ImportInviteList` | A page of invites. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/library`

**List a program's file library**

Scope `referrals:read`. Another workspace's answers `404`.

The images, text snippets and links partners see on their program page (an item with a
`group_id` only by that group's), in order. An image item is shown once its file is in
(`ready`).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `LibraryItemList` | The library. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/library`

**Add to a program's file library**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

An `image` (its file follows through `POST /referrals/library/{id}/upload`), a `snippet`
of at most 2,000 characters whose `{link}` and `{code}` partners see filled with their
own (with the program's disclosure line after it), or a `link` to a brand guide or press
kit (https only; its domain is shown). `group_id` shows it to one group only; `in_panel`
shows a snippet to your own users in the in-app panel too. At most 200 items a program.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `kind` | `string` | (**required**; one of `image`, `snippet`, `link`) |
| `title` | `string` | (**required**; max 120) |
| `body` | `string` | A snippet's text. (max 2000) |
| `url` | `string` | A link's https address. (max 2048) |
| `group_id` | `uuid \| null` |  |
| `in_panel` | `boolean` | A snippet only: shown to your own users in the in-app panel too. |
| `position` | `integer` | (>= 0; <= 10000) |

```json
{
  "kind": "image",
  "title": "<title>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `LibraryItem` | The item. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/library/{id}`

**Change a library item**

Scope `referrals:write`. Another workspace's answers `404`.

Changes an item's title, text, link, group, place in the order, or whether a snippet is in
the panel. For an image, `image_path` saves the file uploaded through
`POST /referrals/library/{id}/upload`; it is accepted only if its bytes are a PNG, JPEG or
WebP image.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `title` | `string` | (max 120) |
| `body` | `string` | (max 2000) |
| `url` | `string` | (max 2048) |
| `group_id` | `uuid \| null` |  |
| `in_panel` | `boolean` |  |
| `position` | `integer` | (>= 0; <= 10000) |
| `image_path` | `string` | An image item's file, from its upload. (max 200) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `LibraryItem` | The item. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `DELETE /v1/referrals/library/{id}`

**Delete a library item**

Scope `referrals:write`. Another workspace's answers `404`.

The item and its file are gone; partners no longer see it. An AI assistant asks a person
first, unless your organisation lets assistants do it without asking.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `LibraryItemDeleted` | Deleted. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/library/{id}/upload`

**Get a URL to upload a library image**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A signed URL for an image item's file: one PNG, JPEG or WebP of at most 2 MiB (no SVG,
HTML or PDF). `PUT` the bytes there with their content type, then save it with
`PATCH /referrals/library/{id}` and the answer's `path` as `image_path`. `upload_url` is
null in a replay of the same `Idempotency-Key`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `content_type` | `string` | (**required**; one of `image/png`, `image/jpeg`, `image/webp`) |

```json
{
  "content_type": "image/png"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `ImageUpload` | Where to upload the file. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/link-hosts`

**List the sites a program's links may go to**

Scope `referrals:read`. Another workspace's answers `404`.

A partner's link can open any page of your site: `https://boltclaw.ai/r/<code>?to=/pricing`
goes to your product URL's host plus `/pricing`, and `&host=docs.example.com` to another
of your hosts. Those other hosts are the program's `allowed_hosts`, each used only once
you prove it is yours with the TXT record shown here (`_boltclaw.<host>` with the value
`bc-verify=<token>`), then `POST /referrals/programs/{id}/link-hosts/check`. Anything
else falls back to the product URL: a link is never an open redirect.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `LinkHostList` | The hosts. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/link-hosts/check`

**Check a site's TXT record now**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Looks up `_boltclaw.<host>` now for one of the program's allowed hosts; when it holds the
value shown, links may go to that host. A check that finds the record gone takes that
away again. At most once every 10 seconds per host.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `host` | `string` | One of the program's allowed hosts. (**required**; max 300) |

```json
{
  "host": "<host>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `LinkHostList` | The hosts, after the check. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/leaderboard`

**Read a program's leaderboard**

Scope `referrals:read`. Another workspace's answers `404`.

Every promoter with something counted, ranked by the program's leaderboard metric over its
period (or the `metric` and `period` you ask for), with each one's membership id, name and,
for one of your own users, your `external_id`. This is your view: promoters see only the
first places, each named as its promoter chose (hidden, initials or their name), and money
only when the board shows amounts. What counts is counted activity: a sale no person or
rule holds, not refunded in full, not disputed; on a waitlist, confirmed friends. Counted
again at most every 10 minutes. See [Campaigns](https://boltclaw.ai/docs/referral-campaigns).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `metric` | query | `BoardMetric` | What to rank by. Left out, the program's own setting. (one of `customers`, `sales`, `revenue`, `friends`) |
| `period` | query | `BoardPeriod` | Over which window. Left out, the program's own setting. (one of `month`, `30d`, `quarter`, `all`) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Leaderboard` | The leaderboard. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/contests`

**List a program's contests**

Scope `referrals:read`. Another workspace's answers `404`.

A program's contests, newest first, each with its prizes and what it is now (`scheduled`,
`running`, `ended`, `awarded` or `cancelled`; the clock decides, not a job).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ContestList` | The contests. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/contests`

**Create a contest**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A window (up to a year), a metric as a leaderboard's, who may enter (every promoter, or one
group or level), a minimum to qualify, and prizes by place: cash (paid as a bonus by hand)
or a reward that is not cash (credit, a gift card, a free period or a perk). Standings count
only what happens inside the window. Nothing is paid until a person awards the prizes after
it ends. At most 10 contests a program may be scheduled or running at once.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `description` | `string \| null` | (max 1000) |
| `starts_at` | `date-time` | (**required**) |
| `ends_at` | `date-time` | At most 366 days after the start. (**required**) |
| `metric` | `BoardMetric` | `customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed. (one of `customers`, `sales`, `revenue`, `friends`) |
| `group_id` | `uuid \| null` |  |
| `prizes` | `ContestPrizeInput[]` | (**required**; <= 20 items; >= 1 items) |
| `minimum` | `integer` | (>= 0) |
| `name` | `string` | (**required**; min 1; max 80) |

```json
{
  "starts_at": "2026-03-04T17:02:10.885210+00:00",
  "ends_at": "2026-03-04T17:02:10.885210+00:00",
  "prizes": [],
  "name": "<name>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Contest` | The contest. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/contests/{id}`

**Read a contest and its standings**

Scope `referrals:read`. Another workspace's answers `404`.

The contest, its standings (each promoter's place among those who reached the minimum,
whether they are under review, and the prize the place wins as things stand) and whether
the prizes may be awarded now (`award.possible`), and if not, why and from when.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ContestDetail` | The contest. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/contests/{id}`

**Change a contest**

Scope `referrals:write`. Another workspace's answers `404`.

Before it starts, anything. Once it has started, only its name and description (`409` for
anything else): its window, what it counts, who may enter, its minimum and its prizes stay
as promoters saw them. Once it is awarded or cancelled, nothing (`409`).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `description` | `string \| null` | (max 1000) |
| `starts_at` | `date-time` |  |
| `ends_at` | `date-time` | At most 366 days after the start. |
| `metric` | `BoardMetric` | `customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed. (one of `customers`, `sales`, `revenue`, `friends`) |
| `group_id` | `uuid \| null` |  |
| `prizes` | `ContestPrizeInput[]` | (<= 20 items; >= 1 items) |
| `minimum` | `integer` | (>= 0) |
| `name` | `string` | (min 1; max 80) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Contest` | The contest. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/contests/{id}/cancel`

**Cancel a contest**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Ends a contest that was not awarded, with a reason promoters in it are shown. No prize is
paid. An AI assistant always asks a person first. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `reason` | `string` | (**required**; min 3; max 500) |

```json
{
  "reason": "<reason>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Contest` | The contest, cancelled. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/contests/{id}/award`

**Award a contest's prizes**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Gives each winning place its prize, once: a cash prize as a bonus paid by hand ("Contest:
<name>, 1st place"), approved at once and paid with the next payout; any other as a reward
ready to give. Refused (`409`, with the reason) before the contest ends, while any winner is
under review, and until the last hold on what the winners counted has passed
(`award.awardable_at` on the contest). BoltClaw never awards by itself. Needs the
`referrals.payouts` permission. A key that is not an AI assistant's must also hold the
`referrals:payouts` scope (`403 INSUFFICIENT_SCOPE` otherwise), as an import with opening
balances must; an AI assistant always asks a person first, and the person who approves may
not be one of the winners. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ContestAward` | The contest, awarded, and what each place was given. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/entries`

**List a waitlist's entries**

Scope `referrals:read`. Another workspace's answers `404`.

A waitlist program's line, in place order: each entry's place (null until they confirm),
confirmed friends, whether they are disqualified and why, and who referred them. Places move
as friends confirm, so a page is the line as it was when read. No address is ever in an
entry: people who joined on the hosted page are exported to CSV in the dashboard only.
`filter` is `all` (default), `confirmed`, `unconfirmed`, `disqualified` or `invited`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `filter` | query | `string` | (one of `all`, `confirmed`, `unconfirmed`, `disqualified`, `invited`) |
| `limit` | query | `integer` | Page size, 1 to 200. Above 200 is clamped; anything else is the default. (default `50`; >= 1; <= 200) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistEntryList` | A page of entries. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/entries`

**Add a person to a waitlist, or update them**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Your own sign-up form's call: your id for the person (`external_id`), the referral code
they arrived with if any, and later `verified: true` once they confirmed their address
(that cannot be taken back). `201` for a new entry, `200` for an existing one. An entry
counts toward the organisation's referrers, never its partners. An `email`, when you send
one, is kept only as a keyed fingerprint for the self-referral check: never stored or
returned. A key may create at most a few hundred new entries an hour (`429` past it).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `external_id` | `string` | (**required**; min 1; max 200) |
| `name` | `string \| null` | (max 200) |
| `email` | `string` | Kept only as a keyed fingerprint for the self-referral check. (max 320) |
| `code` | `string` | A vanity code for their own link, on creation only. (max 40) |
| `referral_code` | `string` | The code of the link they arrived with. (max 40) |
| `verified` | `boolean` |  |
| `status` | `WaitlistEntryStatus` | (one of `waiting`, `invited`, `joined`) |

```json
{
  "external_id": "<external_id>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistEntry` | The entry, updated. |
| 201 | `WaitlistEntry` | The entry, new. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/entries/{id}`

**Read a waitlist entry**

Scope `referrals:read`. Another workspace's answers `404`.

One entry, with the person's own view of their place (`state`), as the in-app panel shows it to them.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistEntryDetail` | The entry. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/entries/{id}`

**Confirm an entry, or set its status**

Scope `referrals:write`. Another workspace's answers `404`.

`verified: true` once the person confirmed their address (it moves the friend who referred
them up; it cannot be taken back); `status` is `waiting`, `invited` or `joined`, for your own
record. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `verified` | `boolean` |  |
| `status` | `WaitlistEntryStatus` | (one of `waiting`, `invited`, `joined`) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistEntry` | The entry. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/entries/{id}/disqualify`

**Disqualify a waitlist entry**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Takes the entry out of the line (it keeps its row) and stops it moving anybody up, with a
reason the person never sees. An AI assistant always asks a person first. Recorded in the
activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `reason` | `string` | (**required**; min 3; max 500) |

```json
{
  "reason": "<reason>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistEntry` | The entry. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/entries/{id}/requalify`

**Put a disqualified entry back in line**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Undoes a disqualification, with a reason. The entry takes its place by its join order again. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `reason` | `string` | (**required**; min 3; max 500) |

```json
{
  "reason": "<reason>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistEntry` | The entry. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/entries/invite`

**Invite waitlist entries**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Marks up to 500 entries `invited` and emails the ones who joined on the hosted page your
message (a subject of one line, at most 140 characters, and a body in the broadcasts'
Markdown subset). Their addresses never leave BoltClaw. An entry from your own form is
marked only, for your own system to email. An AI assistant always asks a person first.
Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `entry_ids` | `uuid[]` | (**required**; <= 500 items; >= 1 items) |
| `subject` | `string` | One line. (**required**; min 1; max 140) |
| `message` | `string` | The broadcasts' Markdown subset: paragraphs, **bold**, *italic*, lists and https links. (**required**; max 6000) |

```json
{
  "entry_ids": "3f8c1e42-9b7a-4d61-a0f5-2c9e1b7d4a83",
  "subject": "<subject>",
  "message": "<message>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistInvited` | How many were invited and emailed. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/second-tier`

**Read a program's second tier**

Scope `referrals:read`. Another workspace's answers `404`.

Whether a partner earns a share of what the partners they recruit earn, and how much: at
most 20% of the recruit's own cash commission on sales, for 1 to 24 months after the
recruit joined, one level only, never on a partner's own purchases. With the clause the
terms carry while it is on, and why it is fenced. See [Two-tier](https://boltclaw.ai/docs/referral-campaigns#two-tier).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `SecondTierSettings` | The second tier. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/programs/{id}/second-tier`

**Switch a program's second tier on or off**

Scope `referrals:write`. Another workspace's answers `404`.

`enabled: true` with `percent_bps` (1 to 2000) and `months` (1 to 24) pays a recruiter that
share of each recruit's cash commission on sales, from the next sale on; the clause is added
to the program's terms and every approved partner is asked to accept the new version before
their next payout. `enabled: false` stops it (what was earned stays). Off by default.
Switching it on is always asked first for an AI assistant. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `enabled` | `boolean` | (**required**) |
| `percent_bps` | `integer` | (>= 1; <= 2000) |
| `months` | `integer \| null` | (>= 1; <= 24) |

```json
{
  "enabled": true
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `SecondTierSettings` | The second tier. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/waitlist/end`

**End a waitlist**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Ends the waitlist for good: nobody new can join, through the hosted page or through
`POST /referrals/programs/{id}/entries`, and everybody already in keeps their place and
may still be updated or invited. The addresses the hosted page holds are deleted 90 days
after the end (`POST /referrals/programs/{id}/waitlist/forget` deletes them now); once an
address is gone, its entrant's own link stops working. Ending again keeps the first end.
A program that is not a waitlist is `409`. To stop new entries for a while instead, set
`waitlist.closed` on the program. An AI assistant always asks a person first. Recorded in
the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistEnded` | When the waitlist ended (`forgotten` is 0). |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/waitlist/forget`

**Delete a waitlist's addresses now**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Deletes every address the waitlist's hosted page holds, and every entrant's own link, now,
and ends the waitlist if it had not ended (as `POST /referrals/programs/{id}/waitlist/end`
does). Entries stay in the line by your own `external_id`; nothing can be emailed to them
any more. It cannot be undone. A program that is not a waitlist is `409`. An AI assistant
always asks a person first. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistEnded` | When the waitlist ended, and how many entries' addresses were deleted by this call. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/waitlist/rotate-page`

**Give the hosted waitlist page a new address**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Makes a new random address for the waitlist's hosted page and stops the old one at once,
for a page whose address was posted somewhere it should not be or is being flooded.
Referral links (`/r/<code>`) keep working: they always send people to the current address.
Update the address wherever you published it. An AI assistant always asks a person first: a
new address stops the one already published.
Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `WaitlistPageAddress` | The page's new address. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/codes`

**List a program's discount codes**

Scope `referrals:read`. Another workspace's answers `404`.

Every partner discount code of the program, newest first (up to 500, in one page), and the
discount a friend gets with one (`friend_discount`, from the program's friend's discount
rule, or null when it gives none). A shop's own system reads it to keep its coupons in step:
Bolt Claw's WooCommerce plugin reads it every hour. A code that is not `active` was switched
off: switch it off in your shop too. `stripe_promotion_code` and `shopify_discount` are the
code in your own Stripe or in the program's linked Shopify shop, once it is one there.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `DiscountCodeList` | The codes, and the friend's discount. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/invites`

**List a program's invites**

Scope `referrals:read`. Another workspace's answers `404`.

Every partner invite of the program and whether it is still usable, newest first, a page
at a time. An invite's link is never shown again here: whoever holds it may join, so it is
shown once, when the invite is made.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `InviteSummaryList` | The invites, without their links. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/invites`

**Create an invite to a program**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A link that lets one person (or up to `max_uses` people) join the program as a partner,
optionally until `expires_at` (within a year). In an invite-only program it is the only
way in. Bolt Claw emails nobody: send the link from your own system, a Zap or a message.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (optional)

| Field | Type |  |
|---|---|---|
| `max_uses` | `integer` | How many people may join with it. 1 by default. (>= 1; <= 100000) |
| `expires_at` | `date-time \| null` | Within a year. |

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Invite` | The invite, with its link. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/shopify-codes`

**Create the program's codes in its Shopify shop**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Keeps every partner code of the program in step with the Shopify shop linked to it through
Bolt Claw's Shopify app: an active code becomes a discount in the shop, at the friend's
discount (percent or amount off, once per customer, or for that many months of a
subscription), and a code switched off in Bolt Claw is switched off in the shop. A code the
shop already has with the same text is matched, not made again; running this twice makes
nothing twice. At most 50 codes a call: `remaining` says how many wait for the next one.
With no linked shop, or no friend's discount rule, nothing is made and `reason` says why.
Linking a shop is done from the shop's own Shopify admin, never through this API. Recorded
in the activity log. An AI assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ShopifyCodesResult` | What was made, matched and switched off. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/privacy-requests`

**List a program's privacy requests**

Scope `referrals:read`. Another workspace's answers `404`.

What Shopify's privacy webhooks asked of this program, newest first (up to 200, in one
page): a customer asking for their data (`data_request`, waiting for you to answer it within
30 days of `received_at`, so `due_by`), and the erasures Bolt Claw did when Shopify asked
(`customer_redact`, `shop_redact`), with counts only. Read a data request to see what Bolt
Claw holds, give it to your customer, then mark it answered.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PrivacyRequestList` | The requests. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/privacy-requests/{id}`

**Read a privacy request**

Scope `referrals:read`. Another workspace's answers `404`.

One request, and for a data request still waiting, everything Bolt Claw holds about the
customer it named in this program (`data`): your customer id, who referred them, their
sales, refunds and rewards, and the referral link they were given. Bolt Claw holds no name,
email address, postal address or phone number, and `data.note` says so in words you can pass
on. Each read of `data` is recorded in the activity log.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PrivacyRequestDetail` | The request, and what Bolt Claw holds. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/privacy-requests/{id}/answer`

**Mark a privacy request answered**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Says you gave your customer what Bolt Claw holds about them. The Shopify customer and order
ids the request named are forgotten in the same step, and the request reads `answered`.
Only a waiting data request is answered (`400` otherwise). Recorded in the activity log.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PrivacyRequest` | The request, answered. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/data-subjects/{external_id}`

**Look up what is held about one of your people**

Scope `referrals:read`. Another workspace's answers `404`.

Answers a privacy request from one of your own customers or in-app referrers: everything
Bolt Claw holds about them in this workspace, by the id your system uses for them (the
`external_id` you sent with their conversions or when you made them a referrer, or the
payment processor's customer id). For a customer: each program they were referred in, their
conversions and the rewards they were given. For a referrer: their display name, status,
links, commissions and rewards. An email fingerprint is shown only as held or not, never its
value; Bolt Claw holds no name, email address, postal address or phone number of a customer.
Each lookup is recorded in the activity log, with counts only. Nobody with that id is `404`.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `external_id` | path | `string` | The id your system uses for the person: the `external_id` you sent with their conversions or when you made them a referrer, or the payment processor's customer id. Percent-encode it in the path. An id nobody in this workspace has is `404`. The decoded id is at most 255 characters; the encoded segment may be longer. (**required**; min 1; max 2300) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `DataSubject` | Everything held about the person in this workspace. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `DELETE /v1/referrals/data-subjects/{external_id}`

**Erase one of your people's referral data**

Scope `referrals:write`. Another workspace's answers `404`.

Erases a customer's or an in-app referrer's referral data at their request, in every program
of this workspace. Their ids and fingerprints are removed and cannot come back; amounts,
commissions and payouts stay as anonymous rows, because your books need them. A referrer's
name, payout details and panel sessions go and their links stop counting. Copies of webhook
bodies that carried the id are scrubbed. Recorded in the activity log, with counts only. An AI
assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `external_id` | path | `string` | The id your system uses for the person: the `external_id` you sent with their conversions or when you made them a referrer, or the payment processor's customer id. Percent-encode it in the path. An id nobody in this workspace has is `404`. The decoded id is at most 255 characters; the encoded segment may be longer. (**required**; min 1; max 2300) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `DataSubjectErased` | Erased, with what was erased counted. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/terms/draft`

**Draft program terms**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Writes the program's terms from Bolt Claw's template: you answer a few questions (your
business's legal name and contact address, who may join, the minimum age, countries left
out, how many months money held for a missing tax form waits before it may be forfeited, the
governing law and courts) and every other fact is read from the program's own settings: the
cookie window, attribution, the hold, the minimum payout, the schedule, tax forms, invoices,
self-billing, the second tier, levels and the disclosure line. Nothing is saved. To use the
text, send it as `terms` to `PATCH /v1/referrals/programs/{id}`. The text ends with a notice
that it is based on a template and is not legal advice. See [Program terms](https://boltclaw.ai/docs/referral-terms).

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `answers` | `object` | (**required**) |

```json
{
  "answers": {}
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `TermsDraft` | The drafted terms, and the settings they were read from. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/export`

**Export a report as a CSV download link**

Scope `referrals:read`. Another workspace's answers `404`.

A single-use link to one of the program's reports as a CSV file, valid for ten minutes:
`commissions` (each with the sentence saying why it is that amount), `conversions`,
`partners` (partners and your own in-app referrers; an email only when the partner chose to
share it with you), `payouts`, `grants` (rewards that are not cash) or `contests` (each
contest's standings and prizes). Filter by `from` and `to` (a date like `2026-10-01`, or an
instant) and by `status` (for conversions, the event). The file is never in the response:
open `url` within ten minutes, once. It is built when it is opened, from what is true then,
and only if the key that asked is still live and its creator may still read the report.

At most 25,000 rows a file (narrow the dates for more; a file cut there says so in its
name), and 60 links an hour for each key (a platform setting; the dashboard counts each
person apart, so an assistant never uses up yours). Recorded in the activity log with the
filters, never a row.

Needs `referrals:read`. The `payouts` report also needs `referrals:payouts` (which no AI
assistant holds) and the permission to pay partners.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `type` | query | `string` | (**required**; one of `commissions`, `conversions`, `partners`, `payouts`, `grants`, `contests`) |
| `from` | query | `string` | Rows at or after this date (`2026-10-01`) or instant. (max 40) |
| `to` | query | `string` | Rows before this date or instant. (max 40) |
| `status` | query | `string` | One status of that report's rows: a commission's, a partner's, a payout's, a grant's or a contest's; for `conversions`, the event (`lead`, `sale`, `refund`, `dispute`, `bonus`). (max 20) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReportLink` | The link, once. Only its fingerprint is kept. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/pause`

**Pause a program**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Stops a program taking new clicks, signups and sales: its links still open your site, but
nothing new is credited until it is resumed. What partners already earned is kept, and
payouts of it go on. A stop, so it is never asked first and keeps working during
maintenance.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Program` | The program, paused. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/resume`

**Resume a program**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Starts a paused or archived program again: its links credit clicks, signups and sales from
now on. Nothing that happened while it was paused is credited afterwards. An AI assistant
resumes a paused program at once; bringing back an archived one asks a person first
(archiving is a person's decision, and so is undoing it).

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Program` | The program, active. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/archive`

**Archive a program**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Ends a program: it is frozen, its links stop crediting anything, partners can no longer
join, and it no longer counts against your plan's programs. What partners earned is kept
and can still be paid. Resuming it starts it again. An AI assistant always asks a person
first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Program` | The program, archived. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/signup-link/rotate`

**Give a program a new signup link**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Makes a new signup link for the program and stops the old one at once, for a link that was
posted somewhere it should not be. Partners already in the program, and their referral
links, are not affected; invites stay valid. An AI assistant always asks a person first: a
new link stops the one already shared.
Recorded in the activity log.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Program` | The program, with its new signup link. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/partners/{id}/ban`

**Ban a partner**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Shuts a partner out of the program: their links and codes stop crediting, and what they are
owed and not yet paid is not paid. Only a person decides this; a fraud rule never bans
anybody. An AI assistant always asks a person first. Recorded in the activity log.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Partner` | The partner, banned. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/partners/{id}/unban`

**Lift a partner's ban**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Lets a banned partner back into the program, approved. Their links and codes credit again
from now on. An AI assistant always asks a person first, as for approving a partner; the
partner themselves can never be the person who decides. Recorded in the activity log.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Partner` | The partner, approved again. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/partners/{id}/codes`

**Give a partner a discount code**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A discount code that credits this partner when a customer types it at checkout. Leave
`code` out for the program's suggestion (its code prefix and the partner's name). For
approved partners only; a code is unique in the program, ignoring case. When the program
has a friend's discount and your Stripe is connected, the code is also made as a
promotion code in your Stripe, and in a Shopify shop linked to the program; otherwise it
only tells BoltClaw which partner a code belongs to. An AI assistant always asks a person
first (never the partner it is for): a code changes what customers pay and who is paid.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (optional)

| Field | Type |  |
|---|---|---|
| `code` | `string \| null` | 3 to 40 letters, digits and hyphens. Left out, the program's suggestion. (max 64) |

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `DiscountCode` | The code. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/codes/{id}`

**Link a code to a Stripe promotion code**

Scope `referrals:write`. Another workspace's answers `404`.

Tells BoltClaw which promotion code in your own Stripe this code is (`promo_…`), so a sale
where a customer typed it at Stripe Checkout is credited even before Stripe's own events
taught BoltClaw. `null` forgets it. Usually unnecessary: BoltClaw learns it from Stripe's
promotion-code events. An AI assistant always asks a person first, and the approval page
names the promotion code: every sale that uses it is credited to this partner, your own
campaign codes included.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `stripe_promotion_code` | `string \| null` | `promo_…` in your own Stripe, or null to forget it. (**required**; max 255) |

```json
{
  "stripe_promotion_code": "<stripe_promotion_code>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `DiscountCode` | The code. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/codes/{id}/deactivate`

**Switch a discount code off**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Stops a code crediting its partner: a sale where a customer types it is no longer
attributed. What it earned before is kept. A code switched off stays off; give the partner a
new one instead. A stop, so it is never asked first and keeps working during maintenance.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `DiscountCode` | The code, switched off. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/links`

**List a program's referral links**

Scope `referrals:read`. Another workspace's answers `404`.

Every referral link of the program (`/r/<code>`), or one partner's with `partner_id`: the
partner's main link and the links BoltClaw placed for them (a bio block, a post, an
automation, shown by type only), archived ones included. Newest first, a page at a time.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `partner_id` | query | `uuid` |  |
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferralLinkList` | The links. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/links/{id}/archive`

**Archive a referral link**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Stops one of a partner's links crediting new clicks (it still opens your site). What it
earned is kept. An AI assistant asks a person first unless your organisation lets
assistants archive links without asking.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferralLink` | The link, archived. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/invites/{id}/revoke`

**Revoke an invite**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Stops an invite letting anyone join. People who already joined with it stay. A stop, so it
is never asked first and keeps working during maintenance.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `InviteSummary` | The invite, revoked, without its link. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/commissions/{id}/approve`

**Approve a commission before its hold ends**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Makes a pending commission payable now, before the program's refund window ends, with your
reason. A refund after this still takes it back from the next payout. Not for a commission
a fraud flag is holding: decide the flag instead. The permission to pay partners is
needed; a key that is not an AI assistant's also needs `referrals:payouts`, and an AI
assistant always asks a person first (never the partner it pays). Recorded in the activity
log with the reason.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `reason` | `string` | Why it is paid before its hold ends. Kept in the activity log. (**required**; max 500) |

```json
{
  "reason": "<reason>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Commission` | The commission, approved. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/payouts/{id}/cancel`

**Cancel a payout**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Takes an unpaid payout out of its batch, as `cancelled` (or `failed`, when you tried to pay
it and the money did not arrive). Its commissions go back to Ready to pay, so nothing a
partner earned is lost. A payout a provider is still handling is `409` until the provider
answers. The permission to pay partners is needed. Cancelling a draft is a stop. Marking
one `failed`, or cancelling one that was exported or sent, lets its commissions be paid
again (it may already have been paid by hand): an AI assistant asks a person first, who
decides at a two-step session and is never the partner it pays, and a key needs
`referrals:payouts` for it. Recorded in the activity log.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (optional)

| Field | Type |  |
|---|---|---|
| `status` | `string` | `cancelled` (the default), or `failed` when you tried to pay it and the money did not arrive. (one of `cancelled`, `failed`) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Payout` | The payout. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/levels/recalculate`

**Recalculate partners' levels now**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Moves the program's partners to the highest level they qualify for, now, instead of at the
daily run: never down past a level that keeps what was earned, and never a partner you
pinned. Answers how many moved. Each move is recorded in the activity log.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `LevelRecalculation` | How many partners moved. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/rewards`

**List a program's reward rules**

Scope `referrals:read`. Another workspace's answers `404`.

Every reward rule of the program: for everyone, for a group or level, and for single
partners, with the conditions each applies under and a one-sentence `summary`. See
[Reward rules](https://boltclaw.ai/docs/rewards) for how one rule is picked for each sale.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `RewardList` | The program's rules. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/rewards`

**Add a reward rule**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A rule for everyone (`scope` `program`), a group or level, or one partner, with conditions
on a sale's products, prices, plans, intervals, first payment or renewal, currency and
amount. Each audience may have one rule with no conditions per event (`409` for a second).
A `bonus` is a flat amount paid once per window when a partner crosses its `milestone`.
A program holds at most 200 rules. The new rule applies to sales from now on.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `event` | `string` | (**required**; one of `lead`, `sale`, `bonus`) |
| `scope` | `string` | `program` (everyone) unless you say otherwise. (one of `program`, `group`, `partner`) |
| `group_id` | `uuid` | With `scope` `group`. |
| `partner_id` | `uuid` | With `scope` `partner`: one partner's own rate. |
| `kind` | `string` | (one of `percent`, `flat`) |
| `percent_bps` | `integer \| null` | 1 to 10000, for `percent`. |
| `amount_minor` | `integer \| null` | For `flat`; a bonus's must be above 0. |
| `duration` | `string` | Ignored for a bonus (one of `once`, `months`, `lifetime`) |
| `months` | `integer \| null` | 1 to 120, for `months`. |
| `cap_minor` | `integer \| null` | The most it pays for one customer; null for no ceiling. |
| `conditions` | `RewardConditions` | When a rule applies. Every key you send must match; a rule with none is its audience's default for its event. A sale without `items` matches no rule that names `products`, `prices`, `plans` or `intervals`. A signup takes none; a bonus takes exactly `milestone`. |
| `priority` | `integer` | -1000 to 1000; 0 by default. |
| `label` | `string \| null` | At most 60 characters after trimming. (max 200) |
| `reward_type` | `RewardType` | `cash` when left out. Fixed once the rule exists. |
| `beneficiary` | `string` | `promoter` when left out. Fixed once the rule exists. (one of `promoter`, `referee`) |
| `period_count` | `integer \| null` | For `free_period`: 1 to 365 days, or 1 to 36 months. |
| `period_unit` | `string \| null` | For `free_period`. (one of `day`, `month`, `null`) |
| `perk_text` | `string \| null` | For `perk`: at most 120 characters after trimming. (max 400) |
| `discount_duration` | `string \| null` | For `discount`: how long it lasts. (one of `once`, `months`, `forever`, `null`) |
| `discount_months` | `integer \| null` | For a `discount` of `months`: 1 to 36. |

```json
{
  "event": "lead"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Reward` | The rule. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/rewards/{id}`

**Update a reward rule**

Scope `referrals:write`. Another workspace's answers `404`.

Changes the fields you send, in place. A rule's event and audience are fixed: add a new
rule for a different one. What was already earned is a stored amount and never changes;
the new terms apply to sales from now on.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `kind` | `string` | (one of `percent`, `flat`) |
| `percent_bps` | `integer \| null` |  |
| `amount_minor` | `integer \| null` |  |
| `duration` | `string` | (one of `once`, `months`, `lifetime`) |
| `months` | `integer \| null` |  |
| `cap_minor` | `integer \| null` |  |
| `conditions` | `RewardConditions` | When a rule applies. Every key you send must match; a rule with none is its audience's default for its event. A sale without `items` matches no rule that names `products`, `prices`, `plans` or `intervals`. A signup takes none; a bonus takes exactly `milestone`. |
| `priority` | `integer` |  |
| `label` | `string \| null` | (max 200) |
| `period_count` | `integer \| null` |  |
| `period_unit` | `string \| null` | (one of `day`, `month`, `null`) |
| `perk_text` | `string \| null` | (max 400) |
| `discount_duration` | `string \| null` | (one of `once`, `months`, `forever`, `null`) |
| `discount_months` | `integer \| null` |  |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Reward` | The rule. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `DELETE /v1/referrals/rewards/{id}`

**Delete a reward rule**

Scope `referrals:write`. Another workspace's answers `404`.

Removes the rule. What it already earned stays earned; it stops paying from now on, and
the next most specific rule pays instead. An AI assistant's key is always asked first.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferralDeleted` | Deleted. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/groups`

**List a program's groups and levels**

Scope `referrals:read`. Another workspace's answers `404`.

Plain groups you place partners in, and levels: groups with a rank and a minimum that the
daily level job moves partners into. Each with how many partners are in it now.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `GroupList` | The program's groups and levels. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/groups`

**Add a group or level**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

A plain group (no `rank`), or a level (`rank` 1 to 100, and above the first a `minimum`).
Give it its own rates with `POST /referrals/programs/{id}/rewards` and `scope` `group`.
Names and ranks are unique in a program; a program holds at most 50 groups and levels.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `name` | `string` | 1 to 60 characters after trimming; unique in the program. (**required**; max 200) |
| `description` | `string \| null` | (max 1000) |
| `is_default` | `boolean` |  |
| `rank` | `integer \| null` | 1 to 100 makes it a level; null for a plain group. |
| `minimum` | `LevelMinimum` | What a partner needs to reach a level: `revenue` in `amount_minor`, or `sales` or `customers` in `count`, over the last `window_days` (30 to 365), or all time when null. Counted net of refunds. Null: everyone qualifies. |
| `keep_earned` | `boolean` |  |

```json
{
  "name": "<name>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Group` | The group. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/groups/{id}`

**Update a group or level**

Scope `referrals:write`. Another workspace's answers `404`.

Changes the fields you send. A minimum is only for a level; a group without a rank has none.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `name` | `string` | (max 200) |
| `description` | `string \| null` | (max 1000) |
| `is_default` | `boolean` |  |
| `rank` | `integer \| null` |  |
| `minimum` | `LevelMinimum` | What a partner needs to reach a level: `revenue` in `amount_minor`, or `sales` or `customers` in `count`, over the last `window_days` (30 to 365), or all time when null. Counted net of refunds. Null: everyone qualifies. |
| `keep_earned` | `boolean` |  |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Group` | The group. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `DELETE /v1/referrals/groups/{id}`

**Delete a group or level**

Scope `referrals:write`. Another workspace's answers `404`.

Removes the group and its own rules. Its partners stay in the program, in no group, and
what they earned stays earned. An AI assistant's key is always asked first.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferralDeleted` | Deleted. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/partners/{id}`

**Move a partner to a group**

Scope `referrals:write`. Another workspace's answers `404`.

Puts the partner in a group or level of their program (`group_id`), or in none (`null`).
`pinned: true` keeps the daily level job from moving them again.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `group_id` | `uuid \| null` | A group or level of the partner's program; null for none. |
| `pinned` | `boolean` | True keeps the daily level job from moving the partner. |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Partner` | The partner. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/partners/{id}/bonuses`

**Pay a partner a bonus**

Scope `referrals:write`. `Idempotency-Key` is **required**. Another workspace's answers `404`.

A flat fee or one-off bonus by hand ("200.00 for the launch video"), in the program's
currency, with a reason the partner reads. It holds for `hold_days` (0 by default, so it
is ready to pay at once), then is paid like any commission. Only an approved partner can
be paid one. Recorded in the activity log, and announced as `referral.bonus.created`.
An AI assistant's key is always asked first.

`Idempotency-Key` is required: a retried network timeout must never pay a partner twice.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `amount_minor` | `integer` | Minor units of the program's currency. (**required**; >= 1) |
| `reason` | `string` | The partner sees it as the line's description. Recorded in the activity log. (**required**; min 1; max 280) |
| `hold_days` | `integer` | Days before it may be paid. 0 (the default) makes it payable at once. (>= 0; <= 365) |

```json
{
  "amount_minor": 1,
  "reason": "<reason>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Commission` | The bonus, as a commission. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/embed-tokens`

**Create a referral panel session for one of your users**

Scope `referrals:embed`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Call this from YOUR server when a signed-in user opens the page that shows their referrals.
It makes the user a referrer in the program if they are not one yet (by the `external_id`
you send, which is your own id for that user), and returns a short-lived token your page
hands to the Bolt Claw panel: `BoltClawReferrals.mount(element, { token, embedKey })`. See
[Referral panel](https://boltclaw.ai/docs/referral-panel).

The token is shown once, in this response, and is the only thing that ever goes to the
browser. It works for that one referrer in that one program, lives `ttl_seconds` (60 to
86400, default 3600), and ends early if you call `POST /referrals/programs/{id}/embed/revoke-sessions`
or the referrer is erased. A replay of the same `Idempotency-Key` returns `token: null`:
mint a new one instead.

Send `mode: "preview"` and no `referrer` for a session that shows sample data, to try your
look and copy before launch.

Needs `referrals:embed` (the "Referral panel" key preset), or `referrals:write`. Never put
this key in a browser or a mobile app, and an AI assistant's key can never mint a session.
The panel must be switched on for the program (`embed_enabled`), or the answer is `409`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `referrer` | `object` | The user the session is for; they are made a referrer if they are not one. Leave it out only for `mode` `preview`. |
| `ttl_seconds` | `integer` | How long the token works, in seconds. (default `3600`; >= 60; <= 86400) |
| `mode` | `string` | `panel` for a real referrer; `preview` shows sample data and needs no referrer. (default `panel`; one of `panel`, `preview`) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `EmbedTokenCreated` | The session. `token` is shown here and nowhere else. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/referrers`

**List a program's referrers**

Scope `referrals:read`. Another workspace's answers `404`.

The people you gave a referral link through the panel or the API, newest first, with what
each link has done. A referrer is one of YOUR users; they have no Bolt Claw account and
are not counted as partners. `search` matches the start of your `external_id`.

Needs `referrals:read`, or `referrals:embed`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `status` | query | `ReferrerStatus` | `approved` earns rewards. `pending` is waiting for you (when the program does not approve new referrers at once, `embed.approve_referrers`); it can share its link and earns nothing yet. `banned` earns nothing; an erased referrer is also `banned`. (one of `approved`, `pending`, `banned`) |
| `search` | query | `string` | The start of the `external_id`, at most 200 characters. (max 200) |
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferrerList` | A page of referrers. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/referrers`

**Create or update a referrer**

Scope `referrals:embed`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Makes one of your users a referrer, or updates the one your `external_id` already names:
`201` when it was created, `200` when it already existed. Only the fields you send change;
send `null` to clear one. The `email`, if you send one, is only used to recognise the
same person again and is never stored, returned or shown to anyone. A new referrer is
announced as `referral.referrer.created`.

`code` is the vanity part of their link (`/r/jane`), 3 to 40 lowercase letters and digits
with single hyphens, chosen only when the referrer is created: after that it is already
in links they have shared, and sending one is `409`. `409` also when the code is taken.

You do not need this before minting a panel session, which creates the referrer itself;
use it to create referrers ahead of time or to keep their name current.

Needs `referrals:embed`, or `referrals:write`. A referrer past the plan's allowance is
`402`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `external_id` | `string` | Your own id for the user. Printable characters only, no space at either end. (**required**; min 1; max 200) |
| `name` | `string \| null` | A first name or display name for the panel's greeting, plain text. `null` clears it. (max 60) |
| `email` | `string \| null` | Only used to recognise the same person again (a fingerprint is kept, never the address). Never returned. (max 400) |
| `code` | `string \| null` | The vanity code for their link: 3 to 40 lowercase letters and digits, single hyphens between words. Only when the referrer is created. (max 40) |

```json
{
  "external_id": "<external_id>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferrerView` | The referrer already existed; the fields you sent are updated. |
| 201 | `ReferrerView` | The referrer was created. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/referrers/{external_id}`

**Read one referrer's panel**

Scope `referrals:read`. Another workspace's answers `404`.

Exactly what the panel shows this referrer, as data: their link, the offer, their numbers,
a history row for each friend (up to 50, newest first) with a stable `reason_code`, how
they are paid, and the share text. Use it to build your own screen. `{external_id}` is
your own id for the user and must be percent-encoded.

The View has no names, emails or ids of your customers: a friend is "Friend 3". An erased
referrer, or one that does not exist, is `404`.

Needs `referrals:read`, or `referrals:embed`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `external_id` | path | `string` | Your own id for the user, as you sent it in `external_id`. It can hold any printable characters, so percent-encode it in the path (`user%2F1`). An id that is not one of your referrers is `404`. The decoded id is at most 200 characters; the encoded segment may be longer. (**required**; min 1; max 1800) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferrerPanelView` | The referrer's panel View. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/referrers/{id}`

**Approve or ban a referrer**

Scope `referrals:write`. Another workspace's answers `404`.

Sets a referrer's `status` to `approved` or `banned`. `{id}` is the referrer's `id` from
`ReferrerView`, not your `external_id`. A banned referrer earns nothing from then on and
keeps what was already earned; approving one again can be `402` if the plan's referrer
allowance is full. An erased referrer cannot be changed (`409`). An AI assistant bans one at
once; approving one asks a person first, as letting a banned partner back in does.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `status` | `string` | (**required**; one of `approved`, `banned`) |

```json
{
  "status": "approved"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferrerView` | The referrer. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `DELETE /v1/referrals/referrers/{id}`

**Erase a referrer**

Scope `referrals:write`. Another workspace's answers `404`.

Erases a person's referral data on their request. The name, the payout details, the
recognition of their address and their panel sessions are removed and cannot come back;
what they earned stays in the commission and payout records as an anonymous row, because
your books need it. Their link stops counting. `{id}` is the referrer's `id` from
`ReferrerView`. An AI assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferralDeleted` | Erased. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/embed/revoke-sessions`

**End every live panel session**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Every panel token minted for this program stops working at once, wherever it is held; each
open panel shows "This link to your referrals has expired" and asks your page for a new
one. Use it if a token may have leaked. Sessions you mint afterwards work as usual.
`embed.generation` in the program goes up by one.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Program` | The program, with its new `embed.generation`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/embed/rotate-key`

**Rotate the panel's public key**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Gives the program a new public `embed.key`. The key is not a secret (it is in your page
and in the panel's address) but it names the panel's settings, so rotating it makes every
install that still carries the old one stops showing the panel until you update `embedKey`
there. Sessions already minted are not ended by a rotation; to end those as well, call
`POST /referrals/programs/{id}/embed/revoke-sessions`. An AI assistant always asks a person
first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Program` | The program, with its new `embed.key` and `embed.panel_url`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/grants`

**List a program's rewards that are not cash**

Scope `referrals:read`. Another workspace's answers `404`.

The free months, credit, perks, gift cards and friend's discounts the program has given or
owes, newest first. Each says what it is (`summary`), whose it is (`recipient`: one of your
own users with a link by your `external_id`, an affiliate by their name in the program, or
the friend by your id for them), and where it stands.

A grant waits out the program's hold as `pending`, then is `ready`: give it in your own
system and say so with `POST /referrals/grants/{id}/fulfil`, or let Bolt Claw give it
through your Stripe if you set a restricted key. A refund or dispute of the sale cancels
one not yet given and marks one already given `reversed`. A friend's discount is recorded
as `fulfilled` with the sale, when the sale shows it was used.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `status` | query | `GrantStatus` | `pending` waits out the program's hold; `ready` is yours to give; `fulfilled` was given; `failed` could not be given yet; `cancelled` by a refund or dispute before it was given; `reversed` by one after. (one of `pending`, `ready`, `fulfilled`, `failed`, `cancelled`, `reversed`) |
| `type` | query | `GrantType` | (one of `credit`, `free_period`, `discount`, `perk`, `gift_card`) |
| `recipient` | query | `string` | `promoter` for the people who shared, `referee` for the friends. (one of `promoter`, `referee`) |
| `limit` | query | `integer` | Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default `50`; >= 1; <= 100) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `GrantList` | A page of grants. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/grants/{id}`

**Read one reward that is not cash**

Scope `referrals:read`. Another workspace's answers `404`.

One grant, as the list shows it. Read it from a `referral.grant.*` webhook event's
`grant_id` before you give it.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Grant` | The grant. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/grants/{id}/fulfil`

**Say a reward was given**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

You gave the reward in your own system (a month added, credit applied, a perk sent); this
records it with your `reference`, tells the person on their page and raises
`referral.grant.fulfilled`. Only a `ready` grant can be given: one still in its hold, or
cancelled, is `409`. Sending the same reference again answers the grant as it is and
changes nothing, so a retry is safe; a different reference for a grant already given is
`409`. Recorded in the activity log. An AI assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `reference` | `string` | Your reference for the reward you gave, like an order or credit note number. The same one again is safe. (**required**; min 1; max 200) |
| `note` | `string \| null` | A note for your own team. (max 500) |

```json
{
  "reference": "<reference>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Grant` | The grant, now `fulfilled`. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/grants/{id}/fail`

**Say a reward could not be given**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

The reward cannot be given as it stands (the account was closed, the perk is out of
stock): this records your `reason`, shown to you and not to the person, and leaves the
grant `failed`, so you can still give it later with `POST /referrals/grants/{id}/fulfil`.
A grant already given or cancelled is `409`. Recorded in the activity log. An AI
assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `reason` | `string` | Why it cannot be given. Shown to you (**required**; min 1; max 500) |

```json
{
  "reason": "<reason>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Grant` | The grant, now `failed`. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/grants/{id}/send`

**Give a gift card through your Tremendous**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Gives a ready gift card to the promoter who earned it, from YOUR Tremendous account, at
the email in their payout details, and records it fulfilled with Tremendous's order id.
The order's id is made from the grant's, so pressing again never buys a second card. A
friend's gift card cannot be sent this way (BoltClaw holds no address for a friend).
`202` means Tremendous did not answer in time: try again in ten minutes.

Needs `referrals:payouts`, or an AI assistant with a person's approval, given by someone
signed in with two-step verification who is not the promoter it pays. A grant whose
promoter or sale has an open fraud flag waits. Recorded in the activity log.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Grant` | The grant, given. |
| 202 | `Grant` | Tremendous did not answer in time; the grant waits. Try again in ten minutes. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/offers/{code}`

**Read the friend's offer for a referral link**

Scope `referrals:read`.

What a person who arrived through this link is offered, in the words the welcome shows
(`offer`), and whether THIS customer may have it at your checkout (`eligible`). Call it
from your server before you create a checkout: with `customer_id` (your own id for the
customer, when they have one) and `new_customer`, a customer who is the referrer
themselves, or was already referred by someone else, is not eligible, with a stable
`reason_code` and a sentence to show instead of an error.

`coupon` is the coupon Bolt Claw made for the friend's discount in your own Stripe (with a
restricted key), ready for `discounts: [{ coupon }]`; `offer.stripe.promotion_code` is the
referrer's own promotion code, when they have one.

A link that does not exist, is archived, or is another workspace's answers `200` with
`offer: null` and `reason_code: unknown_code`, exactly as one that never existed. `coupon`
is null whenever `eligible` is false.

Needs `referrals:read`, or `referrals:embed`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `code` | path | `string` | The code at the end of a referral link (`/r/jane`), lower case. (**required**; min 3; max 40) |
| `customer_id` | query | `string` | Your own id for the customer, at most 255 characters. (min 1; max 255) |
| `new_customer` | query | `string` | `false` when the customer has paid you before: the offer is for new customers. (one of `true`, `false`) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `OfferAnswer` | The offer and whether this customer may have it. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/customers/{external_id}/referral`

**Read who invited a customer**

Scope `referrals:read`. Another workspace's answers `404`.

For your own screens ("Invited by Jane"): whether this customer of yours arrived through
the program, when, and how (`link` or `discount_code`). `invited_by` is the inviter's
name only when they agreed to it being shown; otherwise null. A customer nobody referred
answers with every field null; one the program has never seen is `404`.

Needs `referrals:read`, or `referrals:embed`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `external_id` | path | `string` | Your own id for the customer, as you sent it in a conversion's `customer.id`. Percent-encode it in the path. The decoded id is at most 255 characters; the encoded segment may be longer. (**required**; min 1; max 2300) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `CustomerReferral` | The customer's referral. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/stripe-codes`

**Create the program's codes in Stripe**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Makes one coupon in your own Stripe from the friend's discount rule ("Referral: 20% off for
3 months"), then a promotion code for every active code of the program that Stripe does not
have yet, so a friend can type their referrer's code at Stripe Checkout. A code that
already exists in Stripe with the same text is linked, not made again; running this twice
makes nothing twice. Needs a restricted Stripe key, set in the dashboard (Integration tab);
without one, or without a friend's discount rule, the answer is `409`. Recorded in the
activity log. An AI assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `StripeCodesResult` | What was made and linked. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/integration-status`

**Read a program's live setup status**

Scope `referrals:read`. Another workspace's answers `404`.

Whether each part of the setup works, from what Bolt Claw has actually seen for this
program: the website tag, billing (any connected billing system, or sales through this
API), signups, sales, discount codes, the in-app panel and the friend's welcome. Each part
is `connected`, `warnings` (connected, with something to fix) or `not_connected`, with one
sentence saying what was seen and one saying what to do. A part the program's own setup
does not use is `skipped` and does not count toward `overall`. Test data never makes a part
green: only live events do.

`integrations` lists every connected billing system as its card shows it: which one, how it
proves its deliveries, when the last verified event arrived and how many were refused since.
No credential is ever returned, not even in part.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `IntegrationStatus` | The program's setup, part by part. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/integration-status/tag-check`

**Check the website tag on the program's product page**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Fetches the program's product URL once, from Bolt Claw's server, and looks for the website
tag (`/referrals/v2.js`, or the older `v1.js`) in a script tag, then updates the setup
status. Only `https`, only the program's own hosts, never a private address (checked after
the name is resolved, and again on every redirect), five seconds and 512 KB at most. A tag
your site adds with JavaScript is not visible to this check: open your site with the verify
link from the dashboard instead. A few checks per program an hour.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `TagCheck` | What the check found. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/test-sale`

**Send a test sale through the program**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Runs the real path with TEST data, step by step: the program's test partner ("Test
partner", made once) and its link, a click on it, a signup, a sale of 100.00 in the
program's currency with two items, then a partial refund of 25.00. Each step answers with
what it caused: the conversion with its commissions and grants, the friend's offer, and how
many webhook deliveries it queued for endpoints that receive test events.

Test data is never payable, never counts toward a limit or a total, emails nobody, and
reaches only webhook endpoints that switched test events on. `Clear test data` deletes it
all. The program must be active.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `TestSale` | Every step, in order. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `DELETE /v1/referrals/programs/{id}/test-data`

**Delete a program's test data**

Scope `referrals:write`. Another workspace's answers `404`.

Deletes every TEST row of the program: the conversions, commissions, rewards, customers and
clicks that test sales, `test: true` conversions, a test-mode Stripe or a sandbox recorded,
and the program's test partner with its link. Live data is never touched. Recorded in the
activity log. An AI assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `TestDataCleared` | How many of each were deleted. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/clicks`

**Record a click your own server saw**

Scope `conversions:write`. Accepts `Idempotency-Key`.

For a partner's link that opens straight on your own site (`?via=<code>`), reported by your
server, for example by the SDK's `referralsMiddleware()`. Send the link's code and what your
server saw: the visitor's IP address and user agent are hashed and classed exactly as a
click on `/r/<code>` is, and never stored. It counts toward the click allowance like any
click, and earns nothing by itself: a click is never a conversion. A robot is answered
`recorded: false` and stored nowhere; a code that is not one of your links is `404`.

Needs `conversions:write`, like reporting a conversion.

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `code` | `string` | The partner link's code (`?via=`). (**required**; min 1; max 64) |
| `ip` | `string` | The visitor's IP address (max 64) |
| `user_agent` | `string` | Classed as mobile (max 1024) |
| `referrer` | `string` | The referring page. Only its host is kept. (max 2048) |
| `country` | `string` | An ISO 3166 two-letter country code, if your edge sets one. (max 8) |
| `landing_path` | `string` | The path the visitor landed on. Not stored. (max 2048) |

```json
{
  "code": "<code>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ReferralClickRecorded` | Not stored (a robot, or the month's click allowance is used). |
| 201 | `ReferralClickRecorded` | Recorded. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/flags`

**List a program's review queue**

Scope `referrals:read`. Another workspace's answers `404`.

The flags waiting for a decision (or, with `status`, the ones already decided), newest
first, up to 200. A flag is raised by a rule, never decided by one: the risk engine only
ever HOLDS (a longer hold on what a conversion earned, or `risk_review` for a person to
look at), and nothing here bans, rejects or confirms anybody by itself. Each flag names
the partner by their name in this program, and the conversion, commission or link it is
about; `score` is the risk score (0 to 100) behind a `risk_review` flag.

Filter with `rule`, `partner_id`, `min_score` and `older_than_days`.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `status` | query | `string` | `open` (the default), `dismissed` or `confirmed`. (one of `open`, `dismissed`, `confirmed`) |
| `rule` | query | `FlagRule` | Which rule raised a flag. `risk_review` is the risk engine asking a person (the score reached the program's `review_at`); `self_referral_card` is one card paying for the promoter's own customers; `payout_change` is a payout built inside the window after a partner changed where they are paid; `manual_hold` is "Hold this promoter's payouts". `other` is a rule this version of the API does not name yet. (one of `self_referral`, `click_velocity`, `duplicate_payout`, `risk_review`, `self_referral_card`, `payout_change`, `manual_hold`, `other`) |
| `partner_id` | query | `uuid` | One partner's membership id. |
| `min_score` | query | `integer` | (>= 0; <= 100) |
| `older_than_days` | query | `integer` | Only flags at least this many days old. (>= 0; <= 3650) |
| `reason` | query | `string` | Only `risk_review` flags whose score had this reason, for example `fast_conversion`. (one of `fast_conversion`, `paid_ads`, `datacenter_click`, `disposable_customer`, `email_lookalike`, `no_click_pattern`, `code_leak_spike`, `refund_farming`, `conversion_rate_outlier`, `new_partner`) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `FlagList` | The flags. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/flags/{id}`

**Read one flag in full**

Scope `referrals:read`. Another workspace's answers `404`.

Everything a person needs to decide a flag: the score and every reason as a sentence (what
was seen, in counts and times: "Sale 4 seconds after the click", never an address, a card
or an email), the timeline of the customer's click, signup, sale and refunds with the
seconds between them, and the partner's history in this program (age, clicks and customers
in 30 days, refunds in 60, and their other flags). The partner never sees any of it: their
side says only "Under review".

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `FlagDetail` | The flag, its reasons, timeline and the partner's history. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/flags/{id}/dismiss`

**Dismiss a flag (not fraud)**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Not fraud: the flag is closed and what it held goes on. A held commission matures at its
hold (or on the next sweep if that has passed) and joins a payout like any other. A
flag already decided is `409`. Recorded in the activity log with your note. An AI
assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (optional)

| Field | Type |  |
|---|---|---|
| `note` | `string \| null` | Kept on the flag and in the activity log. (max 500) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `FlagResolution` | The flag, now `dismissed`. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/flags/{id}/confirm`

**Confirm a flag as fraud**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Fraud: every commission the flag holds that is still `pending` or `approved` becomes
`fraud`, which never reaches a payout, and a reward not yet given is cancelled. With
`ban_partner: true` the partner is banned from the program too; nothing bans anybody
unless you say so. A commission already in a payout is `409`. Recorded in the activity log
with your note. An AI assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (optional)

| Field | Type |  |
|---|---|---|
| `note` | `string \| null` | (max 500) |
| `ban_partner` | `boolean` | Ban the partner from the program too (default false). |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `FlagResolution` | The flag, now `confirmed`, and what it did. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/flags/dismiss`

**Dismiss many flags at once**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Dismisses up to 200 open flags of one program with one reason, each decided and recorded in
the activity log on its own. A flag that is not this program's, not open, or holds a
commission already in a payout is left as it was and listed in `skipped` with why. An AI
assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `flag_ids` | `uuid[]` | (**required**; <= 200 items; >= 1 items) |
| `note` | `string` | One reason for all of them. (**required**; min 1; max 500) |

```json
{
  "flag_ids": "3f8c1e42-9b7a-4d61-a0f5-2c9e1b7d4a83",
  "note": "<note>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `FlagsResolution` | How many were decided, and the ones left as they were. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/programs/{id}/flags/confirm`

**Confirm many flags as fraud at once**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Confirms up to 200 open flags of one program as fraud with one reason, each decided and
recorded in the activity log on its own: what each held becomes `fraud`, and with
`ban_partners: true` their partners are banned. Flags left as they were are listed in
`skipped` with why. An AI assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `flag_ids` | `uuid[]` | (**required**; <= 200 items; >= 1 items) |
| `note` | `string` | One reason for all of them. (**required**; min 1; max 500) |
| `ban_partners` | `boolean` | Ban each flag's partner too (default false). |

```json
{
  "flag_ids": "3f8c1e42-9b7a-4d61-a0f5-2c9e1b7d4a83",
  "note": "<note>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `FlagsResolution` | How many were decided, and the ones left as they were. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/referrals/partners/{id}/hold-payouts`

**Hold a partner's payouts**

Scope `referrals:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

"Hold this promoter's payouts": opens a `manual_hold` flag on the partner, and nothing they
earned is paid while it is open. A stop, never a decision: what they earned stays theirs
until you dismiss or confirm the flag. Holding a partner already held answers the open
flag with `already: true`. Recorded in the activity log.

One API key may place at most 30 holds an hour (a platform setting), answered `429
RATE_LIMITED` past it: only a person releases a hold. The dashboard is not counted.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (optional)

| Field | Type |  |
|---|---|---|
| `note` | `string \| null` | Kept on the flag and in the activity log. (max 500) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PayoutHold` | Already held; the open flag. |
| 201 | `PayoutHold` | Held; the new flag. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/risk-policy`

**Read a program's fraud settings**

Scope `referrals:read`. Another workspace's answers `404`.

What holds this program's money: the score at which a conversion's earnings wait longer
(`hold_at`, and `extra_hold_days`), the score at which a person is asked (`review_at`), the
fewest seconds from click to signup, sale or shop order that look human, whether paid
search on the brand is allowed (with campaign ids that are), and whether new partners wait
longer. `policy` is what is in force (your values, else the platform's); `custom` is only
what you set. `alert_email` emails the program's managers when a promoter needs a look, at
most once per promoter a day.

Needs `referrals:read`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `RiskPolicyView` | The settings. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/referrals/programs/{id}/risk-policy`

**Change a program's fraud settings**

Scope `referrals:write`. Another workspace's answers `404`.

Replaces what you set with `policy` (send `null` to go back to the platform's values) and
switches the alert email. Every value is checked: `review_at` is never below `hold_at`, a
campaign id is letters, digits and `_.:-`. Applies from the next conversion; what was
already assessed keeps its hold. Recorded in the activity log with the values before and
after. An AI assistant's key is always asked first.

Needs `referrals:write`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `policy` | `any` | The fields to change: only those you send change, and one sent as null goes back to the platform's value. `policy: null` clears every field the program set. |
| `alert_email` | `boolean` |  |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `RiskPolicyView` | The settings now. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/referrals/programs/{id}/stats`

**Read a program's stats**

Scope `referrals:read`. Another workspace's answers `404`.

Clicks, leads, sales, revenue and commission over a window, in total, per partner and, with `bucket`, over time.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `from` | query | `string` | (max 64) |
| `to` | query | `string` | (max 64) |
| `bucket` | query | `string` | (one of `day`, `week`, `month`) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `ProgramStats` | The stats. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

**How a program is doing** — answers `200`

Clicks, conversions, commissions and payouts over time. No partner account and no customer appears in it.

```bash
curl "https://api.boltclaw.ai/v1/referrals/programs/4e8a2b71-39cd-4f06-8a52-71b0d4c9e283/stats?from=2026-03-01&to=2026-04-01&bucket=day" \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/referrals/programs/4e8a2b71-39cd-4f06-8a52-71b0d4c9e283/stats?from=2026-03-01&to=2026-04-01&bucket=day", {
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
  },
});

// 200
const data = await res.json();
```

## Shapes

The objects these operations send and answer.

### AutoSendCancelled

| Field | Type |  |
|---|---|---|
| `cancelled` | `boolean` | (**required**) |

### BillingProvider

A billing system Bolt Claw connects to directly.

One of: `stripe`, `paddle`, `polar`, `whop`, `revenuecat`, `woocommerce`, `fastspring`, `lemonsqueezy`, `shopify`, `chargebee`, `recurly`, `gumroad`.

### BoardMetric

`customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed.

One of: `customers`, `sales`, `revenue`, `friends`.

### BoardNameChoice

How a promoter chose to appear to the program's other promoters. `hidden` is "A promoter", the default for a partner; one of your own users starts `initials`.

One of: `hidden`, `initials`, `display`.

### BoardPeriod

`month` and `quarter` are calendar ones (UTC); `30d` the last 30 days; `all` since the program began.

One of: `month`, `30d`, `quarter`, `all`.

### BonusCreate

| Field | Type |  |
|---|---|---|
| `amount_minor` | `integer` | Minor units of the program's currency. (**required**; >= 1) |
| `reason` | `string` | The partner sees it as the line's description. Recorded in the activity log. (**required**; min 1; max 280) |
| `hold_days` | `integer` | Days before it may be paid. 0 (the default) makes it payable at once. (>= 0; <= 365) |

### Brand

| Field | Type |  |
|---|---|---|
| `scope` | `string` | `workspace`: the brand of every program. `program`: one program's override. (**required**; one of `workspace`, `program`) |
| `program_id` | `uuid \| null` | (**required**) |
| `name` | `string \| null` | What this brand sets; null follows the workspace's brand (or the program's own name). (**required**) |
| `accent` | `string \| null` | `#rrggbb`, used for buttons and links. (**required**) |
| `background` | `string \| null` | `#rrggbb`, the portal's page colour. (**required**) |
| `radius` | `integer \| null` | Corner radius in pixels, 0 to 24. (**required**) |
| `sender_name` | `string \| null` | The name partners' emails come from. The address stays BoltClaw's. (**required**) |
| `show_powered_by` | `boolean \| null` | (**required**) |
| `support_email` | `email \| null` | (**required**) |
| `support_email_verified` | `boolean` | Confirmed with a code; only then is it the reply-to of partners' emails. (**required**) |
| `logo_light_url` | `uri \| null` | (**required**) |
| `logo_dark_url` | `uri \| null` | (**required**) |
| `favicon_url` | `uri \| null` | (**required**) |
| `effective` | `BrandEffective` | The brand partners of this scope actually see, the program's override over the workspace's. (**required**) |
| `updated_at` | `date-time \| null` | (**required**) |

### BrandEffective

The brand partners of this scope actually see, the program's override over the workspace's.

| Field | Type |  |
|---|---|---|
| `name` | `string \| null` | (**required**) |
| `logo_light_url` | `uri \| null` | (**required**) |
| `logo_dark_url` | `uri \| null` | (**required**) |
| `favicon_url` | `uri \| null` | (**required**) |
| `accent` | `string \| null` | `#rrggbb`. (**required**) |
| `background` | `string \| null` | `#rrggbb`. (**required**) |
| `radius` | `integer \| null` | Corner radius in pixels, 0 to 24. (**required**) |
| `show_powered_by` | `boolean` | Whether "Powered by BoltClaw" shows. Off unless you turn it on. (**required**) |
| `sender_name` | `string \| null` | The name partners' emails come from; null for the default sender. (**required**) |
| `reply_to` | `email \| null` | The confirmed support address partners' replies go to; null until one is confirmed. (**required**) |

### BrandEmailCodeConfirm

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid \| null` |  |
| `code` | `string` | The six digits from the email. (**required**) |

### BrandEmailCodeSent

| Field | Type |  |
|---|---|---|
| `sent` | `boolean` | (**required**; one of `true`) |
| `to` | `email` | The support address the code went to: your own. (**required**) |

### BrandFileSlot

`logo_light`: the logo on light backgrounds. `logo_dark`: on dark ones. `favicon`: the browser tab's icon.

One of: `logo_light`, `logo_dark`, `favicon`.

### BrandScope

An empty object for the workspace's brand.

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid \| null` | A program's override. |

### BrandUpdate

Send only the fields you want to change; `null` clears one. An empty object is `400`.

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid \| null` | Change this program's override instead of the workspace's brand. |
| `name` | `string \| null` | No control characters and no angle brackets. (max 80) |
| `accent` | `string \| null` |  |
| `background` | `string \| null` |  |
| `radius` | `integer \| null` | (>= 0; <= 24) |
| `support_email` | `email \| null` | Unconfirmed until its code comes back. |
| `show_powered_by` | `boolean \| null` |  |
| `sender_name` | `string \| null` | No control characters and none of these: " < > @ , ; : \ (max 60) |
| `logo_light_path` | `string \| null` | The `path` an upload answered, or null to remove the logo. |
| `logo_dark_path` | `string \| null` | The `path` an upload answered, or null to remove the logo for dark backgrounds. |
| `favicon_path` | `string \| null` | The `path` an upload answered, or null to remove the icon. |

### BrandUpload

| Field | Type |  |
|---|---|---|
| `upload_url` | `uri \| null` | PUT the file's bytes here, with its content type. One file; treat it as a secret. Null in a replay of the same `Idempotency-Key`: ask for a new one. (**required**) |
| `path` | `string` | Send it to PATCH /referrals/brand as the slot's field once the file is uploaded. (**required**) |
| `slot` | `BrandFileSlot` | `logo_light`: the logo on light backgrounds. `logo_dark`: on dark ones. `favicon`: the browser tab's icon. (**required**; one of `logo_light`, `logo_dark`, `favicon`) |
| `expires_at` | `date-time` | (**required**) |

### BrandUploadRequest

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid \| null` | For a program's override. |
| `slot` | `BrandFileSlot` | `logo_light`: the logo on light backgrounds. `logo_dark`: on dark ones. `favicon`: the browser tab's icon. (**required**; one of `logo_light`, `logo_dark`, `favicon`) |
| `content_type` | `string` | (**required**; one of `image/png`, `image/jpeg`, `image/webp`) |

### Broadcast

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `subject` | `string` | (**required**) |
| `body` | `string` | (**required**) |
| `audience` | `BroadcastAudience` | (**required**) |
| `status` | `string` | (**required**; one of `draft`, `sending`, `sent`) |
| `via` | `string` | (**required**; one of `dashboard`, `api`) |
| `link_hosts` | `string[]` | Every host the message links to. (**required**) |
| `preview` | `string` | The first line, with sample values in the merge fields. (**required**) |
| `test_sends` | `integer` | (**required**) |
| `tested_at` | `date-time \| null` | (**required**) |
| `tested_current` | `boolean` | Whether the current text was test-sent. (**required**) |
| `sent_at` | `date-time \| null` | (**required**) |
| `finished_at` | `date-time \| null` | (**required**) |
| `counts` | `object` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |

### BroadcastAudience

| Field | Type |  |
|---|---|---|
| `kind` | `string` | (**required**; one of `all`, `group`, `partners`) |
| `group_id` | `uuid` | With `group`: the group or level. |
| `partner_ids` | `uuid[]` | With `partners`: the promoters you picked. (<= 500 items) |

### BroadcastCreate

| Field | Type |  |
|---|---|---|
| `subject` | `string` | (**required**; max 120) |
| `body` | `string` | Markdown subset: paragraphs, **bold**, *italic*, [links](https://...) and lists. No HTML, no images, https links only. `{name}`, `{link}` and `{code}` are filled per promoter. (**required**; max 5000) |
| `audience` | `BroadcastAudience` |  |

### BroadcastList

| Field | Type |  |
|---|---|---|
| `data` | `Broadcast[]` | (**required**) |

### BroadcastReferrer

| Field | Type |  |
|---|---|---|
| `referrer_id` | `uuid` | (**required**) |
| `external_id` | `string` | Your own id for the user. (**required**) |
| `display_name` | `string` | (**required**) |
| `link` | `string \| null` | (**required**) |
| `code` | `string \| null` | (**required**) |

### BroadcastReferrerList

| Field | Type |  |
|---|---|---|
| `data` | `BroadcastReferrer[]` | (**required**) |
| `next_cursor` | `string \| null` | (**required**) |

### BroadcastTested

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `subject` | `string` | (**required**) |
| `body` | `string` | (**required**) |
| `audience` | `BroadcastAudience` | (**required**) |
| `status` | `string` | (**required**; one of `draft`, `sending`, `sent`) |
| `via` | `string` | (**required**; one of `dashboard`, `api`) |
| `link_hosts` | `string[]` | Every host the message links to. (**required**) |
| `preview` | `string` | The first line, with sample values in the merge fields. (**required**) |
| `test_sends` | `integer` | (**required**) |
| `tested_at` | `date-time \| null` | (**required**) |
| `tested_current` | `boolean` | Whether the current text was test-sent. (**required**) |
| `sent_at` | `date-time \| null` | (**required**) |
| `finished_at` | `date-time \| null` | (**required**) |
| `counts` | `object` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |
| `sent_to` | `string` | The key creator's own address, where the test went. (**required**) |

### BroadcastUpdate

| Field | Type |  |
|---|---|---|
| `subject` | `string` | (max 120) |
| `body` | `string` | (max 5000) |
| `audience` | `BroadcastAudience` |  |

### CodeCreate

| Field | Type |  |
|---|---|---|
| `code` | `string \| null` | 3 to 40 letters, digits and hyphens. Left out, the program's suggestion. (max 64) |

### CodeUpdate

| Field | Type |  |
|---|---|---|
| `stripe_promotion_code` | `string \| null` | `promo_…` in your own Stripe, or null to forget it. (**required**; max 255) |

### Commission

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `status` | `CommissionStatus` | (**required**; one of `pending`, `approved`, `payable`, `paid`, `reversed`, `fraud`) |
| `amount_minor` | `integer` | Net of reversals. Negative for a downward adjustment. (**required**) |
| `gross_minor` | `integer` | (**required**) |
| `reversed_minor` | `integer` | (**required**) |
| `currency` | `string` | (**required**) |
| `hold_until` | `date-time \| null` | (**required**) |
| `approved_at` | `date-time \| null` | (**required**) |
| `reversed_at` | `date-time \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `reason` | `string \| null` | (**required**) |
| `adjustment` | `boolean` | (**required**) |
| `under_review` | `boolean` | (**required**) |
| `risk` | `object \| null` | Why the fraud engine held this commission longer (`hold`) or sent it to a person (`review`): the score and every reason as a sentence, from counts and times. Null when it held nothing. See [Fraud and risk](https://boltclaw.ai/docs/referral-fraud). |
| `partner` | `PartnerRef` | (**required**) |
| `conversion` | `object \| null` | (**required**) |
| `basis_text` | `string` | Why it is this amount: "30% of 49.00 on Yearly plans (Gold level), capped at 120.00 per customer". `Program rate` for a commission recorded before reward rules had reasons. (**required**) |
| `capped_minor` | `integer` | What the program's monthly ceiling per partner held back from this commission. (**required**) |
| `bonus` | `string \| null` | `milestone` for a bonus a partner earned by crossing a line, `manual` for one you paid by hand (which has no `conversion`), null for anything else. (**required**; one of `milestone`, `manual`, `null`) |
| `test` | `boolean` | From TEST data, earned by the program's test partner. Never payable. |
| `tier` | `integer` | `2` for a recruiter's share of what a partner they recruited earned (the second tier); `1` for everything else. (one of `1`, `2`) |

### CommissionAdjust

| Field | Type |  |
|---|---|---|
| `amount_minor` | `integer` | Signed minor units; not 0. (**required**) |
| `reason` | `string` | Trimmed; recorded in the activity log. (**required**; min 1; max 500) |

### CommissionApproveRequest

| Field | Type |  |
|---|---|---|
| `reason` | `string` | Why it is paid before its hold ends. Kept in the activity log. (**required**; max 500) |

### CommissionList

| Field | Type |  |
|---|---|---|
| `data` | `Commission[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### CommissionStatus

One of: `pending`, `approved`, `payable`, `paid`, `reversed`, `fraud`.

### Contest

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `name` | `string` | (**required**) |
| `description` | `string \| null` | (**required**) |
| `starts_at` | `date-time` | (**required**) |
| `ends_at` | `date-time` | (**required**) |
| `metric` | `BoardMetric` | `customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed. (**required**; one of `customers`, `sales`, `revenue`, `friends`) |
| `group_id` | `uuid \| null` | Only one group or level may enter; null for every promoter. (**required**) |
| `prizes` | `ContestPrize[]` | (**required**) |
| `minimum` | `integer` | What a promoter must reach to win a prize. (**required**) |
| `status` | `ContestStatus` | (**required**; one of `scheduled`, `running`, `ended`, `awarded`, `cancelled`) |
| `status_text` | `string` | (**required**) |
| `ended_at` | `date-time \| null` | (**required**) |
| `awarded_at` | `date-time \| null` | (**required**) |
| `cancelled_at` | `date-time \| null` | (**required**) |
| `cancel_reason` | `string \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |

### ContestAward

| Field | Type |  |
|---|---|---|
| `contest` | `Contest` | (**required**) |
| `awarded` | `object[]` | (**required**) |

### ContestCreate

| Field | Type |  |
|---|---|---|
| `description` | `string \| null` | (max 1000) |
| `starts_at` | `date-time` | (**required**) |
| `ends_at` | `date-time` | At most 366 days after the start. (**required**) |
| `metric` | `BoardMetric` | `customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed. (one of `customers`, `sales`, `revenue`, `friends`) |
| `group_id` | `uuid \| null` |  |
| `prizes` | `ContestPrizeInput[]` | (**required**; <= 20 items; >= 1 items) |
| `minimum` | `integer` | (>= 0) |
| `name` | `string` | (**required**; min 1; max 80) |

### ContestDetail

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `name` | `string` | (**required**) |
| `description` | `string \| null` | (**required**) |
| `starts_at` | `date-time` | (**required**) |
| `ends_at` | `date-time` | (**required**) |
| `metric` | `BoardMetric` | `customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed. (**required**; one of `customers`, `sales`, `revenue`, `friends`) |
| `group_id` | `uuid \| null` | Only one group or level may enter; null for every promoter. (**required**) |
| `prizes` | `ContestPrize[]` | (**required**) |
| `minimum` | `integer` | What a promoter must reach to win a prize. (**required**) |
| `status` | `ContestStatus` | (**required**; one of `scheduled`, `running`, `ended`, `awarded`, `cancelled`) |
| `status_text` | `string` | (**required**) |
| `ended_at` | `date-time \| null` | (**required**) |
| `awarded_at` | `date-time \| null` | (**required**) |
| `cancelled_at` | `date-time \| null` | (**required**) |
| `cancel_reason` | `string \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |
| `standings` | `ContestStanding[]` | (**required**) |
| `computed_at` | `date-time \| null` | (**required**) |
| `award` | `object` | (**required**) |

### ContestList

| Field | Type |  |
|---|---|---|
| `data` | `Contest[]` | (**required**) |

### ContestPrize

| Field | Type |  |
|---|---|---|
| `rank` | `integer` | (**required**; >= 1; <= 100) |
| `through_rank` | `integer` | The same prize for every place from `rank` to this one. (>= 1; <= 100) |
| `kind` | `string` | (**required**; one of `cash`, `grant`) |
| `amount_minor` | `integer` | Cash, credit or a gift card, in the program's currency's minor units. (>= 1) |
| `reward_type` | `string` | A `grant`'s type. (one of `credit`, `gift_card`, `free_period`, `perk`) |
| `perk_text` | `string` | A perk's words: "A hoodie". (max 120) |
| `period_count` | `integer` | A free period's length: up to 365 days or 36 months. (>= 1; <= 365) |
| `period_unit` | `string` | (one of `day`, `month`) |
| `place` | `string` | "1st", "2nd to 5th". (**required**) |
| `text` | `string` | "500.00 USD", "A hoodie", "1 month free". (**required**) |

### ContestPrizeInput

| Field | Type |  |
|---|---|---|
| `rank` | `integer` | (**required**; >= 1; <= 100) |
| `through_rank` | `integer` | The same prize for every place from `rank` to this one. (>= 1; <= 100) |
| `kind` | `string` | (**required**; one of `cash`, `grant`) |
| `amount_minor` | `integer` | Cash, credit or a gift card, in the program's currency's minor units. (>= 1) |
| `reward_type` | `string` | A `grant`'s type. (one of `credit`, `gift_card`, `free_period`, `perk`) |
| `perk_text` | `string` | A perk's words: "A hoodie". (max 120) |
| `period_count` | `integer` | A free period's length: up to 365 days or 36 months. (>= 1; <= 365) |
| `period_unit` | `string` | (one of `day`, `month`) |

### ContestStanding

| Field | Type |  |
|---|---|---|
| `rank` | `integer` | (**required**) |
| `place` | `integer \| null` | Place among those who reached the minimum (what a prize goes by). (**required**) |
| `qualified` | `boolean` | (**required**) |
| `value` | `integer` | (**required**) |
| `partner_id` | `uuid` | (**required**) |
| `kind` | `string` | (**required**; one of `partner`, `referrer`) |
| `display_name` | `string` | (**required**) |
| `external_id` | `string \| null` | (**required**) |
| `appears_as` | `BoardNameChoice` | How a promoter chose to appear to the program's other promoters. `hidden` is "A promoter", the default for a partner; one of your own users starts `initials`. (**required**; one of `hidden`, `initials`, `display`) |
| `under_review` | `boolean` | (**required**) |
| `prize` | `string \| null` | The prize this place wins as things stand. (**required**) |

### ContestStatus

One of: `scheduled`, `running`, `ended`, `awarded`, `cancelled`.

### ContestUpdate

| Field | Type |  |
|---|---|---|
| `description` | `string \| null` | (max 1000) |
| `starts_at` | `date-time` |  |
| `ends_at` | `date-time` | At most 366 days after the start. |
| `metric` | `BoardMetric` | `customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed. (one of `customers`, `sales`, `revenue`, `friends`) |
| `group_id` | `uuid \| null` |  |
| `prizes` | `ContestPrizeInput[]` | (<= 20 items; >= 1 items) |
| `minimum` | `integer` | (>= 0) |
| `name` | `string` | (min 1; max 80) |

### Conversion

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `event` | `ConversionEvent` | (**required**; one of `lead`, `sale`, `refund`, `dispute`) |
| `amount_minor` | `integer` | Minor units. 0 for a lead; for a refund, this refund alone. (**required**) |
| `currency` | `string \| null` | (**required**) |
| `occurred_at` | `date-time` | (**required**) |
| `attributed` | `boolean` | Whether a partner was credited. (**required**) |
| `partner` | `object \| null` | (**required**) |
| `commission` | `object \| null` | (**required**) |
| `commissions` | `object[]` | Every commission the conversion created, net of reversals, each with why it is that amount. A sale whose items matched two reward rules earns two; `commission` is the first of them. (**required**) |
| `duplicate` | `boolean` | True when this event_id had been reported before and nothing changed. (**required**) |
| `grants` | `object[]` | The rewards that are not cash this conversion gave, to the promoter or to the friend, each in words. A friend's discount is here only when the sale shows it was used. |
| `referee_referrer` | `object \| null` | Invite after purchase: the friend who just made their first payment now has a referral link of their own, so your thank-you page can ask them to invite their friends. Null unless the program asks (`invite_after_purchase`), you named the customer by your own id, and the link exists. |
| `referral_refused` | `string \| null` | A referral code that was sent and not applied, and why: `late_code` when it came more than the program's `late_code_days` after the customer signed up. Null otherwise. (one of `late_code`, `null`) |
| `test` | `boolean` | TEST data: a test sale, `test: true`, or a test-mode or sandbox event from your billing system. Credited to the program's test partner only; never payable, never counted. |

### ConversionCreate

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `event` | `ConversionEvent` | (**required**; one of `lead`, `sale`, `refund`, `dispute`) |
| `event_id` | `string` | YOUR id for this event, and the idempotency key. Reporting it again changes nothing, so a retry sends the same one; a new one is a new conversion. (**required**; min 1; max 255) |
| `occurred_at` | `date-time` |  |
| `customer` | `object` |  |
| `referral` | `object` |  |
| `amount` | `integer` | Minor units. (>= 0) |
| `currency` | `string` |  |
| `refund_of` | `string` | The sale's event_id. (min 1; max 255) |
| `items` | `SaleItem[]` | A sale's lines, so a reward rule can pay per product, price, plan or interval. Their `amount`s must add up to the sale's `amount` (`400` otherwise). Leave them out and the sale is matched as a whole. (<= 100 items) |
| `test` | `boolean` | Record this as TEST data: credited to the program's test partner whoever's code it carries, never payable, never counted toward a limit or a total, emailing nobody, and sent only to webhook endpoints that receive test events. A test `event_id` and a live one never collide, and neither do test and live customers. Deleted with `DELETE /v1/referrals/programs/{id}/test-data`. |

### ConversionEvent

One of: `lead`, `sale`, `refund`, `dispute`.

### CurrencyTotal

| Field | Type |  |
|---|---|---|
| `currency` | `string` | (**required**) |
| `amount_minor` | `integer` | (**required**) |

### CustomerReferral

| Field | Type |  |
|---|---|---|
| `invited_by` | `string \| null` | The inviter's name (**required**) |
| `invited_at` | `date-time \| null` | (**required**) |
| `via` | `string \| null` | (**required**; one of `link`, `discount_code`, `null`) |

### DataSubject

Everything Bolt Claw holds about one of your customers or in-app referrers in this workspace.

| Field | Type |  |
|---|---|---|
| `external_id` | `string` | The id you looked up. (**required**) |
| `customers` | `object[]` | Each program the person was referred in, as a customer. (**required**) |
| `referrers` | `object[]` | Each program the person is an in-app referrer in. (**required**) |

### DataSubjectErased

What was erased, counted. Amounts and commissions stay as anonymous rows.

| Field | Type |  |
|---|---|---|
| `external_id` | `string` | (**required**) |
| `customers` | `integer` | Customer records turned into anonymous ones. (**required**) |
| `conversions` | `integer` | Their conversions (**required**) |
| `referrers` | `integer` | In-app referrers erased. (**required**) |
| `webhook_payloads` | `integer` | Stored webhook bodies whose copy of the id was scrubbed. (**required**) |

### DirectoryCategory

One of: `software`, `ai_tools`, `education`, `finance`, `health`, `ecommerce`, `creator_tools`, `agencies`, `other`.

### DiscountCode

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `code` | `string` | 3 to 40 letters (**required**) |
| `active` | `boolean` | False once the code was switched off. (**required**) |
| `created_at` | `date-time` | (**required**) |
| `partner` | `object \| null` | (**required**) |
| `stripe_promotion_code` | `string \| null` | The promotion code this code is in your own Stripe. (**required**) |
| `shopify_discount` | `string \| null` | The discount this code is in the program's linked Shopify shop. (**required**) |
| `shopify_error` | `string \| null` | Why the last Shopify sync could not make it there. (**required**; one of `no_friend_discount`, `refused`, `unavailable`, `taken`, `null`) |

### DiscountCodeList

| Field | Type |  |
|---|---|---|
| `data` | `DiscountCode[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |
| `friend_discount` | `any` | (**required**) |

### DnsRecord

| Field | Type |  |
|---|---|---|
| `type` | `string` | (**required**; one of `TXT`, `CNAME`) |
| `name` | `string` | The record's full name, as your DNS host may ask for it. (**required**) |
| `value` | `string \| null` | What it must hold; null for the CNAME until the TXT record is seen, when the target for this domain is known. (**required**) |
| `purpose` | `string` | `ownership`: proves the domain is yours. `routing`: points it at us. `provider`: a second TXT record our hosting provider asks for. (**required**; one of `ownership`, `routing`, `provider`) |
| `done` | `boolean` | In place, as last checked. (**required**) |

### Domain

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `host` | `string` | Lower case, international names in their punycode form. (**required**) |
| `kind` | `DomainKind` | `links`: your referral links answer on it (`https://go.example.com/<code>`). `portal`: your partner portal and join pages, and links too. (**required**; one of `links`, `portal`) |
| `status` | `DomainStatus` | `pending_ownership`: waiting for the TXT record that proves it is yours. `pending_dns`: waiting for the CNAME record. `pending_certificate`: the certificate is being issued. `active`: answering. `detached`: its records stopped pointing at us, so it stopped answering; put them back to reconnect it, or it is removed after 7 days (`remove_after`). (**required**; one of `pending_ownership`, `pending_dns`, `pending_certificate`, `active`, `detached`) |
| `status_text` | `string` | The status, in a few words. (**required**) |
| `all_programs` | `boolean` | It serves every program, new ones included. (**required**) |
| `program_ids` | `uuid[]` | The programs it serves when `all_programs` is false; empty otherwise. (**required**) |
| `records` | `DnsRecord[]` | Every DNS record it needs, in the order to set them. (**required**) |
| `problem` | `string \| null` | What is in the way, as a sentence; null when nothing is. (**required**) |
| `caa_warning` | `boolean` | A CAA record on your domain does not allow letsencrypt.org, which issues the certificate. (**required**) |
| `url` | `uri \| null` | `https://<host>` once active. (**required**) |
| `checked_at` | `date-time \| null` | (**required**) |
| `activated_at` | `date-time \| null` | (**required**) |
| `detached_at` | `date-time \| null` | (**required**) |
| `remove_after` | `date-time \| null` | When a detached domain is removed. (**required**) |
| `created_at` | `date-time` | (**required**) |

### DomainCreate

| Field | Type |  |
|---|---|---|
| `host` | `string` | A subdomain of yours, such as go.example.com. Never a domain's root, a wildcard or an IP address. (**required**; max 300) |
| `kind` | `DomainKind` | `links`: your referral links answer on it (`https://go.example.com/<code>`). `portal`: your partner portal and join pages, and links too. (**required**; one of `links`, `portal`) |
| `all_programs` | `boolean \| null` | Serve every program, new ones included. True when left out. |
| `program_ids` | `array \| null` | The programs it serves, when `all_programs` is false (1 to 50). (<= 50 items) |

### DomainKind

`links`: your referral links answer on it (`https://go.example.com/<code>`). `portal`: your partner portal and join pages, and links too.

One of: `links`, `portal`.

### DomainList

| Field | Type |  |
|---|---|---|
| `data` | `Domain[]` | (**required**) |

### DomainRemoved

| Field | Type |  |
|---|---|---|
| `removed` | `boolean` | (**required**; one of `true`) |
| `host` | `string` | (**required**) |

### DomainStatus

`pending_ownership`: waiting for the TXT record that proves it is yours. `pending_dns`:
waiting for the CNAME record. `pending_certificate`: the certificate is being issued.
`active`: answering. `detached`: its records stopped pointing at us, so it stopped
answering; put them back to reconnect it, or it is removed after 7 days (`remove_after`).

One of: `pending_ownership`, `pending_dns`, `pending_certificate`, `active`, `detached`.

### EmbedConfig

The panel's look, copy and sharing. Every key is optional; a key left out keeps its
default, and an unknown key is `400`. Sent whole: a new `embed_config` replaces the old
one. At most 8 KB once written as JSON.

| Field | Type |  |
|---|---|---|
| `accent` | `string` | The accent colour, like `#2A9D8F`. |
| `radius` | `integer` | Corner radius in pixels. (>= 0; <= 24) |
| `theme` | `string` | `auto` follows the visitor's device. (one of `light`, `dark`, `auto`) |
| `link_mode` | `string` | `boltclaw` (default): the referrer's link goes through the program's own `/r/{code}` redirect, which counts clicks. `direct`: your own site with `?via={code}`, which names no other domain; clicks are then counted only when your server reports them. (one of `boltclaw`, `direct`) |
| `invite_from_own_system` | `boolean` | Shows a "send from my own system" field. The panel hands the address a referrer types to your page (the `inviteRequested` event) and nothing else: Bolt Claw never sees or emails it. |
| `channels` | `ShareChannel[]` | The share buttons to show, in order. Left out, all nine. (<= 9 items) |
| `copy` | `EmbedCopy` | Words you replace in the panel. Plain text only, never HTML. Any key left out keeps the panel's own English. Each value is 1 to the number of characters shown beside it. |
| `messages` | `EmbedMessages` | The text each share button pre-fills, at most 280 characters, plain text. `{link}` is replaced by the referrer's link (a message without it gets the link on a new line) and `{program}` by the program's name. |
| `email_subject` | `string` | (max 120) |
| `email_body` | `string` | (max 1000) |

### EmbedCopy

Words you replace in the panel. Plain text only, never HTML. Any key left out keeps the
panel's own English. Each value is 1 to the number of characters shown beside it.

| Field | Type |  |
|---|---|---|
| `headline` | `string` | (max 120) |
| `subline` | `string` | (max 240) |
| `step_1` | `string` | (max 120) |
| `step_2` | `string` | (max 120) |
| `step_3` | `string` | (max 120) |
| `copy_button` | `string` | (max 30) |
| `copied_label` | `string` | (max 30) |
| `share_button` | `string` | (max 30) |
| `email_button` | `string` | (max 30) |
| `email_title` | `string` | (max 60) |
| `terms_label` | `string` | (max 40) |
| `empty_title` | `string` | (max 80) |
| `empty_body` | `string` | (max 240) |
| `payout_title` | `string` | (max 80) |
| `payout_body` | `string` | (max 240) |
| `rules_title` | `string` | (max 60) |
| `history_title` | `string` | (max 60) |
| `expired_title` | `string` | (max 80) |
| `expired_body` | `string` | (max 240) |

### EmbedMessages

The text each share button pre-fills, at most 280 characters, plain text. `{link}` is
replaced by the referrer's link (a message without it gets the link on a new line) and
`{program}` by the program's name.

| Field | Type |  |
|---|---|---|
| `native` | `string` | (max 280) |
| `whatsapp` | `string` | (max 280) |
| `x` | `string` | (max 280) |
| `linkedin` | `string` | (max 280) |
| `facebook` | `string` | (max 280) |
| `messenger` | `string` | (max 280) |
| `telegram` | `string` | (max 280) |
| `sms` | `string` | (max 280) |
| `email` | `string` | (max 280) |

### EmbedSettings

A program's in-app referral panel. Change it with `embed_enabled`, `embed_origins`, `embed_config` and `approve_referrers` on `PATCH /referrals/programs/{id}`.

| Field | Type |  |
|---|---|---|
| `enabled` | `boolean` | Whether sessions can be minted and the panel loads. Off, both fail. (**required**) |
| `key` | `string` | The public embed key your page passes as `embedKey`. Not a secret; rotate it with `POST /referrals/programs/{id}/embed/rotate-key`. (**required**) |
| `panel_url` | `string` | The address the panel's iframe loads. (**required**) |
| `origins` | `string[]` | The sites allowed to frame the panel, like `https://app.example.com`. https only (`http://localhost:3000`, with a port, is allowed for development), no wildcard, no path. Any other site that tries to frame it is refused by the browser. (**required**; <= 10 items) |
| `generation` | `integer` | Goes up by one each time you end every live session. (**required**; >= 1) |
| `approve_referrers` | `boolean` | True (default): a new referrer is `approved` at once. False: `pending` until you approve. (**required**) |
| `config` | `EmbedConfig` | The panel's look, copy and sharing. Every key is optional; a key left out keeps its default, and an unknown key is `400`. Sent whole: a new `embed_config` replaces the old one. At most 8 KB once written as JSON. (**required**) |

### EmbedTokenCreate

| Field | Type |  |
|---|---|---|
| `referrer` | `object` | The user the session is for; they are made a referrer if they are not one. Leave it out only for `mode` `preview`. |
| `ttl_seconds` | `integer` | How long the token works, in seconds. (default `3600`; >= 60; <= 86400) |
| `mode` | `string` | `panel` for a real referrer; `preview` shows sample data and needs no referrer. (default `panel`; one of `panel`, `preview`) |

### EmbedTokenCreated

| Field | Type |  |
|---|---|---|
| `token` | `string \| null` | `bcet_` and 43 characters. Shown once: hand it to the panel and do not store or log it. Null in a replay of the same `Idempotency-Key`: mint a new one. (**required**) |
| `expires_at` | `date-time` | (**required**) |
| `referrer` | `any` | The referrer the session is for; null for a `preview` session. (**required**) |

### FileLink

| Field | Type |  |
|---|---|---|
| `url` | `uri \| null` | Downloads the PDF once, within ten minutes. Treat it as a secret. Null in a replay of the same `Idempotency-Key`: ask for a new one. (**required**) |
| `expires_at` | `date-time` | (**required**) |

### Flag

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `rule` | `FlagRule` | Which rule raised a flag. `risk_review` is the risk engine asking a person (the score reached the program's `review_at`); `self_referral_card` is one card paying for the promoter's own customers; `payout_change` is a payout built inside the window after a partner changed where they are paid; `manual_hold` is "Hold this promoter's payouts". `other` is a rule this version of the API does not name yet. (**required**; one of `self_referral`, `click_velocity`, `duplicate_payout`, `risk_review`, `self_referral_card`, `payout_change`, `manual_hold`, `other`) |
| `score` | `integer \| null` | The risk score behind a `risk_review` flag; null for the other rules. (**required**; >= 0; <= 100) |
| `status` | `string` | (**required**; one of `open`, `dismissed`, `confirmed`) |
| `subject` | `string` | (**required**; one of `click`, `partner`, `conversion`, `commission`) |
| `created_at` | `date-time` | (**required**) |
| `resolved_at` | `date-time \| null` | (**required**) |
| `note` | `string \| null` | Your note (**required**) |
| `partner` | `object \| null` | The partner it is about, by their name in this program. (**required**) |
| `commission` | `object \| null` | (**required**) |
| `conversion` | `object \| null` | (**required**) |
| `link` | `object \| null` | A velocity flag's link, by its code and type. (**required**) |

### FlagConfirm

| Field | Type |  |
|---|---|---|
| `note` | `string \| null` | (max 500) |
| `ban_partner` | `boolean` | Ban the partner from the program too (default false). |

### FlagDecision

| Field | Type |  |
|---|---|---|
| `note` | `string \| null` | Kept on the flag and in the activity log. (max 500) |

### FlagDetail

| Field | Type |  |
|---|---|---|
| `flag` | `Flag` | (**required**) |
| `score` | `integer \| null` | (**required**; >= 0; <= 100) |
| `reasons` | `object[]` | Every rule that counted, with its weight and what it saw, as a sentence. (**required**) |
| `assessed_at` | `date-time \| null` | (**required**) |
| `timeline` | `object[]` | The customer's click, signup, sale, refunds and disputes in order, with the seconds between them. (**required**) |
| `history` | `object \| null` | The partner's history in this program. (**required**) |

### FlagList

| Field | Type |  |
|---|---|---|
| `data` | `Flag[]` | (**required**; <= 200 items) |

### FlagResolution

| Field | Type |  |
|---|---|---|
| `flag` | `Flag` | (**required**) |
| `commissions` | `integer` | Commissions this decision moved to `fraud`; 0 for a dismissal. (**required**) |
| `banned` | `boolean` | (**required**) |

### FlagRule

Which rule raised a flag. `risk_review` is the risk engine asking a person (the score
reached the program's `review_at`); `self_referral_card` is one card paying for the
promoter's own customers; `payout_change` is a payout built inside the window after a
partner changed where they are paid; `manual_hold` is "Hold this promoter's payouts".
`other` is a rule this version of the API does not name yet.

One of: `self_referral`, `click_velocity`, `duplicate_payout`, `risk_review`, `self_referral_card`, `payout_change`, `manual_hold`, `other`.

### FlagsBulk

| Field | Type |  |
|---|---|---|
| `flag_ids` | `uuid[]` | (**required**; <= 200 items; >= 1 items) |
| `note` | `string` | One reason for all of them. (**required**; min 1; max 500) |

### FlagsBulkConfirm

| Field | Type |  |
|---|---|---|
| `flag_ids` | `uuid[]` | (**required**; <= 200 items; >= 1 items) |
| `note` | `string` | One reason for all of them. (**required**; min 1; max 500) |
| `ban_partners` | `boolean` | Ban each flag's partner too (default false). |

### FlagsResolution

| Field | Type |  |
|---|---|---|
| `decided` | `integer` | (**required**) |
| `commissions` | `integer` | Commissions moved to `fraud`. (**required**) |
| `grants` | `integer` | Rewards that are not cash (**required**) |
| `banned` | `integer` | (**required**) |
| `skipped` | `object[]` | (**required**) |

### ForfeitRequest

| Field | Type |  |
|---|---|---|
| `reason` | `string` | Why, recorded in the activity log. (**required**; min 3; max 500) |

### ForfeitResult

| Field | Type |  |
|---|---|---|
| `partner_id` | `uuid` | (**required**) |
| `forfeited` | `integer` | How many commissions were given up. (**required**) |
| `totals` | `CurrencyTotal[]` | What was given up, per currency. (**required**) |

### Grant

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `beneficiary` | `string` | (**required**; one of `promoter`, `referee`) |
| `reward_type` | `GrantType` | (**required**; one of `credit`, `free_period`, `discount`, `perk`, `gift_card`) |
| `value` | `object` | (**required**) |
| `summary` | `string` | What it is, in a few words: "1 month free", "10.00 USD in credit". (**required**) |
| `status` | `GrantStatus` | `pending` waits out the program's hold; `ready` is yours to give; `fulfilled` was given; `failed` could not be given yet; `cancelled` by a refund or dispute before it was given; `reversed` by one after. (**required**; one of `pending`, `ready`, `fulfilled`, `failed`, `cancelled`, `reversed`) |
| `status_text` | `string` | Where it stands (**required**) |
| `hold_until` | `date-time \| null` | (**required**) |
| `ready_at` | `date-time \| null` | (**required**) |
| `fulfilled_at` | `date-time \| null` | (**required**) |
| `ended_at` | `date-time \| null` | When it was cancelled or reversed. (**required**) |
| `fulfilment` | `string \| null` | How it was or will be given; `merchant` is your own system. (**required**; one of `merchant`, `stripe`, `tremendous`, `null`) |
| `reference` | `string \| null` | Your (or Stripe's) reference for it (**required**) |
| `reason` | `string \| null` | Why it was cancelled (**required**) |
| `recipient` | `object` | Who receives it, as you know them: one of your own users with a link (`referrer`, by your `external_id`), an affiliate (`partner`, by their name in the program), or the friend (`customer`, by your own id for them). (**required**) |
| `partner` | `object \| null` | The promoter the sale was credited to. (**required**) |
| `customer_external_id` | `string \| null` | The friend (**required**) |
| `conversion` | `object \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |
| `test` | `boolean` | From TEST data. Never given through Stripe, never counted. |

### GrantFail

| Field | Type |  |
|---|---|---|
| `reason` | `string` | Why it cannot be given. Shown to you (**required**; min 1; max 500) |

### GrantFulfil

| Field | Type |  |
|---|---|---|
| `reference` | `string` | Your reference for the reward you gave, like an order or credit note number. The same one again is safe. (**required**; min 1; max 200) |
| `note` | `string \| null` | A note for your own team. (max 500) |

### GrantList

| Field | Type |  |
|---|---|---|
| `data` | `Grant[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### GrantStatus

`pending` waits out the program's hold; `ready` is yours to give; `fulfilled` was given;
`failed` could not be given yet; `cancelled` by a refund or dispute before it was given;
`reversed` by one after.

One of: `pending`, `ready`, `fulfilled`, `failed`, `cancelled`, `reversed`.

### GrantType

One of: `credit`, `free_period`, `discount`, `perk`, `gift_card`.

### Group

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `name` | `string` | (**required**) |
| `description` | `string \| null` | (**required**) |
| `is_default` | `boolean` | A newly approved partner starts here. (**required**) |
| `rank` | `integer \| null` | A level's place on the ladder (1 is the lowest); null for a plain group. (**required**) |
| `level` | `boolean` | (**required**) |
| `minimum` | `LevelMinimum` | What a partner needs to reach a level: `revenue` in `amount_minor`, or `sales` or `customers` in `count`, over the last `window_days` (30 to 365), or all time when null. Counted net of refunds. Null: everyone qualifies. (**required**) |
| `keep_earned` | `boolean` | A partner who reached this level is never moved down by the daily job. (**required**) |
| `partners` | `integer` | Approved and pending partners in it now. (**required**) |
| `summary` | `string` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |

### GroupCreate

| Field | Type |  |
|---|---|---|
| `name` | `string` | 1 to 60 characters after trimming; unique in the program. (**required**; max 200) |
| `description` | `string \| null` | (max 1000) |
| `is_default` | `boolean` |  |
| `rank` | `integer \| null` | 1 to 100 makes it a level; null for a plain group. |
| `minimum` | `LevelMinimum` | What a partner needs to reach a level: `revenue` in `amount_minor`, or `sales` or `customers` in `count`, over the last `window_days` (30 to 365), or all time when null. Counted net of refunds. Null: everyone qualifies. |
| `keep_earned` | `boolean` |  |

### GroupList

| Field | Type |  |
|---|---|---|
| `data` | `Group[]` | Every group and level of the program, at most 50, levels first by rank. (**required**; <= 51 items) |

### GroupUpdate

Every field optional; a field left out keeps its value.

| Field | Type |  |
|---|---|---|
| `name` | `string` | (max 200) |
| `description` | `string \| null` | (max 1000) |
| `is_default` | `boolean` |  |
| `rank` | `integer \| null` |  |
| `minimum` | `LevelMinimum` | What a partner needs to reach a level: `revenue` in `amount_minor`, or `sales` or `customers` in `count`, over the last `window_days` (30 to 365), or all time when null. Counted net of refunds. Null: everyone qualifies. |
| `keep_earned` | `boolean` |  |

### HistoryStage

Where the friend is: `joined`, `paid`, `rewarded` (the reward is paid out) or `not_counted`.

One of: `joined`, `paid`, `rewarded`, `not_counted`.

### ImageUpload

| Field | Type |  |
|---|---|---|
| `upload_url` | `string \| null` | PUT the file here. Null in a replay of the same Idempotency-Key. (**required**) |
| `path` | `string` | (**required**) |
| `expires_at` | `date-time` | (**required**) |

### ImageUploadCreate

| Field | Type |  |
|---|---|---|
| `content_type` | `string` | (**required**; one of `image/png`, `image/jpeg`, `image/webp`) |

### Import

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `invites` | `integer` | (**required**) |
| `with_balance` | `integer` | (**required**) |
| `claimed` | `integer` | (**required**) |
| `created_at` | `date-time` | (**required**) |

### ImportCommit

| Field | Type |  |
|---|---|---|
| `rows` | `ImportRow[]` | (<= 500 items; >= 1 items) |
| `csv` | `string` | A CSV with a header row; at most 500 rows a call. (max 65000) |
| `import_id` | `uuid` | Continue an import begun by an earlier part of the file. |

### ImportCommitted

| Field | Type |  |
|---|---|---|
| `import_id` | `uuid` | (**required**) |
| `invites` | `ImportInvite[]` | (**required**) |

### ImportInvite

| Field | Type |  |
|---|---|---|
| `id` | `string` | (**required**) |
| `line` | `integer \| null` | (**required**) |
| `name` | `string` | (**required**) |
| `code` | `string \| null` | (**required**) |
| `group_id` | `string \| null` | (**required**) |
| `rate_bps` | `integer \| null` | (**required**) |
| `balance` | `object \| null` | (**required**) |
| `link` | `string` | This person's personal link. Send it from your own email. (**required**) |
| `state` | `string` | (**required**; one of `open`, `waiting`, `claimed`, `revoked`) |
| `claimed_at` | `date-time \| null` | (**required**) |

### ImportInviteList

| Field | Type |  |
|---|---|---|
| `data` | `ImportInvite[]` | (**required**) |
| `next_cursor` | `string \| null` | (**required**) |

### ImportList

| Field | Type |  |
|---|---|---|
| `data` | `Import[]` | (**required**) |

### ImportPreview

| Field | Type |  |
|---|---|---|
| `rows` | `integer` | (**required**) |
| `valid` | `integer` | (**required**) |
| `errors` | `ImportRowError[]` | (**required**) |
| `with_balance` | `integer` | (**required**) |

### ImportRequest

Either `rows` or `csv`, not both.

| Field | Type |  |
|---|---|---|
| `rows` | `ImportRow[]` | (<= 500 items; >= 1 items) |
| `csv` | `string` | A CSV with a header row; at most 500 rows a call. (max 65000) |

### ImportRow

| Field | Type |  |
|---|---|---|
| `line` | `integer` | The row's line in your file, echoed in errors. (>= 1) |
| `name` | `string` | (**required**; max 80) |
| `email` | `string` | Hashed on arrival, never stored, never emailed. (**required**; max 254) |
| `code` | `string` | A vanity code held for this person. (max 40) |
| `group` | `string` | A group or level of the program, by name. (max 80) |
| `rate_bps` | `integer` | A custom rate for this person, in basis points. (>= 1; <= 10000) |
| `balance_minor` | `integer` | An opening balance owed, paid as a bonus when they claim. (>= 1) |
| `currency` | `string` | The balance's currency: the program's own. |

### ImportRowError

| Field | Type |  |
|---|---|---|
| `line` | `integer` | (**required**) |
| `field` | `string` | (**required**; one of `name`, `email`, `code`, `group`, `rate`, `balance`, `currency`, `row`) |
| `message` | `string` | (**required**) |

### Integration

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `provider` | `BillingProvider` | A billing system Bolt Claw connects to directly. (**required**; one of `stripe`, `paddle`, `polar`, `whop`, `revenuecat`, `woocommerce`, `fastspring`, `lemonsqueezy`, `shopify`, `chargebee`, `recurly`, `gumroad`) |
| `webhook_url` | `string` | (**required**) |
| `has_secret` | `boolean` | (**required**) |
| `customer_metadata_key` | `string` | (**required**) |
| `status` | `string` | `disconnected`: a Stripe App connection that was uninstalled or disconnected. (**required**; one of `active`, `disabled`, `disconnected`) |
| `last_event_at` | `date-time \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `codes_and_credits` | `object` | Whether a restricted Stripe key is set, which lets Bolt Claw make codes and give credit and free months in your Stripe. The key itself is never returned, not even in part. |
| `payload_format` | `string \| null` | The shape of the last verified Stripe event. `thin` events carry no data; choose Snapshot in Stripe. (one of `snapshot`, `thin`, `null`) |
| `auth_mode` | `string` | How a delivery proves itself: a signing secret, Basic credentials Bolt Claw generated (Chargebee), a header token Bolt Claw generated (RevenueCat), the Stripe App (`oauth`), an unsigned ping confirmed through the provider's API with your token (Gumroad), or Bolt Claw's Shopify app (`shopify_app`), linked from the shop's own Shopify admin. (one of `signing_secret`, `basic`, `header_token`, `oauth`, `api_key`, `shopify_app`) |
| `test_mode` | `boolean` | The Sandbox switch. Every event of this integration is recorded as test data. |
| `credential` | `object` | Whether the credential is saved (or generated), and since when. Never the value. |
| `read_key` | `object` | Recurly's read-only key or Gumroad's token. Whether one is saved, never the value. |
| `provider_account` | `string \| null` | Recurly's region (`us` or `eu`), or the Shopify shop that sent the first delivery. |
| `shopify` | `object \| null` | Bolt Claw's Shopify app, linked to this program from the shop's own Shopify admin. Null for every other kind of integration. `connected` is false once it was unlinked or the shop uninstalled the app. |
| `app` | `object \| null` | The Stripe App connection. Null for every other kind of integration. |
| `health` | `object` | What the setup status reads. Fixed words and counts; nothing from a delivery. |

### IntegrationStatus

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `checked_at` | `date-time` | (**required**) |
| `overall` | `SetupState` | (**required**; one of `connected`, `warnings`, `not_connected`) |
| `parts` | `SetupPart[]` | (**required**) |
| `integrations` | `Integration[]` | (**required**) |
| `test_conversions` | `integer` | Test conversions the program holds now. `DELETE /v1/referrals/programs/{id}/test-data` deletes them. (**required**) |

### Invite

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `url` | `string` | The link to send. Bolt Claw emails nobody. (**required**) |
| `max_uses` | `integer` | (**required**) |
| `used_count` | `integer` | (**required**) |
| `expires_at` | `date-time \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `state` | `string` | (**required**; one of `active`, `expired`, `used`) |

### InviteCreate

| Field | Type |  |
|---|---|---|
| `max_uses` | `integer` | How many people may join with it. 1 by default. (>= 1; <= 100000) |
| `expires_at` | `date-time \| null` | Within a year. |

### InviteSummary

An invite without its link (shown once, when the invite is made).

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `max_uses` | `integer` | (**required**) |
| `used_count` | `integer` | (**required**) |
| `expires_at` | `date-time \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `state` | `string` | (**required**; one of `active`, `expired`, `used`) |

### InviteSummaryList

| Field | Type |  |
|---|---|---|
| `data` | `InviteSummary[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### Invoice

An invoice for one payout: one you issued in the promoter's name (`self_billed`), or their
own (`uploaded`). Read its PDF through `POST /referrals/invoices/{id}/link`.

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `payout_id` | `uuid` | (**required**) |
| `partner_id` | `uuid` | (**required**) |
| `kind` | `string` | (**required**; one of `self_billed`, `uploaded`) |
| `status` | `string` | `issued`: accepted unless the promoter disputes it by `accept_by` (14 days). `accepted`. `disputed`: paused until you issue a corrected one. `void`: replaced (see `replaced_by`). `received`: the promoter's own uploaded invoice. (**required**; one of `issued`, `accepted`, `disputed`, `void`, `received`) |
| `number` | `string \| null` | A self-billed invoice's number, in the promoter's own series with you, without gaps (`SB1A2B3C4D-00042`). Null for an uploaded one. (**required**) |
| `currency` | `string` | (**required**) |
| `gross_minor` | `integer` | The payout's amount. (**required**) |
| `net_minor` | `integer` | (**required**) |
| `vat_minor` | `integer` | (**required**) |
| `vat_bps` | `integer \| null` | The VAT rate in basis points, for a domestic supply. (**required**) |
| `vat_treatment` | `string` | `domestic`: the promoter is in your own country and charges your VAT rate. `reverse_charge`: cross-border business to business, so you account for the VAT (the invoice says "Reverse charge"). `outside_scope`: you are outside the EU and the UK. `none`: an uploaded invoice. (**required**; one of `domestic`, `reverse_charge`, `outside_scope`, `none`) |
| `issued_at` | `date-time` | (**required**) |
| `accept_by` | `date-time \| null` | (**required**) |
| `accepted_at` | `date-time \| null` | (**required**) |
| `disputed_at` | `date-time \| null` | (**required**) |
| `dispute_reason` | `string \| null` | (**required**; one of `details_wrong`, `amount_wrong`, `vat_wrong`, `not_mine`, `other`, `null`) |
| `dispute_note` | `string \| null` | What the promoter wrote, up to 500 characters. (**required**) |
| `replaced_by` | `uuid \| null` | (**required**) |

### InvoiceList

| Field | Type |  |
|---|---|---|
| `payout_id` | `uuid` | (**required**) |
| `data` | `Invoice[]` | (**required**) |

### Leaderboard

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `settings` | `any` | The program's setting; null when promoters see no leaderboard. (**required**) |
| `metric` | `BoardMetric` | `customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed. (**required**; one of `customers`, `sales`, `revenue`, `friends`) |
| `period` | `BoardPeriod` | `month` and `quarter` are calendar ones (UTC); `30d` the last 30 days; `all` since the program began. (**required**; one of `month`, `30d`, `quarter`, `all`) |
| `title` | `string` | "paying customers, this month". (**required**) |
| `since` | `date-time \| null` | (**required**) |
| `until` | `date-time \| null` | (**required**) |
| `ranked` | `integer` | How many promoters have something counted. (**required**) |
| `computed_at` | `date-time \| null` | (**required**) |
| `rows` | `LeaderboardRow[]` | (**required**) |

### LeaderboardFields

The leaderboard promoters see, merged onto the current setting; null switches it off. You always see your own.

| Field | Type |  |
|---|---|---|
| `metric` | `BoardMetric` | `customers`: paying customers brought; `sales`: payments; `revenue`: their amount, in minor units; `friends`: on a waitlist, friends who joined and confirmed. (one of `customers`, `sales`, `revenue`, `friends`) |
| `period` | `BoardPeriod` | `month` and `quarter` are calendar ones (UTC); `30d` the last 30 days; `all` since the program began. (one of `month`, `30d`, `quarter`, `all`) |
| `show` | `string` | (one of `counts`, `amounts`) |
| `size` | `integer` | (>= 10; <= 100) |
| `panel` | `boolean` |  |

### LeaderboardRow

| Field | Type |  |
|---|---|---|
| `rank` | `integer` | (**required**; >= 1) |
| `value` | `integer` | The count (**required**) |
| `partner_id` | `uuid` | The membership id. (**required**) |
| `kind` | `string` | `referrer`: one of your own users. (**required**; one of `partner`, `referrer`) |
| `display_name` | `string` | (**required**) |
| `external_id` | `string \| null` | Your own id (**required**) |
| `appears_as` | `BoardNameChoice` | How a promoter chose to appear to the program's other promoters. `hidden` is "A promoter", the default for a partner; one of your own users starts `initials`. (**required**; one of `hidden`, `initials`, `display`) |
| `under_review` | `boolean` | An open fraud flag of theirs in the program. (**required**) |
| `last_activity_at` | `date-time \| null` | (**required**) |

### LevelMinimum

What a partner needs to reach a level: `revenue` in `amount_minor`, or `sales` or
`customers` in `count`, over the last `window_days` (30 to 365), or all time when null.
Counted net of refunds. Null: everyone qualifies.

| Field | Type |  |
|---|---|---|
| `metric` | `string` | (**required**; one of `revenue`, `sales`, `customers`) |
| `amount_minor` | `integer` | (>= 0) |
| `count` | `integer` | (>= 0) |
| `window_days` | `integer \| null` |  |

### LevelRecalculation

| Field | Type |  |
|---|---|---|
| `moved` | `integer` | Partners moved to another level. (**required**) |

### LibraryItem

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `kind` | `string` | (**required**; one of `image`, `snippet`, `link`) |
| `title` | `string` | (**required**) |
| `body` | `string \| null` | A snippet's text, with `{link}` and `{code}` as written. (**required**) |
| `url` | `string \| null` | (**required**) |
| `host` | `string \| null` | (**required**) |
| `image_url` | `string \| null` | (**required**) |
| `ready` | `boolean` | False for an image item whose file is not in yet. (**required**) |
| `group_id` | `string \| null` | (**required**) |
| `in_panel` | `boolean` | (**required**) |
| `position` | `integer` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |

### LibraryItemCreate

| Field | Type |  |
|---|---|---|
| `kind` | `string` | (**required**; one of `image`, `snippet`, `link`) |
| `title` | `string` | (**required**; max 120) |
| `body` | `string` | A snippet's text. (max 2000) |
| `url` | `string` | A link's https address. (max 2048) |
| `group_id` | `uuid \| null` |  |
| `in_panel` | `boolean` | A snippet only: shown to your own users in the in-app panel too. |
| `position` | `integer` | (>= 0; <= 10000) |

### LibraryItemDeleted

| Field | Type |  |
|---|---|---|
| `deleted` | `boolean` | (**required**; one of `true`) |
| `id` | `uuid` | (**required**) |

### LibraryItemList

| Field | Type |  |
|---|---|---|
| `data` | `LibraryItem[]` | (**required**) |

### LibraryItemUpdate

| Field | Type |  |
|---|---|---|
| `title` | `string` | (max 120) |
| `body` | `string` | (max 2000) |
| `url` | `string` | (max 2048) |
| `group_id` | `uuid \| null` |  |
| `in_panel` | `boolean` |  |
| `position` | `integer` | (>= 0; <= 10000) |
| `image_path` | `string` | An image item's file, from its upload. (max 200) |

### LinkHost

| Field | Type |  |
|---|---|---|
| `host` | `string` | (**required**) |
| `source` | `string` | `product`: the product URL's own host, needing no proof. (**required**; one of `product`, `allowed`) |
| `verified` | `boolean` | (**required**) |
| `record` | `object \| null` | (**required**) |
| `checked_at` | `date-time \| null` | (**required**) |
| `problem` | `string \| null` | (**required**) |

### LinkHostList

| Field | Type |  |
|---|---|---|
| `data` | `LinkHost[]` | (**required**) |

### ListingSettings

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `listing_id` | `uuid \| null` | Null until the listing is first saved. (**required**) |
| `listed` | `boolean` | (**required**) |
| `category` | `DirectoryCategory` | (**required**; one of `software`, `ai_tools`, `education`, `finance`, `health`, `ecommerce`, `creator_tools`, `agencies`, `other`) |
| `pitch` | `string \| null` | (**required**) |
| `logo_url` | `string \| null` | (**required**) |
| `countries` | `string[]` | (**required**) |
| `languages` | `string[]` | (**required**) |
| `listed_since` | `string \| null` | (**required**) |
| `state` | `string` | (**required**; one of `unlisted`, `listed`, `held`, `delisted`) |
| `missing` | `object[]` | Every condition not met yet. Listing is refused while any is. (**required**) |
| `preview` | `any` | (**required**) |

### ListingUpdate

| Field | Type |  |
|---|---|---|
| `listed` | `boolean` |  |
| `category` | `DirectoryCategory` | (one of `software`, `ai_tools`, `education`, `finance`, `health`, `ecommerce`, `creator_tools`, `agencies`, `other`) |
| `pitch` | `string \| null` | Plain text: no HTML. (max 280) |
| `countries` | `array \| null` | Two-letter country codes the program welcomes partners from. (<= 60 items) |
| `languages` | `array \| null` | Two-letter language codes, like en or de. (<= 20 items) |
| `logo_path` | `string \| null` | The `path` from the logo upload, or null to remove it. (max 200) |

### Milestone

A bonus's line: `revenue` in `amount_minor` (the program's currency), or `sales` or
`customers` in `count`, over the partner's `lifetime` or each `calendar_month` (UTC). Paid
once per window, at the sale that crosses it. While it is on hold, a refund or dispute
that takes the partner back under the line takes it back, and crossing again pays it
again; once approved, it stays.

| Field | Type |  |
|---|---|---|
| `metric` | `string` | (**required**; one of `revenue`, `sales`, `customers`) |
| `amount_minor` | `integer` | (>= 1) |
| `count` | `integer` | (>= 1) |
| `window` | `string` | (**required**; one of `lifetime`, `calendar_month`) |

### NecProvider

One payout provider, and whether what it paid counts toward your 1099-NEC, with why.

| Field | Type |  |
|---|---|---|
| `provider` | `PayoutProviderName` | How a payout is paid. `manual`: a file you pay from yourself. `webhook`: your own payout system. The rest: your own account at that provider. BoltClaw never holds the money. (**required**; one of `manual`, `webhook`, `paypal`, `wise`, `stripe_connect`, `tremendous`, `trolley`, `tango`) |
| `label` | `string` | (**required**) |
| `included` | `boolean` | Counted toward your 1099-NEC, as you set it. (**required**) |
| `default_included` | `boolean` | (**required**) |
| `note` | `string \| null` | Your own note, where you changed the row. (**required**) |
| `reason` | `string` | Why it is included or left out by default. (**required**) |
| `source` | `object` | (**required**) |

### NecReason

Why a person is on your 1099-NEC file or not. `reason_text` says it in a sentence.

One of: `reportable`, `below_threshold`, `backup_withholding`, `not_us_person`, `corporation`, `no_form`, `nothing_reportable`, `threshold_unset`.

### NextCursor

Pass back as `cursor` for the next page; null on the last page.

`string | null`.

### OfferAnswer

| Field | Type |  |
|---|---|---|
| `offer` | `any` | (**required**) |
| `eligible` | `boolean` | (**required**) |
| `reason_code` | `any` | (**required**) |
| `reason` | `string \| null` | One sentence to show at checkout instead of an error. (**required**) |
| `coupon` | `string \| null` | The friend's coupon in your Stripe, for `discounts: [{ coupon }]`. (**required**) |

### PanelHistoryRow

| Field | Type |  |
|---|---|---|
| `id` | `string` | Stable for a friend, like `f3`. Not an id of ours or yours. (**required**) |
| `label` | `string` | `Friend 3`: friends are numbered in the order they joined, never named. (**required**) |
| `stage` | `HistoryStage` | Where the friend is: `joined`, `paid`, `rewarded` (the reward is paid out) or `not_counted`. (**required**; one of `joined`, `paid`, `rewarded`, `not_counted`) |
| `reason_code` | `ReasonCode` | The stable code beside each history sentence. Translate by code; the English `reason` is a convenience. A code is never renamed or reused, and a new one only ever joins the end. `awaiting_payment`: the friend joined; nothing paid yet. `on_hold_until`: the friend paid; the reward waits out the program's hold, until `hold_until`, in case of a refund. `reward_ready`: the hold is over; it goes out in the next payout. `reward_paid`: it has been paid. `under_review`: the program is checking it first. `refunded`: the friend's payment was refunded, so no reward. `no_reward`: the program pays nothing for this event. `referrer_not_approved`: the referrer is not approved, so nothing is earned. (**required**; one of `awaiting_payment`, `on_hold_until`, `reward_ready`, `reward_paid`, `under_review`, `refunded`, `no_reward`, `referrer_not_approved`) |
| `reason` | `string` | One English sentence saying why the row is where it is. Translate by `reason_code`. (**required**) |
| `occurred_at` | `date-time` | (**required**) |
| `hold_until` | `date-time \| null` | When the reward is ready, for `on_hold_until`; otherwise null. (**required**) |
| `reward` | `object \| null` | The reward this friend has earned so far, net of refunds; null when there is none. (**required**) |

### Partner

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | The partner's membership in this program. (**required**) |
| `display_name` | `string` | (**required**) |
| `status` | `PartnerStatus` | (**required**; one of `pending`, `approved`, `rejected`, `banned`) |
| `country` | `string \| null` | (**required**) |
| `payout_method` | `string \| null` | The payout method type. Never the details. (**required**; one of `paypal`, `wise`, `bank`, `other`, `null`) |
| `share_email` | `boolean` | (**required**) |
| `email` | `string \| null` | Only when the partner chose to share it. (**required**) |
| `terms_version_accepted` | `integer \| null` | (**required**) |
| `applied_at` | `date-time` | (**required**) |
| `approved_at` | `date-time \| null` | (**required**) |
| `stats` | `PartnerStats` | (**required**) |
| `group` | `object \| null` | The group or level the partner is in, or null. (**required**) |
| `group_pinned` | `boolean` | True when the daily level job leaves this partner where you put them. (**required**) |
| `application` | `PartnerApplication` | What the partner wrote when they applied, and what the checks saw. Counts only: never which other programs, which accounts, or an address. (**required**) |
| `disclosure_acknowledged_at` | `date-time \| null` | When they last acknowledged your disclosure reminder (shown when they copy a link, then monthly). |
| `muted_broadcasts` | `boolean` | Whether they muted your broadcast emails (one click in any of them). They still see your messages on their program page. |

### PartnerApplication

What the partner wrote when they applied, and what the checks saw. Counts only: never which
other programs, which accounts, or an address.

| Field | Type |  |
|---|---|---|
| `questions` | `string[]` | The program's questions as the partner was asked them; empty when it asked none. (**required**; <= 3 items) |
| `answers` | `string[]` | In the order of `questions`. (**required**; <= 3 items) |
| `website` | `string \| null` | (**required**) |
| `account_age_days` | `integer \| null` | Days since the partner's Bolt Claw account was made. (**required**) |
| `templated` | `integer \| null` | "Looks templated": near-identical applications to other programs this week, or null. (**required**) |
| `burst` | `integer \| null` | Accounts that applied from one network and device in 24 hours, or null when it was not a burst. (**required**) |

### PartnerList

| Field | Type |  |
|---|---|---|
| `data` | `Partner[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### PartnerRef

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `display_name` | `string` | (**required**) |

### PartnerStats

| Field | Type |  |
|---|---|---|
| `clicks` | `integer` | (**required**) |
| `leads` | `integer` | (**required**) |
| `sales` | `integer` | (**required**) |
| `revenue_minor` | `integer` | (**required**) |
| `commission_minor` | `integer` | (**required**) |
| `conversion_rate` | `number \| null` | Sales per click, 0 to 1. Null with no clicks. (**required**) |
| `epc_minor` | `number \| null` | Earnings per click, in minor units. Null with no clicks. (**required**) |

### PartnerStatus

One of: `pending`, `approved`, `rejected`, `banned`.

### PartnerUpdate

| Field | Type |  |
|---|---|---|
| `group_id` | `uuid \| null` | A group or level of the partner's program; null for none. |
| `pinned` | `boolean` | True keeps the daily level job from moving the partner. |

### Payout

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `batch_id` | `uuid \| null` | (**required**) |
| `status` | `PayoutStatus` | `unclaimed`: the provider holds it until the promoter claims it (PayPal's sign-up link, a reward email that bounced). `returned`: it came back before it was paid, and its commissions are back in Ready to pay. (**required**; one of `draft`, `processing`, `unclaimed`, `paid`, `failed`, `returned`, `cancelled`) |
| `provider` | `PayoutProviderName` | How a payout is paid. `manual`: a file you pay from yourself. `webhook`: your own payout system. The rest: your own account at that provider. BoltClaw never holds the money. (**required**; one of `manual`, `webhook`, `paypal`, `wise`, `stripe_connect`, `tremendous`, `trolley`, `tango`) |
| `currency` | `string` | (**required**) |
| `amount_minor` | `integer` | (**required**) |
| `short_reference` | `string` | (**required**) |
| `reference` | `string \| null` | (**required**) |
| `paid_at` | `date-time \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |
| `partner` | `PartnerRef` | (**required**) |
| `country` | `string \| null` | (**required**) |
| `exports` | `object \| null` | Which export file an open payout would go in; null where it would, else why not. (**required**) |
| `delivery` | `PayoutDelivery` | How a payout sent through a provider went; null until it was sent. (**required**) |

### PayoutBatch

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `label` | `string` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `sent_at` | `date-time \| null` | (**required**) |
| `totals` | `CurrencyTotal[]` | What the batch held when it was made. (**required**) |
| `sendable` | `object` | (**required**) |
| `lines` | `object[]` | (**required**) |
| `waiting_for_funding` | `object[]` | (**required**) |
| `auto_send` | `object` | (**required**) |

### PayoutBatchResult

| Field | Type |  |
|---|---|---|
| `status` | `string` | (**required**; one of `created`, `empty`) |
| `batch_id` | `uuid \| null` | (**required**) |
| `payouts` | `integer` | (**required**) |
| `carried` | `integer` | Promoters whose total was below the minimum or negative. (**required**) |
| `held` | `integer` | Approved commissions a flag or a payout-details hold kept out. (**required**) |

### PayoutCancel

| Field | Type |  |
|---|---|---|
| `status` | `string` | `cancelled` (the default), or `failed` when you tried to pay it and the money did not arrive. (one of `cancelled`, `failed`) |

### PayoutDelivery

How a payout sent through a provider went; null until it was sent.

| Field | Type |  |
|---|---|---|
| `sent_at` | `date-time` | (**required**) |
| `provider_ref` | `string \| null` | The provider's own id for it: what to look for in your provider account. (**required**) |
| `awaiting_funding_at` | `date-time \| null` | Waiting for you to fund it at the provider (Wise, Trolley), since when. (**required**) |
| `unclaimed_at` | `date-time \| null` | (**required**) |
| `failure` | `object \| null` | (**required**) |
| `returned_after_paid_at` | `date-time \| null` | It came back AFTER it was paid. Nothing was changed; it is yours to decide. (**required**) |

### PayoutDetails

| Field | Type |  |
|---|---|---|
| `payout_id` | `uuid` | (**required**) |
| `amount_minor` | `integer` | (**required**) |
| `currency` | `string` | (**required**) |
| `reference` | `string` | (**required**) |
| `method` | `string \| null` | (**required**; one of `paypal`, `wise`, `bank`, `other`, `null`) |
| `details` | `object \| null` | The promoter's own payout details for this method (an email, or bank fields), in snake_case. (**required**) |
| `external_id` | `string \| null` | For one of your own users (a referrer), your own id for them. (**required**) |

### PayoutHold

| Field | Type |  |
|---|---|---|
| `flag` | `Flag` | (**required**) |
| `already` | `boolean` | True when the partner was already held. (**required**) |

### PayoutList

| Field | Type |  |
|---|---|---|
| `data` | `Payout[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### PayoutMarkPaid

| Field | Type |  |
|---|---|---|
| `reference` | `string` | Your payment reference; trimmed. (**required**; min 1; max 200) |
| `expected_minor` | `integer` | The amount you were shown. (>= 0) |

### PayoutPreview

| Field | Type |  |
|---|---|---|
| `groups` | `object[]` | (**required**) |
| `promoters` | `object[]` | (**required**) |
| `waiting` | `integer` | (**required**) |
| `truncated` | `boolean` | More than the 2,000 largest balances are owed. (**required**) |

### PayoutProvider

A payout account a program pays through, by name and state. Never a credential.

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `provider` | `string` | (**required**; one of `webhook`, `paypal`, `wise`, `stripe_connect`, `tremendous`, `trolley`, `tango`) |
| `name` | `string` | (**required**) |
| `mode` | `string` | A sandbox connection never pays a batch. (**required**; one of `live`, `sandbox`) |
| `status` | `string` | (**required**; one of `active`, `error`) |
| `problem` | `string \| null` | Why it is not working (**required**) |
| `verified_at` | `date-time \| null` | (**required**) |
| `last_used_at` | `date-time \| null` | (**required**) |
| `label` | `string \| null` | The campaign, profile or reward it pays from. (**required**) |
| `needs` | `string` | What a promoter must give before this provider can pay them. (**required**; one of `email`, `email_or_iban`, `stripe_account`, `trolley_recipient`, `nothing`) |

### PayoutProviderList

| Field | Type |  |
|---|---|---|
| `data` | `PayoutProvider[]` | (**required**) |

### PayoutProviderName

How a payout is paid. `manual`: a file you pay from yourself. `webhook`: your own payout
system. The rest: your own account at that provider. BoltClaw never holds the money.

One of: `manual`, `webhook`, `paypal`, `wise`, `stripe_connect`, `tremendous`, `trolley`, `tango`.

### PayoutResult

| Field | Type |  |
|---|---|---|
| `status` | `string` | (**required**; one of `paid`, `failed`, `returned`) |
| `reference` | `string` | Your own payment reference (paid only). (max 200) |
| `reason` | `string` | Why it failed or came back (required unless paid). (one of `invalid_destination`, `recipient_not_ready`, `insufficient_funds`, `returned`, `other`) |

### PayoutSend

| Field | Type |  |
|---|---|---|
| `payout_ids` | `uuid[]` | Send only these; every payout in `sendable` when absent. (<= 5000 items) |
| `expected_totals` | `object` | What you expect to send, per currency, in minor units: `{"USD": 12000}`. Must equal `sendable.totals`. (**required**) |
| `confirm_large` | `uuid[]` | (<= 5000 items) |

### PayoutSendResult

| Field | Type |  |
|---|---|---|
| `batch_id` | `uuid` | (**required**) |
| `claimed` | `integer` | (**required**) |
| `results` | `object[]` | (**required**) |

### PayoutSettings

| Field | Type |  |
|---|---|---|
| `provider` | `PayoutProviderName` | How a payout is paid. `manual`: a file you pay from yourself. `webhook`: your own payout system. The rest: your own account at that provider. BoltClaw never holds the money. (**required**; one of `manual`, `webhook`, `paypal`, `wise`, `stripe_connect`, `tremendous`, `trolley`, `tango`) |
| `providers` | `PayoutProviderName[]` | The providers a promoter may choose among; the program's own provider is always one. (**required**) |
| `schedule` | `string` | (**required**; one of `manual`, `monthly`) |
| `payout_day` | `integer` | The day a monthly batch is made (UTC). (**required**; >= 1; <= 28) |
| `auto_send` | `boolean` | (**required**) |
| `auto_send_available` | `boolean` | False until BoltClaw allows sending on a schedule, after a legal review; until then a person sends every batch. (**required**) |
| `max_payout_minor` | `integer \| null` | A payout above this needs confirming by name. (**required**) |
| `available` | `PayoutProviderName[]` | What this program can pay with now (a file, and every live connection that works). (**required**) |

### PayoutSettingsUpdate

| Field | Type |  |
|---|---|---|
| `provider` | `PayoutProviderName` | How a payout is paid. `manual`: a file you pay from yourself. `webhook`: your own payout system. The rest: your own account at that provider. BoltClaw never holds the money. (one of `manual`, `webhook`, `paypal`, `wise`, `stripe_connect`, `tremendous`, `trolley`, `tango`) |
| `providers` | `PayoutProviderName[]` | (<= 8 items) |
| `auto_send` | `boolean` |  |
| `payout_day` | `integer` | (>= 1; <= 28) |
| `max_payout_minor` | `integer \| null` | (>= 1) |

### PayoutStatus

`unclaimed`: the provider holds it until the promoter claims it (PayPal's sign-up link, a
reward email that bounced). `returned`: it came back before it was paid, and its commissions
are back in Ready to pay.

One of: `draft`, `processing`, `unclaimed`, `paid`, `failed`, `returned`, `cancelled`.

### PrivacyRequest

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `source` | `string` | (**required**; one of `shopify`) |
| `kind` | `string` | (**required**; one of `data_request`, `customer_redact`, `shop_redact`) |
| `shop` | `string` | The shop's myshopify.com name. (**required**) |
| `status` | `string` | (**required**; one of `open`, `answered`, `done`) |
| `result` | `object \| null` | What an erasure made anonymous, as counts. (**required**) |
| `received_at` | `date-time` | (**required**) |
| `answered_at` | `date-time \| null` | (**required**) |
| `due_by` | `date-time` | Thirty days after Shopify asked. (**required**) |

### PrivacyRequestDetail

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `program_id` | `uuid` | (**required**) |
| `source` | `string` | (**required**; one of `shopify`) |
| `kind` | `string` | (**required**; one of `data_request`, `customer_redact`, `shop_redact`) |
| `shop` | `string` | The shop's myshopify.com name. (**required**) |
| `status` | `string` | (**required**; one of `open`, `answered`, `done`) |
| `result` | `object \| null` | What an erasure made anonymous, as counts. (**required**) |
| `received_at` | `date-time` | (**required**) |
| `answered_at` | `date-time \| null` | (**required**) |
| `due_by` | `date-time` | Thirty days after Shopify asked. (**required**) |
| `data` | `object \| null` | For a data request still waiting, what Bolt Claw holds about the customer it named. (**required**) |

### PrivacyRequestList

| Field | Type |  |
|---|---|---|
| `data` | `PrivacyRequest[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### Program

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `name` | `string` | (**required**) |
| `product_url` | `string` | (**required**) |
| `currency` | `string` | (**required**) |
| `cookie_days` | `integer` | (**required**) |
| `attribution_model` | `string` | (**required**; one of `last_click`, `first_click`) |
| `hold_days` | `integer` | (**required**) |
| `min_payout_minor` | `integer` | (**required**) |
| `payout_schedule` | `string` | (**required**; one of `manual`, `monthly`) |
| `join_mode` | `string` | (**required**; one of `apply`, `invite`) |
| `auto_approve` | `boolean` | (**required**) |
| `signup_url` | `string` | The program's public join page. (**required**) |
| `terms` | `string \| null` | (**required**) |
| `terms_version` | `integer` | (**required**) |
| `preview_title` | `string \| null` | (**required**) |
| `preview_description` | `string \| null` | (**required**) |
| `preview_image_url` | `string \| null` | (**required**) |
| `customer_discount` | `string \| null` | (**required**) |
| `discount_code_prefix` | `string \| null` | (**required**) |
| `status` | `string` | (**required**; one of `active`, `paused`, `archived`) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |
| `pending_partners` | `integer` | (**required**) |
| `integrations` | `Integration[]` | (**required**) |
| `kind` | `ProgramKind` | The template a program was made from. It sets the starting values and the words the screens use for promoters; it never switches a feature off. A `waitlist` program's promoters are the people on its waitlist. (**required**; one of `affiliate`, `refer_a_friend`, `creator`, `ambassador`, `reseller`, `shop`, `waitlist`) |
| `partner_monthly_cap_minor` | `integer \| null` | The most one partner earns from this program in a calendar month (UTC), bonuses included. Null for no ceiling. (**required**) |
| `embed` | `EmbedSettings` | A program's in-app referral panel. Change it with `embed_enabled`, `embed_origins`, `embed_config` and `approve_referrers` on `PATCH /referrals/programs/{id}`. (**required**) |
| `welcome` | `WelcomeSettings` | The friend's welcome, as you set it. (**required**) |
| `late_code_days` | `integer` | How many days after sign-up a referral code may still be entered; 0 for never after. (**required**) |
| `lead_grant_daily_cap` | `integer` | The most rewards a day (UTC) that signups alone may earn, across the program. (**required**) |
| `invite_after_purchase` | `boolean` | Whether a friend's first payment gives them a referral link of their own. (**required**) |
| `ask_snooze_days` | `integer` | How long "Not right now" in the panel lasts before a person is asked again. (**required**) |
| `application_questions` | `string[]` | Up to three questions a partner answers when they apply, shown on the program's join page. (<= 3 items) |
| `risk_alert_email` | `boolean` | Whether the program's managers are emailed when a promoter needs a look. Set with `PATCH /referrals/programs/{id}/risk-policy`. |
| `disclosure_text` | `string` | The sentence partners add when they share a link or a code ("I earn a commission if you buy through my link."). Never empty. Partners are reminded of it when they copy a link, when they put one in a post, a bio page or an automation, after every library snippet, and in the in-app panel. |
| `leaderboard` | `any` | The leaderboard promoters see; null when they see none (you always see yours). |
| `waitlist` | `any` | A waitlist program's settings and its hosted page's address; null for any other program. |
| `second_tier` | `any` | The second tier; null while it is off. Set with `PATCH /referrals/programs/{id}/second-tier`. |
| `terms_required_version` | `integer \| null` | The terms version every approved partner must accept before their next payout (a change to the second tier asks it). |
| `setup` | `object` | The answers to "How do you take payments?" and "How do people sign up?", which choose the setup steps. |

### ProgramCreate

| Field | Type |  |
|---|---|---|
| `name` | `string` | (**required**; max 200) |
| `product_url` | `string` | An https URL. (**required**; max 4096) |
| `currency` | `string` | (max 8) |
| `cookie_days` | `integer` | 1 to 365. |
| `attribution_model` | `string` | `last_click` or `first_click`. (max 20) |
| `hold_days` | `integer` |  |
| `min_payout_minor` | `integer` |  |
| `payout_schedule` | `string` | `manual` or `monthly`. (max 20) |
| `join_mode` | `string` | `apply` or `invite`. (max 20) |
| `auto_approve` | `boolean` |  |
| `terms` | `string \| null` | (max 20000) |
| `preview_title` | `string \| null` | (max 400) |
| `preview_description` | `string \| null` | (max 1000) |
| `customer_discount` | `string \| null` | (max 1000) |
| `discount_code_prefix` | `string \| null` | (max 40) |
| `partner_monthly_cap_minor` | `integer \| null` | The most one partner earns in a calendar month (UTC), in minor units; null for no ceiling. |
| `embed_enabled` | `boolean` | Turns the in-app referral panel on or off. Off, no session can be minted and the panel does not load. |
| `embed_origins` | `string[]` | The sites allowed to frame the panel, like `https://app.example.com`. Replaces the list. https only (`http://localhost:3000` for development), no wildcard, no path, at most 10. (<= 10 items) |
| `embed_config` | `EmbedConfig` | The panel look, copy and sharing, replacing the old settings whole. Unknown keys are `400`. |
| `approve_referrers` | `boolean` | True (default): a new referrer is approved at once. False: they are `pending` until you approve them. |
| `welcome_config` | `any` | The welcome's look and words, replacing the old settings whole; null for the defaults. Unknown keys are `400`. |
| `welcome_show_name` | `boolean` | Show a referrer's name to the friend (default true). An affiliate's is shown only when they agreed. |
| `welcome_page` | `boolean` | A one-screen welcome at the link before your product (default false). |
| `allowed_hosts` | `string[]` | Extra hosts your website tag may read the offer from, like `www.example.com`, beside the product URL's own and the panel's framing origins. Replaces the list. Host names only, at most 10. An AI assistant's key cannot change it. (<= 10 items) |
| `late_code_days` | `integer` | How many days after sign-up a referral code may still be entered (default 14; 0 for never after). (>= 0; <= 365) |
| `lead_grant_daily_cap` | `integer` | The most rewards a day (UTC) signups alone may earn across the program (default 100). (>= 0; <= 100000) |
| `invite_after_purchase` | `boolean` | Give a friend who makes their first payment a referral link of their own (default false). |
| `ask_snooze_days` | `integer` | How long "Not right now" in the panel lasts (default 30). (>= 1; <= 365) |
| `setup_payments` | `SetupPayments` | (one of `stripe`, `paddle`, `polar`, `whop`, `revenuecat`, `woocommerce`, `fastspring`, `lemonsqueezy`, `shopify`, `chargebee`, `recurly`, `gumroad`, `own_system`, `not_sure`, `null`) |
| `setup_signups` | `SetupSignups` | (one of `own_backend`, `supabase`, `firebase`, `clerk`, `authjs`, `nocode`, `checkout_only`, `null`) |
| `audience_eu_uk` | `boolean` | Your audience includes the EU or the UK (default false). The setup status then asks for the tag's consent mode. |
| `application_questions` | `string[]` | Up to three questions a partner answers when they apply (each 1 to 200 characters, no hidden characters). Answers are shown on their application; an empty list asks nothing. (<= 3 items) |
| `disclosure_text` | `string` | The sentence partners add when they share (plain text, 10 to 280 characters). It may be edited, never emptied: partners must tell people they earn from the link (FTC, ASA). A new program starts with one for its currency's region. (min 10; max 280) |
| `leaderboard` | `LeaderboardFields` | The leaderboard promoters see, merged onto the current setting; null switches it off. You always see your own. |
| `waitlist` | `WaitlistFields` | A waitlist program's settings, merged onto the current ones. |
| `kind` | `string` | Start from a template: `affiliate`, `refer_a_friend`, `creator`, `ambassador`, `reseller`, `shop` or `waitlist`. The program gets that kind's settings and reward rules (and levels, for `reseller`; the waitlist itself, for `waitlist`), with anything you send yourself taking precedence, in one step. Left out, the program starts with no rules, as before. (max 40) |

### ProgramKind

The template a program was made from. It sets the starting values and the words the
screens use for promoters; it never switches a feature off. A `waitlist` program's
promoters are the people on its waitlist.

One of: `affiliate`, `refer_a_friend`, `creator`, `ambassador`, `reseller`, `shop`, `waitlist`.

### ProgramList

| Field | Type |  |
|---|---|---|
| `data` | `Program[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### ProgramStats

| Field | Type |  |
|---|---|---|
| `currency` | `string` | (**required**) |
| `from` | `date-time` | (**required**) |
| `to` | `date-time` | (**required**) |
| `bucket` | `string \| null` | (**required**; one of `day`, `week`, `month`, `null`) |
| `totals` | `PartnerStats` | (**required**) |
| `untracked_clicks` | `integer` | (**required**) |
| `untracked_by_month` | `object[]` | (**required**) |
| `series` | `array \| null` | (**required**) |
| `partners` | `PartnerStats[]` | (**required**) |

### ProgramUpdate

Every field optional; a field left out keeps its value.

| Field | Type |  |
|---|---|---|
| `name` | `string` | (max 200) |
| `product_url` | `string` | An https URL. (max 4096) |
| `currency` | `string` | (max 8) |
| `cookie_days` | `integer` | 1 to 365. |
| `attribution_model` | `string` | `last_click` or `first_click`. (max 20) |
| `hold_days` | `integer` |  |
| `min_payout_minor` | `integer` |  |
| `payout_schedule` | `string` | `manual` or `monthly`. (max 20) |
| `join_mode` | `string` | `apply` or `invite`. (max 20) |
| `auto_approve` | `boolean` |  |
| `terms` | `string \| null` | (max 20000) |
| `preview_title` | `string \| null` | (max 400) |
| `preview_description` | `string \| null` | (max 1000) |
| `customer_discount` | `string \| null` | (max 1000) |
| `discount_code_prefix` | `string \| null` | (max 40) |
| `partner_monthly_cap_minor` | `integer \| null` | The most one partner earns in a calendar month (UTC), in minor units; null for no ceiling. |
| `embed_enabled` | `boolean` | Turns the in-app referral panel on or off. Off, no session can be minted and the panel does not load. |
| `embed_origins` | `string[]` | The sites allowed to frame the panel, like `https://app.example.com`. Replaces the list. https only (`http://localhost:3000` for development), no wildcard, no path, at most 10. (<= 10 items) |
| `embed_config` | `EmbedConfig` | The panel look, copy and sharing, replacing the old settings whole. Unknown keys are `400`. |
| `approve_referrers` | `boolean` | True (default): a new referrer is approved at once. False: they are `pending` until you approve them. |
| `welcome_config` | `any` | The welcome's look and words, replacing the old settings whole; null for the defaults. Unknown keys are `400`. |
| `welcome_show_name` | `boolean` | Show a referrer's name to the friend (default true). An affiliate's is shown only when they agreed. |
| `welcome_page` | `boolean` | A one-screen welcome at the link before your product (default false). |
| `allowed_hosts` | `string[]` | Extra hosts your website tag may read the offer from, like `www.example.com`, beside the product URL's own and the panel's framing origins. Replaces the list. Host names only, at most 10. An AI assistant's key cannot change it. (<= 10 items) |
| `late_code_days` | `integer` | How many days after sign-up a referral code may still be entered (default 14; 0 for never after). (>= 0; <= 365) |
| `lead_grant_daily_cap` | `integer` | The most rewards a day (UTC) signups alone may earn across the program (default 100). (>= 0; <= 100000) |
| `invite_after_purchase` | `boolean` | Give a friend who makes their first payment a referral link of their own (default false). |
| `ask_snooze_days` | `integer` | How long "Not right now" in the panel lasts (default 30). (>= 1; <= 365) |
| `setup_payments` | `SetupPayments` | (one of `stripe`, `paddle`, `polar`, `whop`, `revenuecat`, `woocommerce`, `fastspring`, `lemonsqueezy`, `shopify`, `chargebee`, `recurly`, `gumroad`, `own_system`, `not_sure`, `null`) |
| `setup_signups` | `SetupSignups` | (one of `own_backend`, `supabase`, `firebase`, `clerk`, `authjs`, `nocode`, `checkout_only`, `null`) |
| `audience_eu_uk` | `boolean` | Your audience includes the EU or the UK (default false). The setup status then asks for the tag's consent mode. |
| `application_questions` | `string[]` | Up to three questions a partner answers when they apply (each 1 to 200 characters, no hidden characters). Answers are shown on their application; an empty list asks nothing. (<= 3 items) |
| `disclosure_text` | `string` | The sentence partners add when they share (plain text, 10 to 280 characters). It may be edited, never emptied: partners must tell people they earn from the link (FTC, ASA). A new program starts with one for its currency's region. (min 10; max 280) |
| `leaderboard` | `LeaderboardFields` | The leaderboard promoters see, merged onto the current setting; null switches it off. You always see your own. |
| `waitlist` | `WaitlistFields` | A waitlist program's settings, merged onto the current ones. |

### ReasonBody

| Field | Type |  |
|---|---|---|
| `reason` | `string` | (**required**; min 3; max 500) |

### ReasonCode

The stable code beside each history sentence. Translate by code; the English `reason` is a
convenience. A code is never renamed or reused, and a new one only ever joins the end.

`awaiting_payment`: the friend joined; nothing paid yet. `on_hold_until`: the friend paid;
the reward waits out the program's hold, until `hold_until`, in case of a refund.
`reward_ready`: the hold is over; it goes out in the next payout. `reward_paid`: it has
been paid. `under_review`: the program is checking it first. `refunded`: the friend's
payment was refunded, so no reward. `no_reward`: the program pays nothing for this event.
`referrer_not_approved`: the referrer is not approved, so nothing is earned.

One of: `awaiting_payment`, `on_hold_until`, `reward_ready`, `reward_paid`, `under_review`, `refunded`, `no_reward`, `referrer_not_approved`.

### ReferralClickCreate

| Field | Type |  |
|---|---|---|
| `code` | `string` | The partner link's code (`?via=`). (**required**; min 1; max 64) |
| `ip` | `string` | The visitor's IP address (max 64) |
| `user_agent` | `string` | Classed as mobile (max 1024) |
| `referrer` | `string` | The referring page. Only its host is kept. (max 2048) |
| `country` | `string` | An ISO 3166 two-letter country code, if your edge sets one. (max 8) |
| `landing_path` | `string` | The path the visitor landed on. Not stored. (max 2048) |

### ReferralClickRecorded

| Field | Type |  |
|---|---|---|
| `recorded` | `boolean` | (**required**) |
| `reason` | `string \| null` | (**required**; one of `bot`, `allowance`, `null`) |
| `program_id` | `uuid` | (**required**) |

### ReferralDeleted

| Field | Type |  |
|---|---|---|
| `deleted` | `true` | (**required**) |

### ReferralLink

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `code` | `string` | (**required**) |
| `url` | `string` | `https://boltclaw.ai/r/<code>`, or on your own link domain. (**required**) |
| `kind` | `string` | (**required**; one of `primary`, `placement`) |
| `placement` | `string \| null` | Where BoltClaw placed it, by type only (never the partner's own note). (**required**; one of `bio`, `post`, `automation`, `manual`, `null`) |
| `archived_at` | `date-time \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `partner` | `object \| null` | (**required**) |

### ReferralLinkList

| Field | Type |  |
|---|---|---|
| `data` | `ReferralLink[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### ReferrerList

| Field | Type |  |
|---|---|---|
| `data` | `ReferrerView[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### ReferrerPanelView

Everything the panel draws for one referrer, as data: the same View behind the hosted panel,
the browser client and `GET /referrals/programs/{id}/referrers/{external_id}`, so your own
screen can show exactly what the panel shows. Keys are snake_case; values are plain text.

| Field | Type |  |
|---|---|---|
| `mode` | `string` | `preview` is sample data. (**required**; one of `panel`, `preview`) |
| `generated_at` | `date-time` | (**required**) |
| `referrer` | `object` | (**required**) |
| `program` | `object` | (**required**) |
| `notice` | `object \| null` | A line above everything when something stops all rewards (a paused program, an unapproved referrer); otherwise null. (**required**) |
| `offer` | `object` | (**required**) |
| `stats` | `object` | (**required**) |
| `history` | `PanelHistoryRow[]` | One row per friend, newest first, at most 50. (**required**; <= 50 items) |
| `payout` | `object` | (**required**) |
| `share` | `object` | (**required**) |
| `look` | `object` | (**required**) |
| `copy` | `EmbedCopy` | Words you replace in the panel. Plain text only, never HTML. Any key left out keeps the panel's own English. Each value is 1 to the number of characters shown beside it. (**required**) |
| `grants` | `object[]` | The referrer's own rewards that are not cash ("1 month free"), newest first, at most 50, each with where it stands in a sentence. Ids are this View's own (`g1`). (**required**; <= 50 items) |
| `ask` | `object` | Whether to ask the referrer to share now, or they said "Not right now" until `snoozed_until`. (**required**) |
| `thank_you` | `object` | The words of the compact thank-you card, for a panel opened with `mode="thank-you"`. (**required**) |

### ReferrerStatus

`approved` earns rewards. `pending` is waiting for you (when the program does not approve
new referrers at once, `embed.approve_referrers`); it can share its link and earns nothing
yet. `banned` earns nothing; an erased referrer is also `banned`.

One of: `approved`, `pending`, `banned`.

### ReferrerStatusUpdate

| Field | Type |  |
|---|---|---|
| `status` | `string` | (**required**; one of `approved`, `banned`) |

### ReferrerUpsert

| Field | Type |  |
|---|---|---|
| `external_id` | `string` | Your own id for the user. Printable characters only, no space at either end. (**required**; min 1; max 200) |
| `name` | `string \| null` | A first name or display name for the panel's greeting, plain text. `null` clears it. (max 60) |
| `email` | `string \| null` | Only used to recognise the same person again (a fingerprint is kept, never the address). Never returned. (max 400) |
| `code` | `string \| null` | The vanity code for their link: 3 to 40 lowercase letters and digits, single hyphens between words. Only when the referrer is created. (max 40) |

### ReferrerView

One of your own users who holds a referral link, as you see them. Never an email, a
fingerprint or a Bolt Claw account: a referrer has none.

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | The referrer's membership in this program. Use it with `PATCH` and `DELETE /referrals/referrers/{id}`. (**required**) |
| `program_id` | `uuid` | (**required**) |
| `external_id` | `string` | Your own id for the user. (**required**; min 1; max 200) |
| `name` | `string \| null` | The first name or display name you sent. Null once erased. (**required**; max 60) |
| `code` | `string \| null` | The code in the referrer's link. (**required**) |
| `link_url` | `string \| null` | The link the referrer shares. (**required**) |
| `status` | `ReferrerStatus` | `approved` earns rewards. `pending` is waiting for you (when the program does not approve new referrers at once, `embed.approve_referrers`); it can share its link and earns nothing yet. `banned` earns nothing; an erased referrer is also `banned`. (**required**; one of `approved`, `pending`, `banned`) |
| `erased` | `boolean` | True for an erased referrer: a row that keeps what it earned, with no name. (**required**) |
| `created_at` | `date-time` | (**required**) |
| `clicks` | `integer` | Clicks on the link (**required**) |
| `friends` | `integer` | Friends who signed up through the link. (**required**) |
| `friends_paid` | `integer` | Friends who have paid. (**required**) |
| `earned_minor` | `integer` | Rewards earned (**required**) |
| `currency` | `string` | The program's currency. (**required**) |

### ReportLink

| Field | Type |  |
|---|---|---|
| `type` | `string` | (**required**; one of `commissions`, `conversions`, `partners`, `payouts`, `grants`, `contests`) |
| `url` | `string` | Open it once, within ten minutes, to download the CSV. Only its fingerprint is kept. (**required**) |
| `expires_at` | `date-time` | (**required**) |
| `filename` | `string` | What the file is called when it downloads, like `acme-partners-commissions-2026-10-27.csv`. (**required**) |

### Reward

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `event` | `string` | (**required**; one of `lead`, `sale`, `bonus`) |
| `kind` | `string` | (**required**; one of `percent`, `flat`) |
| `percent_bps` | `integer \| null` | Basis points: 2000 is 20%. (**required**) |
| `amount_minor` | `integer \| null` | Minor units of the program's currency. (**required**) |
| `duration` | `string` | (**required**; one of `once`, `months`, `lifetime`) |
| `months` | `integer \| null` | (**required**) |
| `updated_at` | `date-time` | (**required**) |
| `scope` | `string` | Who it pays. The most specific scope with a matching rule wins. (**required**; one of `program`, `group`, `partner`) |
| `group_id` | `uuid \| null` | (**required**) |
| `partner_id` | `uuid \| null` | (**required**) |
| `label` | `string \| null` | Your own words for when it applies. (**required**) |
| `conditions` | `RewardConditions` | When a rule applies. Every key you send must match; a rule with none is its audience's default for its event. A sale without `items` matches no rule that names `products`, `prices`, `plans` or `intervals`. A signup takes none; a bonus takes exactly `milestone`. (**required**) |
| `cap_minor` | `integer \| null` | The most it pays for one customer (**required**) |
| `priority` | `integer` | Breaks a tie between two equally specific rules; higher wins. (**required**) |
| `created_at` | `date-time` | (**required**) |
| `summary` | `string` | The rule in one sentence. (**required**) |
| `reward_type` | `RewardType` | What a rule gives. `cash` is a commission, paid out; the rest are grants, given in your own system or through your Stripe. A friend (`referee`) gets a `discount`, credit, a free period, a perk or a gift card, never cash; a `discount` is only ever the friend's, at a sale, once per customer. A bonus is cash. (one of `cash`, `credit`, `free_period`, `discount`, `perk`, `gift_card`) |
| `beneficiary` | `string` | Who it rewards. `referee` is the friend who arrived through the link. (one of `promoter`, `referee`) |
| `period_count` | `integer \| null` | A free period's length. |
| `period_unit` | `string \| null` | (one of `day`, `month`, `null`) |
| `perk_text` | `string \| null` | A perk |
| `discount_duration` | `string \| null` | How long a friend's discount lasts. (one of `once`, `months`, `forever`, `null`) |
| `discount_months` | `integer \| null` |  |
| `stripe_coupon_id` | `string \| null` | The coupon Bolt Claw made for this rule in your own Stripe, when it made one. |

### RewardConditions

When a rule applies. Every key you send must match; a rule with none is its audience's
default for its event. A sale without `items` matches no rule that names `products`,
`prices`, `plans` or `intervals`. A signup takes none; a bonus takes exactly `milestone`.

| Field | Type |  |
|---|---|---|
| `products` | `string[]` | (<= 100 items) |
| `prices` | `string[]` | (<= 100 items) |
| `plans` | `string[]` | Matched without regard to case. (<= 100 items) |
| `intervals` | `string[]` | (<= 3 items) |
| `purchase` | `string` | The customer's first payment or a renewal. `any` is the same as leaving it out. (one of `first`, `renewal`, `any`) |
| `currencies` | `string[]` | (<= 100 items) |
| `min_amount_minor` | `integer` | Only sales of at least this. (>= 0) |
| `milestone` | `Milestone` | A bonus's line: `revenue` in `amount_minor` (the program's currency), or `sales` or `customers` in `count`, over the partner's `lifetime` or each `calendar_month` (UTC). Paid once per window, at the sale that crosses it. While it is on hold, a refund or dispute that takes the partner back under the line takes it back, and crossing again pays it again; once approved, it stays. |

### RewardCreate

`kind` (`percent` or `flat`) is required for cash, credit, a gift card and a discount; a
free period and a perk have no amount and take none.

| Field | Type |  |
|---|---|---|
| `event` | `string` | (**required**; one of `lead`, `sale`, `bonus`) |
| `scope` | `string` | `program` (everyone) unless you say otherwise. (one of `program`, `group`, `partner`) |
| `group_id` | `uuid` | With `scope` `group`. |
| `partner_id` | `uuid` | With `scope` `partner`: one partner's own rate. |
| `kind` | `string` | (one of `percent`, `flat`) |
| `percent_bps` | `integer \| null` | 1 to 10000, for `percent`. |
| `amount_minor` | `integer \| null` | For `flat`; a bonus's must be above 0. |
| `duration` | `string` | Ignored for a bonus (one of `once`, `months`, `lifetime`) |
| `months` | `integer \| null` | 1 to 120, for `months`. |
| `cap_minor` | `integer \| null` | The most it pays for one customer; null for no ceiling. |
| `conditions` | `RewardConditions` | When a rule applies. Every key you send must match; a rule with none is its audience's default for its event. A sale without `items` matches no rule that names `products`, `prices`, `plans` or `intervals`. A signup takes none; a bonus takes exactly `milestone`. |
| `priority` | `integer` | -1000 to 1000; 0 by default. |
| `label` | `string \| null` | At most 60 characters after trimming. (max 200) |
| `reward_type` | `RewardType` | `cash` when left out. Fixed once the rule exists. |
| `beneficiary` | `string` | `promoter` when left out. Fixed once the rule exists. (one of `promoter`, `referee`) |
| `period_count` | `integer \| null` | For `free_period`: 1 to 365 days, or 1 to 36 months. |
| `period_unit` | `string \| null` | For `free_period`. (one of `day`, `month`, `null`) |
| `perk_text` | `string \| null` | For `perk`: at most 120 characters after trimming. (max 400) |
| `discount_duration` | `string \| null` | For `discount`: how long it lasts. (one of `once`, `months`, `forever`, `null`) |
| `discount_months` | `integer \| null` | For a `discount` of `months`: 1 to 36. |

### RewardList

| Field | Type |  |
|---|---|---|
| `data` | `Reward[]` | Every rule of the program, at most 200. (**required**; <= 201 items) |

### RewardType

What a rule gives. `cash` is a commission, paid out; the rest are grants, given in your own
system or through your Stripe. A friend (`referee`) gets a `discount`, credit, a free period,
a perk or a gift card, never cash; a `discount` is only ever the friend's, at a sale, once
per customer. A bonus is cash.

One of: `cash`, `credit`, `free_period`, `discount`, `perk`, `gift_card`.

### RewardUpdate

Every field optional. A rule's event and audience are fixed once it exists, and so are what
it gives and to whom (`reward_type`, `beneficiary`): sending either is `400`.

| Field | Type |  |
|---|---|---|
| `kind` | `string` | (one of `percent`, `flat`) |
| `percent_bps` | `integer \| null` |  |
| `amount_minor` | `integer \| null` |  |
| `duration` | `string` | (one of `once`, `months`, `lifetime`) |
| `months` | `integer \| null` |  |
| `cap_minor` | `integer \| null` |  |
| `conditions` | `RewardConditions` | When a rule applies. Every key you send must match; a rule with none is its audience's default for its event. A sale without `items` matches no rule that names `products`, `prices`, `plans` or `intervals`. A signup takes none; a bonus takes exactly `milestone`. |
| `priority` | `integer` |  |
| `label` | `string \| null` | (max 200) |
| `period_count` | `integer \| null` |  |
| `period_unit` | `string \| null` | (one of `day`, `month`, `null`) |
| `perk_text` | `string \| null` | (max 400) |
| `discount_duration` | `string \| null` | (one of `once`, `months`, `forever`, `null`) |
| `discount_months` | `integer \| null` |  |

### RiskPolicy

| Field | Type |  |
|---|---|---|
| `hold_at` | `integer` | From this score, a conversion's earnings wait `extra_hold_days` longer. (**required**) |
| `review_at` | `integer` | From this score a person is asked (`risk_review`). (**required**) |
| `extra_hold_days` | `integer` | (**required**) |
| `min_seconds_lead` | `integer` | Fewer seconds from click to signup than this look automated. (**required**) |
| `min_seconds_sale` | `integer` | (**required**) |
| `min_seconds_shop` | `integer` | The same for a shop order. (**required**) |
| `allow_paid_search` | `boolean` | False when partners may not bid on your brand in paid search. (**required**) |
| `campaign_allowlist` | `string[]` | Campaign ids that are allowed when paid search is not. (**required**) |
| `hold_new_partners` | `boolean` | A promoter's first days in the program (90 by default) count toward the score, and their earnings wait a little longer. On unless you switch it off. (**required**) |

### RiskPolicyFields

| Field | Type |  |
|---|---|---|
| `hold_at` | `integer` | (>= 1; <= 100) |
| `review_at` | `integer` | Never below `hold_at`. (>= 1; <= 100) |
| `extra_hold_days` | `integer` | (>= 0; <= 365) |
| `min_seconds_lead` | `integer` | (>= 0; <= 3600) |
| `min_seconds_sale` | `integer` | (>= 0; <= 3600) |
| `min_seconds_shop` | `integer` | (>= 0; <= 3600) |
| `allow_paid_search` | `boolean` |  |
| `campaign_allowlist` | `string[]` | (<= 50 items) |
| `hold_new_partners` | `boolean` |  |

### RiskPolicyUpdate

| Field | Type |  |
|---|---|---|
| `policy` | `any` | The fields to change: only those you send change, and one sent as null goes back to the platform's value. `policy: null` clears every field the program set. |
| `alert_email` | `boolean` |  |

### RiskPolicyView

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `policy` | `RiskPolicy` | (**required**) |
| `custom` | `RiskPolicyFields` | (**required**) |
| `alert_email` | `boolean` | Email the program's managers when a promoter needs a look (at most once per promoter a day). (**required**) |

### SaleItem

| Field | Type |  |
|---|---|---|
| `product` | `string` | Your product id, or Stripe's `prod_…`. (max 200) |
| `price` | `string` | Your price or plan id, or Stripe's `price_…`. (max 200) |
| `plan` | `string` | A plan name in your own words, matched without regard to case. (max 200) |
| `interval` | `string` | (one of `month`, `year`, `one_time`) |
| `quantity` | `integer` | (>= 1) |
| `amount` | `integer` | This line's share of the sale, minor units. (**required**; >= 0) |

### SecondTierSettings

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `second_tier` | `any` | Null while it is off. (**required**) |
| `rule` | `string \| null` | The rule in one sentence (**required**) |
| `terms_version` | `integer` | (**required**) |
| `terms_required_version` | `integer \| null` | The terms version partners must accept before their next payout. (**required**) |
| `clause` | `string` | The paragraph the terms carry while it is on. (**required**) |
| `fence` | `string` | Why it is limited the way it is. (**required**) |

### SecondTierUpdate

| Field | Type |  |
|---|---|---|
| `enabled` | `boolean` | (**required**) |
| `percent_bps` | `integer` | (>= 1; <= 2000) |
| `months` | `integer \| null` | (>= 1; <= 24) |

### SetupPart

| Field | Type |  |
|---|---|---|
| `part` | `string` | (**required**; one of `tag`, `billing`, `signups`, `sales`, `codes`, `panel`, `welcome`) |
| `label` | `string` | (**required**) |
| `state` | `SetupState` | (**required**; one of `connected`, `warnings`, `not_connected`) |
| `skipped` | `boolean` | The program's own setup does not use this part; it does not count toward `overall`. (**required**) |
| `summary` | `string` | One sentence about what was seen (**required**) |
| `fix` | `string \| null` | What to do next; null when nothing is needed. (**required**) |
| `warnings` | `object[]` | (**required**) |

### SetupPayments

One of: `stripe`, `paddle`, `polar`, `whop`, `revenuecat`, `woocommerce`, `fastspring`, `lemonsqueezy`, `shopify`, `chargebee`, `recurly`, `gumroad`, `own_system`, `not_sure`, `null`.

### SetupSignups

One of: `own_backend`, `supabase`, `firebase`, `clerk`, `authjs`, `nocode`, `checkout_only`, `null`.

### SetupState

One of: `connected`, `warnings`, `not_connected`.

### ShareChannel

A share button. `native` is the device's own share sheet.

One of: `native`, `whatsapp`, `x`, `linkedin`, `facebook`, `messenger`, `telegram`, `sms`, `email`.

### ShopifyCodesResult

| Field | Type |  |
|---|---|---|
| `shop` | `string \| null` | The linked shop's myshopify.com name. (**required**) |
| `created` | `integer` | Discounts made in the shop now. (**required**) |
| `matched` | `integer` | Codes the shop already had with the same text (**required**) |
| `deactivated` | `integer` | Codes switched off in Bolt Claw (**required**) |
| `failed` | `integer` | Codes the shop refused or did not answer for. They still work as codes Bolt Claw knows. (**required**) |
| `remaining` | `integer` | Codes waiting for the next call. (**required**) |
| `reason` | `string \| null` | Why nothing could be synced: no linked shop, no friend's discount rule, or the shop must open the app again. (**required**; one of `not_linked`, `no_friend_discount`, `reconnect`, `null`) |

### StripeCodesResult

| Field | Type |  |
|---|---|---|
| `coupon` | `string` | The coupon behind every code (**required**) |
| `created` | `integer` | Promotion codes made in Stripe now. (**required**) |
| `linked` | `integer` | Codes Stripe already had with the same text (**required**) |
| `failed` | `integer` | Codes Stripe refused. They still work as codes Bolt Claw knows. (**required**) |

### TagCheck

| Field | Type |  |
|---|---|---|
| `result` | `string` | (**required**; one of `found_v2`, `found_v1`, `not_found`, `refused`, `unreachable`) |
| `version` | `string \| null` | (**required**; one of `v1`, `v2`, `null`) |
| `consent` | `boolean \| null` | Whether the tag waits for consent (`data-consent="required"`). (**required**) |
| `payment_links` | `string \| null` | Stripe Payment Links or a Buy Button on the page: `none`, `ready` (the version 2 tag has `data-payment-links="auto"`, which is the whole integration for them) or `missing` (there are some and the tag does not reach them; the sentence says what to add). Null when the page was not read. (**required**; one of `none`, `ready`, `missing`, `null`) |
| `sentence` | `string` | A fixed sentence to show. (**required**) |
| `checked_at` | `date-time` | (**required**) |

### TaxFormStatus

| Field | Type |  |
|---|---|---|
| `partner_id` | `uuid` | (**required**) |
| `display_name` | `string` | (**required**) |
| `origin` | `string` | `managed`: one of your own users (a referrer). (**required**; one of `account`, `managed`) |
| `form` | `TaxFormSummary` | A promoter's latest form for your business, by type and status; null when they have none. Never its contents. (**required**) |
| `requirement_met` | `boolean` | False while their payouts wait for a form. (**required**) |
| `waiting_since` | `date-time \| null` | Since when their approved money has waited for a form; null when nothing waits. (**required**) |
| `forfeitable_minor` | `integer` | The approved money waiting for a form, in minor units, which a person may forfeit. (**required**) |

### TaxFormStatusList

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `tax_forms` | `TaxRequirement` | Whether a program's payouts wait for a tax form. `off`: never. `us_only`: a US person needs a W-9, and anyone else signs a short statement that they are not a US person. `all`: everyone needs a W-9, W-8BEN or W-8BEN-E. (**required**; one of `off`, `us_only`, `all`) |
| `data` | `TaxFormStatus[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |
| `disclaimer` | `string` | (**required**) |

### TaxFormSummary

A promoter's latest form for your business, by type and status; null when they have none. Never its contents.

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `type` | `TaxFormType` | `w9`: Form W-9, from a US person. `w8ben`: Form W-8BEN, from a person outside the US. `w8bene`: Form W-8BEN-E, from an organisation outside the US. `foreign_statement`: a short statement that they are not a US person, which a program set to `us_only` accepts instead of a W-8. (**required**; one of `w9`, `w8ben`, `w8bene`, `foreign_statement`) |
| `name` | `string` | The form's name, such as Form W-9. (**required**) |
| `revision` | `string` | The IRS revision it was built from. (**required**) |
| `status` | `string` | `superseded`: replaced after a change of name, tax number or address, and not signed again yet. `expired`: a W-8 past its last day. `invalid`: it no longer matches what was signed; ask them to sign again. (**required**; one of `signed`, `superseded`, `expired`, `invalid`) |
| `signed_at` | `date-time` | (**required**) |
| `valid_until` | `date-time \| null` | A W-8's last day (31 December of the third year after it was signed). Null for a W-9 or the statement. (**required**) |

### TaxFormType

`w9`: Form W-9, from a US person. `w8ben`: Form W-8BEN, from a person outside the US.
`w8bene`: Form W-8BEN-E, from an organisation outside the US. `foreign_statement`: a short
statement that they are not a US person, which a program set to `us_only` accepts instead
of a W-8.

One of: `w9`, `w8ben`, `w8bene`, `foreign_statement`.

### TaxProfileStatus

Whether your own tax details are complete. Never the details themselves: your EIN, legal
name and addresses are entered and read in the dashboard only.

| Field | Type |  |
|---|---|---|
| `country` | `string \| null` | Your business's country, two letters. (**required**) |
| `domestic_vat_bps` | `integer \| null` | Your own VAT rate in basis points (2000 is 20%), used for a promoter in your own country. (**required**) |
| `payer_complete` | `boolean` | Your 1099 payer details (legal name, EIN, US address, telephone) are entered. The 1099 files need them. (**required**) |
| `invoice_complete` | `boolean` | Your invoicing details (legal name, address, country, VAT number) are entered. Self-billing needs them. (**required**) |
| `updated_at` | `date-time \| null` | (**required**) |

### TaxRequirement

Whether a program's payouts wait for a tax form. `off`: never. `us_only`: a US person needs
a W-9, and anyone else signs a short statement that they are not a US person. `all`:
everyone needs a W-9, W-8BEN or W-8BEN-E.

One of: `off`, `us_only`, `all`.

### TaxSettings

| Field | Type |  |
|---|---|---|
| `program_id` | `uuid` | (**required**) |
| `tax_forms` | `TaxRequirement` | Whether a program's payouts wait for a tax form. `off`: never. `us_only`: a US person needs a W-9, and anyone else signs a short statement that they are not a US person. `all`: everyone needs a W-9, W-8BEN or W-8BEN-E. (**required**; one of `off`, `us_only`, `all`) |
| `invoice_required` | `boolean` | Each payout waits for the promoter's own invoice. A promoter who self-bills never waits for one. (**required**) |
| `self_billing` | `boolean` | You issue self-billed invoices for the promoters who accept your self-billing agreement. (**required**) |
| `profile` | `TaxProfileStatus` | Whether your own tax details are complete. Never the details themselves: your EIN, legal name and addresses are entered and read in the dashboard only. (**required**) |
| `providers` | `NecProvider[]` | Which payout providers count toward your 1099-NEC. (**required**) |
| `sources_note` | `string` | (**required**) |
| `disclaimer` | `string` | BoltClaw prepares these forms; it does not give tax advice or file anything for you. (**required**) |

### TaxSettingsUpdate

Send only the fields you want to change. An empty object is `400`.

| Field | Type |  |
|---|---|---|
| `tax_forms` | `TaxRequirement` | Whether a program's payouts wait for a tax form. `off`: never. `us_only`: a US person needs a W-9, and anyone else signs a short statement that they are not a US person. `all`: everyone needs a W-9, W-8BEN or W-8BEN-E. (one of `off`, `us_only`, `all`) |
| `invoice_required` | `boolean` |  |
| `self_billing` | `boolean` | Turning it on needs your invoicing details first (`409` until then). |

### TaxYear

| Field | Type |  |
|---|---|---|
| `year` | `integer` | (**required**) |
| `threshold_minor` | `integer \| null` | The year's 1099-NEC threshold in US cents (200000 for 2026). Null until it is set for the year, and until then no IRS file can be made for it. (**required**) |
| `payer_complete` | `boolean` | Your 1099 payer details are entered. The files need them. (**required**) |
| `recipients` | `TaxYearRecipient[]` | (**required**) |
| `counts` | `object` | (**required**) |
| `providers` | `NecProvider[]` | (**required**) |
| `sources_note` | `string` | (**required**) |
| `disclaimer` | `string` | (**required**) |

### TaxYearRecipient

One person your programs paid in the year (every membership of one account together), and
whether they belong on your 1099-NEC. No tax number and no legal name.

| Field | Type |  |
|---|---|---|
| `reference` | `string` | Their reference in the files (`P-` and ten characters). Never an account id. (**required**) |
| `partner_ids` | `uuid[]` | Their memberships in your programs. (**required**) |
| `display_name` | `string` | (**required**) |
| `origin` | `string` | (**required**; one of `account`, `managed`) |
| `form` | `TaxFormSummary` | A promoter's latest form for your business, by type and status; null when they have none. Never its contents. (**required**) |
| `included` | `boolean` | On your 1099-NEC file. (**required**) |
| `listed_for_backup_withholding` | `boolean` | No W-9 or no tax number, and something reportable was paid: listed for your accountant with a backup withholding mark (24%). BoltClaw does not withhold. (**required**) |
| `reason` | `NecReason` | Why a person is on your 1099-NEC file or not. `reason_text` says it in a sentence. (**required**; one of `reportable`, `below_threshold`, `backup_withholding`, `not_us_person`, `corporation`, `no_form`, `nothing_reportable`, `threshold_unset`) |
| `reason_text` | `string` | (**required**) |
| `warnings` | `object[]` | (**required**) |
| `reportable_minor` | `integer` | US cents paid through the providers you report. (**required**) |
| `excluded_minor` | `integer` | US cents paid through the providers you leave out, such as PayPal. (**required**) |
| `other_currencies` | `CurrencyTotal[]` | Paid in other currencies through the providers you report. Never counted; listed for you to convert. (**required**) |
| `by_provider` | `object[]` | How they were paid in the year. (**required**) |
| `e_delivery` | `boolean` | They agreed to receive their Copy B electronically. Otherwise, send them paper. (**required**) |

### TermsDraft

| Field | Type |  |
|---|---|---|
| `terms` | `string` | The assembled terms. Edit them as you wish before saving. (**required**) |
| `settings` | `object` | What was read from the program's own settings, which the terms describe. (**required**) |
| `clauses` | `string[]` | The template's clauses the draft holds (**required**) |
| `notice` | `string` | Based on Bolt Claw's template. Not legal advice; have it checked for the countries your partners live in. (**required**) |

### TermsDraftRequest

| Field | Type |  |
|---|---|---|
| `answers` | `object` | (**required**) |

### TestDataCleared

| Field | Type |  |
|---|---|---|
| `conversions` | `integer` | (**required**) |
| `commissions` | `integer` | (**required**) |
| `grants` | `integer` | (**required**) |
| `customers` | `integer` | (**required**) |
| `clicks` | `integer` | (**required**) |
| `partners` | `integer` | (**required**) |

### TestSale

| Field | Type |  |
|---|---|---|
| `run_id` | `string` | (**required**) |
| `steps` | `TestSaleStep[]` | (**required**) |

### TestSaleStep

| Field | Type |  |
|---|---|---|
| `step` | `string` | (**required**; one of `partner`, `click`, `lead`, `sale`, `refund`) |
| `run_id` | `string` | (**required**) |
| `summary` | `string` | (**required**) |
| `partner` | `object \| null` | (**required**) |
| `conversion` | `any` | (**required**) |
| `offer` | `any` | (**required**) |
| `webhooks` | `object` | (**required**) |
| `test` | `boolean` | (**required**; one of `true`) |

### WaitlistEnded

| Field | Type |  |
|---|---|---|
| `forgotten` | `integer` | How many entries' addresses and links were deleted by this call. (**required**) |
| `ended_at` | `date-time \| null` | When the waitlist ended (the first end (**required**) |

### WaitlistEntry

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `external_id` | `string` | Your own id for the person; the hosted page makes one, `wl_…`. (**required**) |
| `name` | `string \| null` | (**required**) |
| `code` | `string \| null` | (**required**) |
| `link_url` | `string \| null` | Their link to invite friends. (**required**) |
| `source` | `string` | (**required**; one of `api`, `hosted`) |
| `status` | `WaitlistEntryStatus` | (**required**; one of `waiting`, `invited`, `joined`) |
| `position` | `integer \| null` | Their place in line; null until they confirm. (**required**) |
| `friends` | `integer` | Confirmed friends who count. (**required**) |
| `verified` | `boolean` | (**required**) |
| `disqualified` | `boolean` | (**required**) |
| `disqualify_reason` | `string \| null` | (**required**) |
| `flags` | `string[]` | `velocity`: one of a flood from one address; `self`: the referrer themselves. Either moves nobody. (**required**) |
| `joined_at` | `date-time` | (**required**) |
| `invited_at` | `date-time \| null` | (**required**) |
| `referred_by` | `object \| null` | (**required**) |

### WaitlistEntryDetail

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `external_id` | `string` | Your own id for the person; the hosted page makes one, `wl_…`. (**required**) |
| `name` | `string \| null` | (**required**) |
| `code` | `string \| null` | (**required**) |
| `link_url` | `string \| null` | Their link to invite friends. (**required**) |
| `source` | `string` | (**required**; one of `api`, `hosted`) |
| `status` | `WaitlistEntryStatus` | (**required**; one of `waiting`, `invited`, `joined`) |
| `position` | `integer \| null` | Their place in line; null until they confirm. (**required**) |
| `friends` | `integer` | Confirmed friends who count. (**required**) |
| `verified` | `boolean` | (**required**) |
| `disqualified` | `boolean` | (**required**) |
| `disqualify_reason` | `string \| null` | (**required**) |
| `flags` | `string[]` | `velocity`: one of a flood from one address; `self`: the referrer themselves. Either moves nobody. (**required**) |
| `joined_at` | `date-time` | (**required**) |
| `invited_at` | `date-time \| null` | (**required**) |
| `referred_by` | `object \| null` | (**required**) |
| `state` | `any` | (**required**) |

### WaitlistEntryList

| Field | Type |  |
|---|---|---|
| `data` | `WaitlistEntry[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |
| `total` | `integer` | Entries matching the filter. (**required**) |
| `line` | `integer` | Confirmed entries (**required**) |

### WaitlistEntryStatus

One of: `waiting`, `invited`, `joined`.

### WaitlistEntryUpdate

| Field | Type |  |
|---|---|---|
| `verified` | `boolean` |  |
| `status` | `WaitlistEntryStatus` | (one of `waiting`, `invited`, `joined`) |

### WaitlistEntryUpsert

| Field | Type |  |
|---|---|---|
| `external_id` | `string` | (**required**; min 1; max 200) |
| `name` | `string \| null` | (max 200) |
| `email` | `string` | Kept only as a keyed fingerprint for the self-referral check. (max 320) |
| `code` | `string` | A vanity code for their own link, on creation only. (max 40) |
| `referral_code` | `string` | The code of the link they arrived with. (max 40) |
| `verified` | `boolean` |  |
| `status` | `WaitlistEntryStatus` | (one of `waiting`, `invited`, `joined`) |

### WaitlistFields

A waitlist program's settings, merged onto the current ones.

| Field | Type |  |
|---|---|---|
| `bump` | `integer` | Places a confirmed friend moves their referrer up (default 5). (>= 1; <= 1000) |
| `page` | `boolean` | Switch the hosted page at `/r/w/<token>` on (default false). |
| `pitch` | `string \| null` | (max 500) |
| `closed` | `boolean` |  |

### WaitlistInvite

| Field | Type |  |
|---|---|---|
| `entry_ids` | `uuid[]` | (**required**; <= 500 items; >= 1 items) |
| `subject` | `string` | One line. (**required**; min 1; max 140) |
| `message` | `string` | The broadcasts' Markdown subset: paragraphs, **bold**, *italic*, lists and https links. (**required**; max 6000) |

### WaitlistInvited

| Field | Type |  |
|---|---|---|
| `invited` | `integer` | (**required**) |
| `emailed` | `integer` | Entries from the hosted page (**required**) |
| `entry_ids` | `uuid[]` | (**required**) |

### WaitlistPageAddress

| Field | Type |  |
|---|---|---|
| `page_url` | `string` | The hosted page's new address. It is unguessable and noindex. (**required**) |

### WelcomeConfig

The welcome's look and words. Text is shown as plain text, never markup: angle brackets,
controls and invisible marks are refused. The call to action needs both its label and its
address.

| Field | Type |  |
|---|---|---|
| `style` | `string` | (one of `bar`, `corner`, `sheet`) |
| `position` | `string` | (one of `top`, `bottom`) |
| `accent` | `string` | A colour like `#1a2b3c`. |
| `radius` | `integer` | (>= 0; <= 24) |
| `theme` | `string` | (one of `light`, `dark`, `auto`) |
| `headline` | `string` | Your headline. `{name}` is the inviter (or "A friend"), `{offer}` the gift. (max 120) |
| `conditions` | `string` | The one line of conditions. (max 160) |
| `cta_label` | `string` | (max 40) |
| `cta_url` | `string` | An https address. (max 2048) |

### WelcomeSettings

The friend's welcome, as you set it.

| Field | Type |  |
|---|---|---|
| `config` | `WelcomeConfig` | The welcome's look and words. Text is shown as plain text, never markup: angle brackets, controls and invisible marks are refused. The call to action needs both its label and its address. (**required**) |
| `show_name` | `boolean` | Whether a referrer's name (or an affiliate's, when they agreed) is shown to the friend. (**required**) |
| `page` | `boolean` | Whether a person opening the link first sees a one-screen welcome before your product. (**required**) |
| `allowed_hosts` | `string[]` | Hosts your website tag may read the offer from, beside the product URL's own. (**required**) |

<!-- https://boltclaw.ai/docs/api/referrals as markdown. Generated from openapi.yaml; API version 1.22.0. -->
<!-- Every /docs page answers as markdown: add .md to its path, or send Accept: text/markdown. -->
