# Campaign API


The Campaign API lets a program read a Campaign's definition, check a candidate change, create a Campaign or apply a change to its draft, and run a launch that a person has confirmed. It is a separate contract from subscription data delivery. Delivered records come from [the data API](/api/api-data), and a data-delivery Key carries none of the grants below.

## Hosts, credential, and grants

Use `https://api.humanbased.ai` with a Key issued for production, or `https://api.staging.humanbased.ai` with a staging Key. Every operation is under `/api/v1/campaignapi/` and takes the header `Authorization: Bearer <API_KEY>`.

An organization member creates the Key and chooses its Campaign grants in the portal; [Create and use an API Key](/api/api-auth) walks through the form. The server checks each grant on its own.

| Grant | The Key may | Reach |
| --- | --- | --- |
| `campaign:read` | List and read Campaigns, their versions, and templates. | A read-only Key reaches every Campaign in its organization. |
| `campaign:author` | Validate and plan a candidate, create a Campaign, apply a change to a draft, and read apply receipts. | The Campaigns named in the Key's `managed_campaign_ids`: the ones its creator could edit when the Key was created, plus any Campaign the Key creates. |
| `campaign:launch` | Propose a launch, read back its own proposal, and execute it once a person has confirmed it. | The same list, within the spend cap recorded on the Key. |

A Campaign outside the Key's organization or outside its list answers `404` with `CAMPAIGN_NOT_FOUND`, the same answer as a Campaign that does not exist.

The Key form issues `campaign:author` and `campaign:launch` only to a member who is an editor or owner of at least one Campaign in the organization. Create an organization's first Campaign in the Developer portal.

## Check identity and read scope

Start with a read-only identity check:

```sh
curl --silent --show-error \
  'https://api.humanbased.ai/api/v1/auth/whoami' \
  --header "Authorization: Bearer $HB_API_KEY"
```

Read `data.org_id`, `data.scopes`, and `data.managed_campaign_ids` before continuing. Keep the Key in an environment variable or secret store, never in a copied command or URL. Then list the Campaigns visible to that identity:

```sh
curl --silent --show-error \
  'https://api.humanbased.ai/api/v1/campaignapi/campaigns' \
  --header "Authorization: Bearer $HB_API_KEY"
```

Every response carries `success`, `errorCode`, `errorMessage`, and `data`. The Campaign list accepts optional `status`, `limit` (1–200), and opaque `cursor` query parameters. Read `data.items`; if `data.has_more` is true, pass `data.next_cursor` unchanged in the next request. An empty page alone is not the end or proof that the organization has no Campaigns. Keep identifiers as strings.

## Choose the operation

| Purpose | API operation | Required grant |
| --- | --- | --- |
| Find a template a Campaign File may reference | `GET /api/v1/campaignapi/templates`, `template`, `catalog` | `campaign:read` |
| List or inspect Campaigns and stored versions | `GET /api/v1/campaignapi/campaigns`, `campaign`, `versions`, `version`, `versiondiff` | `campaign:read` |
| Check a candidate without changing state | `POST /api/v1/campaignapi/candidatevalidate`, `candidateplan` | `campaign:author` |
| Create a Campaign, or apply a change to a draft | `POST /api/v1/campaignapi/candidateapply` | `campaign:author` |
| Read the receipt of an earlier apply | `GET /api/v1/campaignapi/actionreceipt` | `campaign:author` |
| Propose a launch, and read the proposal back | `POST /api/v1/campaignapi/launchpropose`, `GET /api/v1/campaignapi/launchproposal` | `campaign:launch` |
| Confirm a proposed launch | `POST /api/v1/campaignapi/launchconfirm` | A signed-in person; a Key is refused |
| Execute a confirmed launch | `POST /api/v1/campaignapi/launch` | `campaign:launch` |

Identifiers travel in the query string of a `GET` and in the JSON body of a `POST`. The [CLI](/api/cli#commands-for-campaign-writes-and-launch) wraps the same operations as commands.

## Write a Campaign File

A candidate is a Campaign File: one JSON object that holds the whole Campaign definition. Every write sends the complete document, not a patch.

| Section | Holds |
| --- | --- |
| `spec_version` | The string `"1.0"`. |
| `campaign` | The Campaign's `name`. The server assigns `campaign_id`, `owner`, and `created_at`. |
| `eligibility` | Who may contribute: `gates`, `kyc_tier` (`none`, `light`, or `full`), and `reputation`. |
| `workflow` | The steps, as `nodes`, and the order between them, as `next`. |
| `reward_policy` | The `base` reward, its `factors`, and its `caps`. |
| `quotas` | `budget_usd`, the total the Campaign may spend, and `per_node` limits. |
| `data_contract` | `scarcity_strata` and `lineage`. |
| `audit` | A one-sentence `intent`, and `versions`. |

All eight sections are required. The platform publishes no standalone JSON Schema for this document: `candidatevalidate` is the authority, and it reports each problem by path.

To change an existing Campaign, read its latest version back and edit that document, as the next section shows. To create one, start from the document below. It describes one contributor step behind an access gate, with each submission reviewed by a person. The name, amounts, and counts are illustrations; decide your own. Take `template_id` from `GET /api/v1/campaignapi/templates`.

```json
{
  "spec_version": "1.0",
  "campaign": { "name": "Returns triage, round 1" },
  "eligibility": { "gates": [], "kyc_tier": "none", "reputation": {} },
  "workflow": {
    "nodes": [
      { "key": "gate", "node_key": "access_gate", "params": { "spec_version": 1 } },
      {
        "key": "collect",
        "node_key": "human_supply",
        "params": {
          "form_template_ref": { "template_id": "784210002001" },
          "task": { "totalCap": 200, "perUserQuota": 20 },
          "reward": { "asset_code": "USDC", "amount": 0.5 }
        }
      },
      { "key": "accept", "node_key": "data_accept", "params": { "acceptance": { "mode": "manual" } } }
    ],
    "next": [
      { "from": "gate", "to": "collect" },
      { "from": "collect", "to": "accept" },
      { "from": "accept", "to": "done" }
    ]
  },
  "reward_policy": {
    "base": { "asset_code": "USDC", "amount": 0.5 },
    "factors": [],
    "caps": {}
  },
  "quotas": { "per_node": {}, "budget_usd": 100 },
  "data_contract": { "scarcity_strata": [], "lineage": {} },
  "audit": {
    "intent": "Collect triage decisions for returned-goods cases.",
    "versions": []
  }
}
```

In the `collect` step, `task.totalCap` is how many submissions the step takes from all contributors together, and `task.perUserQuota` is how many one contributor may make. Both count tasks in progress and submissions waiting for review. A submission that is rejected or found to be a duplicate stops counting, which frees its place. `reward.amount` is what one accepted submission pays.

In the `accept` step, `acceptance.mode` set to `manual` holds every submission until a person reviews it. Without that setting the step accepts each submission when it arrives.

`hb campaign init --template` writes the same sections and steps, including `task` and `acceptance`, with a `<DECIDE: …>` placeholder in place of each value you must decide.

## Validate and plan a candidate

Both operations read; neither writes. Save the document above as `candidate.json` in a private working directory, then build the two request bodies for a new Campaign:

```sh
python3 - <<'PY'
import json
from pathlib import Path

candidate = json.loads(Path('candidate.json').read_text())
Path('validate.json').write_text(json.dumps({'candidate': candidate}))
Path('plan.json').write_text(json.dumps({'mode': 'create', 'candidate': candidate}))
PY
```

To plan a change instead, set `HB_CAMPAIGN_ID` to a Campaign the Key may edit and read its latest version:

```sh
curl --fail-with-body --silent --show-error --get \
  'https://api.humanbased.ai/api/v1/campaignapi/version' \
  --header "Authorization: Bearer $HB_API_KEY" \
  --data-urlencode "campaign_id=$HB_CAMPAIGN_ID" \
  --output version.json
```

`data.version.version_number` and `data.version.spec_hash` identify the base. `data.campaign_file` is the complete stored document, and `data.server_assigned_fields` names the keys under `campaign` that a candidate must leave out. This illustrative edit changes only the name:

```sh
python3 - <<'PY'
import json
from pathlib import Path

response = json.loads(Path('version.json').read_text())
assert response['success'] and response['data']['campaign_file']
data = response['data']
candidate = data['campaign_file']
for field in data.get('server_assigned_fields', []):
    candidate['campaign'].pop(field, None)
candidate['campaign']['name'] = 'Reviewed example name'
Path('validate.json').write_text(json.dumps({'candidate': candidate}))
Path('plan.json').write_text(json.dumps({
    'mode': 'update', 'campaign_id': data['campaign_id'],
    'candidate': candidate,
    'expected_version_number': data['version']['version_number'],
    'expected_spec_hash': data['version']['spec_hash'],
}))
PY
```

Send either pair of bodies the same way:

```sh
curl --fail-with-body --silent --show-error \
  'https://api.humanbased.ai/api/v1/campaignapi/candidatevalidate' \
  --header "Authorization: Bearer $HB_API_KEY" \
  --header 'Content-Type: application/json' --data-binary @validate.json \
  --output validation.json
curl --fail-with-body --silent --show-error \
  'https://api.humanbased.ai/api/v1/campaignapi/candidateplan' \
  --header "Authorization: Bearer $HB_API_KEY" \
  --header 'Content-Type: application/json' --data-binary @plan.json \
  --output plan-result.json
```

| File | Check | Meaning |
| --- | --- | --- |
| `validation.json` | `data.valid` | `true` when every check passed. |
| `validation.json` | `data.checks` | One entry each for `schema`, `compile`, and `dependencies`, with `ran`, `passed`, and `violations`. A violation names a `path`, a stable `code`, and a `message`. |
| `validation.json` | `data.candidate_hash` | The identity of the document you sent. |
| `plan-result.json` | `data.applicable` | `true` when the candidate is valid and, for a change, the base version is still a draft. |
| `plan-result.json` | `data.base` | The version the plan compared against; `null` for a new Campaign. |
| `plan-result.json` | `data.changes`, `data.change_count` | Each change as `op` (`add`, `remove`, or `replace`), `path`, and `before` or `after`. |
| `plan-result.json` | `data.findings` | The same validation result, repeated for this plan. |

A plan with `applicable: false` is not ready to apply. If the base version or hash has moved, the plan is refused with `BASE_VERSION_STALE`: read the new version and review a new candidate and plan.

## Create a Campaign

After the accountable person approves the document, add a `request_key` to the create plan body and send it to `candidateapply`. Choose the key yourself: a stable string of up to 200 characters that names this one write.

```sh
python3 - <<'PY'
import json
from pathlib import Path

body = json.loads(Path('plan.json').read_text())
assert body['mode'] == 'create'
body['request_key'] = 'create-returns-triage-2026-10-10'
Path('apply.json').write_text(json.dumps(body))
PY
curl --fail-with-body --silent --show-error \
  'https://api.humanbased.ai/api/v1/campaignapi/candidateapply' \
  --header "Authorization: Bearer $HB_API_KEY" \
  --header 'Content-Type: application/json' --data-binary @apply.json \
  --output receipt.json
```

A create body holds `mode`, `candidate`, and `request_key`. It names no `campaign_id` and no expected base.

One create performs these steps in order, under the one `request_key`:

| Step | Effect |
| --- | --- |
| Allocate | A draft Campaign in the Key's organization, owned by the person who created the Key, with the budget from `quotas.budget_usd`. |
| Provision | The Campaign's Pipeline, holding only an entry gate. A launch writes your draft's steps into it. |
| Store | The document, as version 1 in the `draft` state. |
| Grant | The new Campaign is added to the Key's `managed_campaign_ids`, so the same Key can change and launch it. `granted_to_key` reports whether this step landed. |

The response is a receipt. Hashes are shortened in this illustration:

```json
{
  "data": {
    "replayed": false,
    "action_id": "784210008001",
    "action": "campaignfile.apply",
    "status": "succeeded",
    "request_key": "create-returns-triage-2026-10-10",
    "request_hash": "5d0c…a917",
    "campaign_id": "784210001001",
    "result": {
      "campaign_id": "784210001001",
      "external_pipeline_id": "784210011001",
      "file_version_id": "784210007001",
      "version_number": 1,
      "persisted_file_hash": "8a41…c2f0",
      "state": "draft",
      "granted_to_key": true
    }
  },
  "success": true,
  "errorCode": 0,
  "errorMessage": "SUCCESS"
}
```

Keep `data.campaign_id`: every later operation names it. `data.result.granted_to_key: false` means the Campaign exists and this Key was not given it; change it in the Developer portal or with a replacement Key that names it. To confirm the create, read `GET /api/v1/campaignapi/campaign?campaign_id=<campaign_id>` and check that `data.campaign.status` is `draft`.

`persisted_file_hash` differs from the `candidate_hash` you validated, because the stored document includes the server-assigned fields.

## Apply a change to a draft

Add a `request_key` to the update plan body and send it to the same operation. An update body holds `mode`, `campaign_id`, `candidate`, `expected_version_number`, `expected_spec_hash`, and `request_key`.

A successful update stores the candidate as that Campaign's newest draft version and changes nothing else. The receipt has the shape shown above; `data.result.version_number` is the new version, and `data.result` carries no `granted_to_key`.

Both pins are compared again at the moment of the write. If another editor saved first, the request is refused with `409` and `BASE_VERSION_STALE`, and nothing is written: read the new version, rebuild the candidate, and plan again.

## Read the receipt and recover

A `200` from `candidateapply` is always a receipt with `status: succeeded`. Any other outcome arrives as an error response; the receipt lookup below shows the other three statuses.

| Receipt field | Meaning |
| --- | --- |
| `replayed` | `true` when this response describes an earlier attempt under the same `request_key`, not work done now. A receipt lookup always returns `true`, whatever that attempt's `status`. |
| `action_id` | The server's identifier for this write. |
| `action` | `campaignfile.apply` for a create or an update. |
| `status` | `succeeded`, `in_progress`, `failed`, or `uncertain`. |
| `request_key`, `request_hash` | Your key, and the server's hash of the whole request. |
| `campaign_id`, `result` | The Campaign written, and what the write produced. |
| `steps`, `uncertain_reason` | Present while a write is unresolved or failed: how far it got, and why the outcome is unknown. |
| `in_progress_since` | Present while `status` is `in_progress`: when the write last made progress. |

The `request_key` is what makes a retry safe. Keep the request body and its key together, and decide by what the retry returns:

| You see | What happened | What to do now |
| --- | --- | --- |
| No response at all | The write may or may not have landed. | Read `GET /api/v1/campaignapi/actionreceipt?request_key=<your-key>`, or resend the identical body with the same key. |
| A receipt with `replayed: true` and `status: succeeded` | The first attempt succeeded; nothing was written again. | Use this receipt. |
| `409` `ACTION_IN_FLIGHT`, or a receipt with `status: in_progress` | A request is still writing under this key. | Wait, then read the receipt. If it is still `in_progress` ten minutes after `in_progress_since`, resend the identical body with the same key. |
| `409` `REQUEST_KEY_SPENT`, or a receipt with `status: failed` | The earlier attempt failed and wrote nothing. | Fix the cause and send again with a new key. |
| `409` `REQUEST_KEY_REUSED` | This key already named a different request. | Send the original body to retry it, or choose a new key for the new request. |
| `409` `ACTION_UNCERTAIN`, or a receipt with `status: uncertain` | A write was attempted and its outcome is unknown. | Stop. A person reads the receipt's `steps`, which name any Campaign a create already allocated, and the Campaign's current version before deciding. A new key would be a second write. |

`actionreceipt` takes exactly one of `request_key` or `action_id`. A lookup by `request_key` searches Campaign File applies unless you add `action=template.apply`. It does not find a launch.

## Launch a Campaign

A launch moves a draft Campaign to `live` and locks its budget from the organization's balance. A Key cannot decide that alone: the Key proposes, a person confirms the exact proposal, and the Key executes it.

Before you propose, check four things:

| Precondition | Where it is decided |
| --- | --- |
| The Key holds `campaign:launch` and the Campaign is in its `managed_campaign_ids`. | The Key's grant, read with `whoami`. |
| The latest stored version has a step where work enters, and every contributor step has a form. | The Campaign File. |
| The Campaign has a budget, and the organization's available balance covers it. | The proposal's `amount_usd`, and the organization's Billing page. |
| The budget fits the room left under the Key's spend cap. | The cap set when the Key was created. |

### 1. Propose

```sh
curl --fail-with-body --silent --show-error \
  'https://api.humanbased.ai/api/v1/campaignapi/launchpropose' \
  --header "Authorization: Bearer $HB_API_KEY" \
  --header 'Content-Type: application/json' \
  --data "{\"campaign_id\": \"$HB_CAMPAIGN_ID\"}" \
  --output proposal.json
```

A proposal writes one record. No money moves and the Campaign does not change. Save the response: it is the only place the `proposal_id` is returned.

```json
{
  "data": {
    "proposal_id": "784210010001",
    "campaign_id": "784210001001",
    "operation": "launch",
    "state": "proposed",
    "amount_usd": "100.0000",
    "base_version_number": 1,
    "base_spec_hash": "8a41…c2f0",
    "proposal_hash": "clp1_77be…09d3",
    "expires_at": "2026-10-10T10:30:00+00:00",
    "confirmed_by": null,
    "confirmed_at": null,
    "executed_action_id": null
  },
  "success": true,
  "errorCode": 0,
  "errorMessage": "SUCCESS"
}
```

`amount_usd` is the budget the launch would commit, as a string with four decimal places. `base_version_number` and `base_spec_hash` name the draft it would run. `proposal_hash` binds all of them together. `expires_at` is one hour after the proposal was made; confirmation and execution both have to happen before it.

A Key holds one open proposal per Campaign. A second `launchpropose` for the same Campaign is refused with `PROPOSAL_OPEN_ALREADY` until the first is executed or expires.

### 2. A person confirms

Send the person who owns the decision to the Developer portal's confirmation page:

```text
https://developer.humanbased.ai/campaigns/<campaign_id>/launch/confirm?proposal=<proposal_id>
```

They sign in as an editor or owner of that Campaign. The page shows the Campaign, the amount, the draft version, the expiry, and the proposal hash as the server holds them. Selecting **Confirm this launch** records their agreement to that exact hash. It does not launch.

Read `GET /api/v1/campaignapi/launchproposal?proposal_id=<proposal_id>` with the Key that proposed. Continue when `data.state` is `confirmed`.

### 3. Execute

```sh
python3 - <<'PY'
import json
from pathlib import Path

proposal = json.loads(Path('proposal.json').read_text())['data']
Path('launch.json').write_text(json.dumps({
    'proposal_id': proposal['proposal_id'],
    'request_key': 'launch-returns-triage-2026-10-10',
}))
PY
curl --fail-with-body --silent --show-error \
  'https://api.humanbased.ai/api/v1/campaignapi/launch' \
  --header "Authorization: Bearer $HB_API_KEY" \
  --header 'Content-Type: application/json' --data-binary @launch.json \
  --output launch-receipt.json
```

The body holds `proposal_id` and a `request_key` you choose. Only the Key that proposed may execute. Before any money moves, the server recomputes the proposal hash from the Campaign as it is now. A budget, draft, or payment term changed after the confirmation makes the launch a different action, and it is refused with `PROPOSAL_STALE`.

A successful launch returns a receipt with `action: campaign.launch` and `status: succeeded`. Its `data.result` holds `campaign_id`, the Campaign's new `status`, the `proposal_id`, and `confirmed_by`.

### Confirm or recover a launch

| You see | What happened | What to do now |
| --- | --- | --- |
| A receipt with `status: succeeded` | The Campaign launched and its budget is locked. | Confirm with `GET /api/v1/campaignapi/campaign`: `data.campaign.status` is `live`. The proposal's `state` is `executed`. |
| No response at all | The launch may or may not have run. | Resend the identical body with the same `request_key`. The server replays the first receipt and launches nothing twice. `actionreceipt?request_key=` does not find a launch. |
| `PROPOSAL_NOT_CONFIRMED` | No person has confirmed yet. | Ask the person to confirm, then send the same body again. |
| `PROPOSAL_EXPIRED` or `PROPOSAL_REVOKED` | The proposal is closed. | Propose again and have the new proposal confirmed. |
| `PROPOSAL_STALE` | The Campaign changed after the confirmation. | Propose again once this proposal's `expires_at` has passed; until then a new proposal is refused with `PROPOSAL_OPEN_ALREADY`. |
| `SPEND_CAP_EXCEEDED` | The launch would exceed the Key's spend cap. No money moved. | Have a person launch the Campaign in the Developer portal, or propose again with a Key whose cap has room. |
| `CAMPAIGN_GRAPH_MALFORMED` | The draft cannot run as stored: the Pipeline refused its steps, or a form template it names is missing or empty. No money moved. | Apply a corrected draft. Propose again once this proposal's `expires_at` has passed. |
| `503` `CAMPAIGN_GRAPH_UNAVAILABLE` | The Pipeline did not take the draft. No money moved, and the Campaign is not `live`. | Send the launch again with a new `request_key`. The proposal is still confirmed. |
| `PROPOSAL_ALREADY_EXECUTED` | This proposal already ran, or is running. | Read the proposal and the Campaign's status. Launch nothing again. |
| `409` `ACTION_UNCERTAIN` | The launch was attempted and its outcome is unknown; the budget may be locked. | Stop. Read `launchproposal`: `executed` means the launch finished, `executing` means it is unresolved. A person checks the Campaign's status and the organization's Billing page, then contacts [hello@humanbased.ai](mailto:hello@humanbased.ai) with the `proposal_id`. A new key would be a second launch. |

## Errors

A refusal has `success: false`, `data: null`, an `errorCode` equal to the HTTP status, and an `errorCodeName`. Branch on `errorCodeName`. `errorMessage` is usually prose and can be reworded. For `CANDIDATE_INVALID` it is an object instead, carrying the validation result:

```json
{
  "data": null,
  "success": false,
  "errorCode": 400,
  "errorCodeName": "CANDIDATE_INVALID",
  "errorMessage": {
    "error": "CANDIDATE_INVALID",
    "message": "the candidate did not pass validation, so nothing was applied",
    "findings": {
      "valid": false,
      "checks": [
        {"check": "schema", "ran": true, "passed": false, "violations": [{"path": "…", "code": "…", "message": "…"}]}
      ],
      "candidate_hash": "…"
    }
  }
}
```

`findings` is the object `candidatevalidate` returns in `data`; the example shortens it. Check whether `errorMessage` is a string before you print it.

| `errorCodeName` | What happened | What to do now |
| --- | --- | --- |
| `KEY_CAPABILITY_MISSING` | The Key lacks the grant this operation needs. | Create a Key with that grant. |
| `KEY_HAS_NO_CAMPAIGN_ALLOWLIST` | The Key can read the organization's Campaigns and names none it may change. | Create a replacement Key with the write grant. |
| `KEY_HAS_NO_ACCOUNTABLE_PERSON` | The Key records nobody who created it, so a Campaign it made would have no owner. | Create a replacement Key. |
| `CAMPAIGN_NOT_FOUND` | The Campaign does not exist, or this Key cannot see it. | Check the ID against `campaigns` and `managed_campaign_ids`. |
| `CANDIDATE_MALFORMED` | The candidate is not a JSON object shaped like a Campaign File. | Fix the document's structure. |
| `CANDIDATE_TOO_LARGE` | The candidate exceeds a size limit: 1,048,576 bytes, 20,000 JSON values, 64 nesting levels, 256 workflow nodes, or 1,024 edges. | Shrink the document. |
| `CANDIDATE_INVALID` | The candidate was checked and failed. Nothing was applied, and the `request_key` is still unused. | Read `errorMessage.findings`, fix each path, and send again. |
| `EXPECTED_VERSION_REQUIRED`, `EXPECTED_SPEC_HASH_REQUIRED` | An update did not pin its base. | Send both values from the version you read. |
| `EXPECTED_BASE_ON_CREATE` | A create named a `campaign_id` or an expected base. | Remove them. |
| `REQUEST_KEY_REQUIRED` | A write arrived with no `request_key`, or a blank one. | Choose a key and send it. |
| `CAMPAIGN_HAS_NO_VERSION` | An update named a Campaign with no version to compare against. | Read the Campaign's versions before planning. |
| `BASE_VERSION_STALE` | The base moved after you read it. | Read the new version, rebuild the candidate, and plan again. |
| `CONTENT_IS_HISTORICAL` | This exact content is already an older version, and a newer one is current. Nothing changed. | Send a document that differs from that older version. |
| `PLATFORM_NOT_CONFIGURED` | The platform is missing configuration a create needs. Nothing was created. | Contact [hello@humanbased.ai](mailto:hello@humanbased.ai); a retry will not help. |
| `DEVELOPER_SESSION_REQUIRED` | A Key called `launchconfirm`. | Have a person confirm in the Developer portal. |
| `PROPOSAL_NOT_FOUND` | No such proposal, or another Key made it. | Use the Key that proposed, and the `proposal_id` it received. |
| `PROPOSAL_HASH_MISMATCH` | The hash sent with a confirmation is not the stored proposal's. | Read the proposal again and confirm the hash it shows. |
| `SPEND_CAP_UNAVAILABLE` | The Key may launch, and its spend cap could not be read. Nothing launched. | Send the same request again; if it persists, create a replacement Key. |
| `ACTION_NOT_FOUND` | No receipt matches that `action_id` or `request_key` for this Key. | Check the handle and the `action` it belongs to. |
| `ACTION_LOOKUP_REQUIRED` | A receipt lookup named neither, or both, of `action_id` and `request_key`. | Send exactly one of them. |
| `CAMPAIGN_GRAPH_NO_WORK_ORIGIN` | The graph has no task or import node through which work can enter. | Add one, save a new version, and launch again. |
| `CAMPAIGN_ID_REQUIRED` | The operation acts on one Campaign and none was named. | Send the `campaign_id`. |
| `CAMPAIGN_ROLE_INSUFFICIENT` | Your role on this Campaign is below what the operation requires. | Ask the Campaign's owner for a role that allows it. |
| `CAMPAIGN_VERSION_NOT_FOUND` | No such version of this Campaign. | Read `versions` and use a listed version. |
| `CLI_SESSION_NOT_PERMITTED` | A signed-in CLI may not perform this operation. | Use an organization API Key for it; signing in again does not change this. |
| `CURSOR_INVALID` | The cursor is not one this endpoint issued. | Start again without a cursor and follow the ones it returns. |
| `KEY_PRINCIPAL_INCOMPLETE` | The gateway forwarded an incomplete Key identity. This is a platform fault, not a bad Key. | Retry; contact [hello@humanbased.ai](mailto:hello@humanbased.ai) if it persists. |
| `LIMIT_OUT_OF_RANGE` | `limit` is outside the range this endpoint accepts. | Use a value in range, such as 1–200 for `campaigns`. |
| `MODE_UNSUPPORTED` | The mode is not one this operation offers. | Use a mode the operation lists. |
| `OBSERVATION_OWNER_DISAGREES` | This Campaign names a pipeline its owner does not have. This is a provisioning fault, not a bad request. | Contact support with the Campaign ID. |
| `OBSERVATION_OWNER_UNAVAILABLE` | The service that owns these counts did not answer. The request was not refused. | Retry the read. |
| `OBSERVATION_REFUSED_BY_OWNER` | The service that owns these counts refused the request without naming a reason. | Contact support with the Campaign ID and the time. |
| `OBSERVATION_WINDOW_INVALID` | The observation window or pipeline version was refused; `errorMessage` quotes the value. | Correct that value and read again. |
| `ORG_ID_REQUIRED` | The caller resolved to no organization, so there is nothing to read within. | Use a Key or sign-in that belongs to an organization. |
| `ORG_MISMATCH` | This CLI session is signed in to a different organization than the request named. | Sign in to that organization, or drop the explicit organization. |
| `PIPELINE_VERSION_NOT_FOUND` | That pipeline version does not belong to this Campaign's pipeline. | Use a version from this Campaign. |
| `PORTAL_SESSION_NOT_PERMITTED` | A launch through this API is bound to the organization API Key or signed-in CLI that proposed it, and a Developer portal session is neither. | Launch from the Campaign page in the Developer portal, or propose and execute the launch with the same Key or CLI session. |
| `REQUEST_MALFORMED` | The request did not parse: a field is missing, of the wrong type, or out of range. | Read `errorMessage`, fix the field, and send again. |
| `TEMPLATE_INVALID` | The template was checked and something in it is wrong. | Fix each path-addressed finding, the same ones `templatevalidate` returns, and send again. |
| `TEMPLATE_KIND_IMMUTABLE` | A template's kind is fixed when it is created. | Create a new template instead. |
| `TEMPLATE_MALFORMED` | The body is not a template document at all. | Fix the request's structure. |
| `TEMPLATE_NOT_EDITABLE` | This organization can read the template but not change it. | Adopt it into your organization, then edit the copy. |
| `TEMPLATE_NOT_FOUND` | No such template, or none this organization may use. | Check the template ID. |
| `TEMPLATE_STALE` | The template changed since you read it. | Read it again, apply your change to what it says now, and send it with the new hash. |
| `ACTION_IN_FLIGHT`, `ACTION_UNCERTAIN`, `REQUEST_KEY_REUSED`, `REQUEST_KEY_SPENT` | A write under a `request_key` is still running, failed, ended with an unknown outcome, or the key already named a different request. | Follow the table in [Read the receipt and recover](#read-the-receipt-and-recover). For `ACTION_UNCERTAIN`, stop and have a person check before anything is sent again. |
| `PROPOSAL_NOT_CONFIRMED`, `PROPOSAL_EXPIRED`, `PROPOSAL_REVOKED`, `PROPOSAL_STALE`, `PROPOSAL_OPEN_ALREADY`, `PROPOSAL_ALREADY_EXECUTED`, `SPEND_CAP_EXCEEDED`, `CAMPAIGN_GRAPH_MALFORMED`, `CAMPAIGN_GRAPH_UNAVAILABLE` | A launch proposal or its execution was refused. | Follow the refusal table in [Launch a Campaign](#launch-a-campaign). |

A refusal raised before the request reaches this API, such as a `401` for a missing or invalid Key, can arrive without an `errorCodeName`. Keep a default branch for it. The same holds for `429` `Too many requests` from the gateway: wait `Retry-After` seconds, as described under [Rate limits](/api/api-fields#rate-limits).

To launch from the Developer portal instead, see [launch and budget checks](/tasks/launch).
