HumanbasedDocs
Developer Guides

Campaign API

Read and change your Campaigns from code, with an Organization API Key.

The Campaign API lets a program do what you would otherwise do in the Developer Portal: list your Campaigns, read the exact file that defines one, check a change before making it, and apply that change once. It is the same API Key and the same host as the rest of the platform.

Authorization: Bearer hb_live_sk_...

Every response arrives in the standard envelope, so the payload is always under data:

{ "data": { }, "success": true, "errorCode": 0, "errorMessage": "SUCCESS" }

Start with whoami

Call this first, and call it again whenever something returns 403.

curl https://api.humanbased.ai/api/v1/auth/whoami \
  -H "Authorization: Bearer $HB_API_KEY"
{
  "principal": "api_key",
  "org_id": "10571000020000000001",
  "api_key_id": "…",
  "api_key_prefix": "hb_live_sk_wwwwwwwww",
  "accountable_auth_id": "…",
  "scopes": ["campaign:read", "campaign:author"],
  "managed_campaign_ids": ["10571000020000000042"]
}

A revoked Key, the wrong Organization, and a capability the Key never had all arrive as the same 403, and they are fixed differently. This is what tells them apart. It needs no capability of its own — requiring one would make the diagnosis need the thing being diagnosed.

managed_campaign_ids is the list of Campaigns this Key may change. null and [] are different answers: null means the Key carries no allowlist, so it reads your Organization's Campaigns and changes none of them, while [] is an allowlist that names nothing. Both refuse a write; only one is fixed by minting a Key that names Campaigns.

Capabilities

A Key carries capabilities, chosen when you mint it, and they do not imply one another. Each operation names exactly the one it needs; a wider grant is not accepted as a substitute. That is what lets you hand an agent a Key narrower than your own access.

CapabilityWhat it allows
campaign:readRead Campaigns, versions, templates, and observation counts
campaign:authorValidate, plan and apply changes to a Campaign's draft

campaign:author is granted together with campaign:read, because a Key that can apply a change and cannot read the result back is of no use to anybody.

The capability is only half of the answer. A Key may also carry a Campaign allowlist, frozen when the Key is minted, and a write is refused unless the Campaign is named in it. Read the Campaign's capabilities in the response — that is the grant intersected with your role on that Campaign, which is the question you actually care about.

Operations

OperationWhat it doesCapability
GET /api/v1/auth/whoamiWhat the platform resolved this caller to be
GET /api/v1/campaignapi/catalogWhich task families this release supportscampaign:read
GET /api/v1/campaignapi/templateOne template's field contractcampaign:read
GET /api/v1/campaignapi/campaignsYour Campaigns, newest firstcampaign:read
GET /api/v1/campaignapi/campaignOne Campaign, and what you may do to itcampaign:read
GET /api/v1/campaignapi/versionsA Campaign's file versions, newest firstcampaign:read
GET /api/v1/campaignapi/versionOne version, with its body and hashcampaign:read
GET /api/v1/campaignapi/versiondiffWhat changed between two stored versionscampaign:read
GET /api/v1/campaignapi/observationWhat ran, in one window, under one pipeline versioncampaign:read
POST /api/v1/campaignapi/candidatevalidateCheck a whole Campaign File; writes nothingcampaign:author
POST /api/v1/campaignapi/candidateplanWhat applying it would change; writes nothingcampaign:author
POST /api/v1/campaignapi/candidateapplyCreate a Campaign, or apply a change to one — once, under a request keycampaign:author
POST /api/v1/campaignapi/launchproposePropose a launch for a person to confirmcampaign:launch
POST /api/v1/campaignapi/launchconfirmA person confirms the exact proposal— (session only)
POST /api/v1/campaignapi/launchExecute a confirmed launch, oncecampaign:launch
GET /api/v1/campaignapi/actionreceiptWhat an action didcampaign:author

Identifiers go in the query string or the body, never in the path.

Changing a Campaign

Four steps: read the version you mean to edit, then validate, plan and apply. Only the last one writes, so run the others as often as you like.

# 0. Read the version you are editing. Keep `version_number` and `spec_hash` —
#    they are how you pin the base in steps 2 and 3.
curl -G https://api.humanbased.ai/api/v1/campaignapi/version \
  -H "Authorization: Bearer $HB_API_KEY" \
  --data-urlencode "campaign_id=…"

Drop the server-assigned fields before you send the document back. The response names them in server_assigned_fields — today campaign_id, owner and created_at, all under campaign — and an apply refuses a candidate carrying any of them, because sending one would let you believe you chose an identity you did not. Read the list from the response rather than hardcoding it:

# The version you read, minus the fields the server owns, ready to edit.
curl -sG https://api.humanbased.ai/api/v1/campaignapi/version \
  -H "Authorization: Bearer $HB_API_KEY" \
  --data-urlencode "campaign_id=…" \
| jq '(.data.server_assigned_fields // error("server_assigned_fields missing")) as $owned
      | .data.campaign_file
      | .campaign |= with_entries(select(.key as $k | $owned | index($k) | not))' \
  > candidate.json

The // error(…) is deliberate: if the field is ever absent, that pipeline stops instead of handing you an unstripped document that the apply will refuse three steps later.

# 1. Does this file pass validation at all?
curl -X POST https://api.humanbased.ai/api/v1/campaignapi/candidatevalidate \
  -H "Authorization: Bearer $HB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"candidate": { }}'

# 2. What would applying it change, against the exact version you read?
curl -X POST https://api.humanbased.ai/api/v1/campaignapi/candidateplan \
  -H "Authorization: Bearer $HB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode":"update","campaign_id":"…","candidate":{ },
       "expected_version_number":4,"expected_spec_hash":"…"}'

# 3. Apply it, once.
curl -X POST https://api.humanbased.ai/api/v1/campaignapi/candidateapply \
  -H "Authorization: Bearer $HB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode":"update","campaign_id":"…","candidate":{ },
       "expected_version_number":4,"expected_spec_hash":"…",
       "request_key":"a-key-you-choose"}'

You choose the request key, and it is what makes a retry safe. Send the same request twice under one key and the second call returns the first call's receipt instead of applying anything again. A server-generated key would make every retry a new action, which is the accident this prevents.

Pin the base you edited with expected_version_number and expected_spec_hash, both read from the version you fetched. If somebody else changed the Campaign in between, the apply is refused rather than silently rebasing your change onto a version you never saw.

When you do not know whether it worked

If the response is lost, ask what happened before you do anything else:

curl -G https://api.humanbased.ai/api/v1/campaignapi/actionreceipt \
  -H "Authorization: Bearer $HB_API_KEY" \
  --data-urlencode "request_key=a-key-you-choose"

The receipt reports one of four statuses, and each has exactly one safe move:

StatusWhat it meansWhat to do
succeededThe apply committed. result is the receipt.Nothing. Read result.
failedNothing committed.Fix the cause and apply again under a new key.
in_progressA writer holds the claim. in_progress_since says when it was last touched.Wait, then re-read the receipt. If it is still in_progress 10 minutes after in_progress_since, re-send the identical apply under the same key (below).
uncertainA write was attempted and its outcome is not known.Read steps to see how far it got, check the campaign, and decide as a human. Never blind-retry, and never reuse the key.

Re-sending the identical apply under the same key is safe, and after 10 minutes it is the recovery. That is what the request key is for: the platform recognises the key, sees the claim has gone quiet, and asks whether that action's write actually landed — versions are content-addressed, so it can answer. You get the receipt the lost response should have carried, or uncertain if nothing landed. It cannot apply twice: a claim that already committed replays its receipt rather than re-running.

Two things are not safe, and they are what the key protects you from:

  • Never retry under a new key. A new key is a new action, so the change applies a second time. This is the accident the whole mechanism exists to prevent.
  • Never change the request. The retry has to be byte-for-byte the same call — same mode, same candidate, same expected_version_number and expected_spec_hash. A different request under the same key is refused (REQUEST_KEY_REUSED), because settling one action using another action's candidate would hand you a receipt for work you did not ask for.

Before those 10 minutes are up an identical retry answers ACTION_IN_FLIGHT rather than recovering, which is deliberate: a writer that is merely slow must be allowed to finish and report its own outcome.

Creating a Campaign

candidateapply takes mode=create. It allocates the Campaign, provisions its Pipeline and writes version 1 as one action under one request key — send no campaign_id and no expected base, because there is nothing to pin yet.

curl -X POST https://api.humanbased.ai/api/v1/campaignapi/candidateapply \
  -H "Authorization: Bearer $HB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode":"create","candidate":{ },"request_key":"a-key-you-choose"}'

The Campaign's name and visibility come from the candidate's campaign section. The receipt names what was made:

{
  "status": "succeeded",
  "result": {
    "campaign_id": "…",
    "external_pipeline_id": "…",
    "version_number": 1,
    "granted_to_key": true
  }
}

granted_to_key is the field to read, and it has three values. A key may only change the Campaigns its allowlist names, and a Campaign you just created was not on that list when the key was minted — so a create through a key adds it.

  • true — go straight on to editing what you made.
  • false — the Campaign is real and this key cannot change it. The Portal can, and so can a replacement key naming it.
  • null — not established. The grant did not answer, or answered with something that decides nothing — a 5xx, a timeout, a rate limit. It is idempotent, so it may or may not have landed. Read the Campaign back through GET /campaign: reaching it means the grant is in place. This is reported rather than guessed, because false would send you to mint a replacement key you may not need.

A key whose grant covers the whole Organization rather than named Campaigns is refused (KEY_HAS_NO_CAMPAIGN_ALLOWLIST): that grant is the read shape, and creating with it would hand the key a write authority it was never minted with.

A Campaign is owned by the person who minted the key, so a key that does not record one cannot create (KEY_HAS_NO_ACCOUNTABLE_PERSON). Keys minted before this was recorded carry no minter; mint a replacement and the new key will. The Campaign's own version history still names the credential that wrote it, so an audit can say which key acted.

If the Campaign is allocated but anything after that fails — its Pipeline cannot be provisioned, or the call times out — the receipt is uncertain, never succeeded or failed. The Campaign exists. Read the receipt by your request key and look at steps; do not create another, which is what a fresh request key would do.

Launching

A launch is the one thing on this API a key cannot decide alone. It is three calls, and the middle one is not yours to make.

# 1. Propose. Writes a proposal and nothing else — no money, no change.
curl -X POST https://api.humanbased.ai/api/v1/campaignapi/launchpropose \
  -H "Authorization: Bearer $HB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"campaign_id":"…"}'

What comes back is what a person has to read, and the hash that binds it:

{
  "proposal_id": "…",
  "campaign_id": "…",
  "amount_usd": "250.0000",
  "base_version_number": 7,
  "base_spec_hash": "…",
  "proposal_hash": "clp1_…",
  "expires_at": "…"
}

2. A person confirms it. launchconfirm is session-only and takes the proposal_hash back — confirming by id alone would agree to whatever the proposal says at that moment. Your key cannot make this call however it is scoped; it answers DEVELOPER_SESSION_REQUIRED. Show the proposal to whoever owns the Campaign.

# 3. Execute what was confirmed, once.
curl -X POST https://api.humanbased.ai/api/v1/campaignapi/launch \
  -H "Authorization: Bearer $HB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"proposal_id":"…","request_key":"a-key-you-choose"}'

Execution recomputes the hash from current state and refuses if it moved. A budget raised or a draft saved after the confirmation makes this a different action (PROPOSAL_STALE) — propose again and have the new one confirmed. A confirmation also expires within the hour: it cannot be banked.

The spend cap

A key carrying campaign:launch must be minted with a spend cap, and the cap is enforced when the launch runs, not merely displayed. A launch that would pass it is refused with SPEND_CAP_EXCEEDED and no money moves; the message says how much room is left.

The cap counts committed money per window, so launches that succeed keep counting against it. A launch that is rolled back hands its room back.

If the cap cannot be read at all, the launch is refused with SPEND_CAP_UNAVAILABLE rather than run without one — an unbounded launch is never the safe reading of a missing limit. This is a transient condition rather than a property of your key: retry, and if it persists the key's cap is not reaching the service that enforces it and the key should be re-minted.

Concurrent launches cannot together exceed the cap. Two that each fit, and together do not, will not both succeed — one is refused. Do not treat a successful launchpropose as room reserved; only the execute reserves.

Errors

Every refusal carries errorCodeName, a stable name. Branch on that, never on errorMessage — the message is prose and gets reworded for readability, which would silently change your program's behaviour.

{
  "data": null,
  "success": false,
  "errorCode": 409,
  "errorMessage": "expected base version 4, but the current version is 5; re-read the version and plan again",
  "errorCodeName": "BASE_VERSION_STALE"
}

The 409 family is the one worth reading carefully, because each member wants a different response from you:

NameWhat to do
BASE_VERSION_STALESomebody moved the base. Re-read and rebase. Usually the same request key still works — a base already stale on arrival is refused before the key is claimed. If the base moved during the apply the key is spent, and a retry answers REQUEST_KEY_SPENT; use a new one.
ACTION_IN_FLIGHTThe same action is running now. Wait and read its receipt; do not re-send.
ACTION_UNCERTAINThe outcome is genuinely unknown. Reconcile from the receipt.
REQUEST_KEY_REUSEDThis key already carried a different payload. Use a new one.
REQUEST_KEY_SPENTThis key's earlier attempt failed and committed nothing. Use a new one.
CONTENT_IS_HISTORICALThis exact content is already an older version. Nothing changed.

A refusal raised before the request reaches the Campaign API — an expired Key, a rate limit — carries a different name or none at all, so keep a default branch.

CANDIDATE_INVALID is the one refusal whose body you should read: its errorMessage is an object carrying findings, each one a path into your file, a code, and a sentence. The full list of finding codes is published in the contract as CampaignFileViolationCode.

What this API does not do yet

Stated plainly so you do not build against a gap:

  • It cannot pause or publish. Those remain Portal actions.
  • Applying a change does not change what is running. An apply writes a new draft version of the Campaign File; the live Pipeline keeps running what it was running until the Campaign is saved in the Portal.

Rate limits

Campaign API requests share the standard per-Key limit of 100 requests per minute. Page through versions and campaigns with the cursor each response returns rather than by raising limit.

On this page