Skip to content

Boards API

The endpoints behind workflow boards. Authentication, project scoping and rate limits are the same as the rest of the API: see API for agents.

Base URL: https://leadhunter.api.humans2agents.com/api

General rules:

  • A board or card in a project you don’t belong to answers 404, and so does an id that isn’t a valid id.
  • Every query parameter is validated: a bad value answers 400 keyed by the parameter’s name.
  • Board endpoints spend no AI credits and share the normal per-user rate limit.

GET /api/boards/templates/ lists the templates, latest version of each:

FieldMeaning
key, version, label, descriptionoutreach (Plain outreach) and backed_trial (Backed trial).
stageskey, label, terminal, optional, color, owner_role, outcomes (the outcomes a closing stage accepts).
roleskey, label, description. Plain outreach: seller. Backed trial: preparer, seller, approver.
timing_defaultsDays per timing key. Plain outreach: follow_up_days (7). Backed trial: ready_review_days (7), never_ready_days (14), contact_review_days (3), removal_after_days (28).
facts_schemaThe template’s extra card fields: {name: {kind, max_length}}.
viewsThe template’s preset queue views (key, label); [] when it has none.
suggested_goalsThe campaign purposes the template suits.

Read stages, views and each card’s allowed_moves instead of hard-coding the rules: a template’s rules belong to its version, and a card keeps the version it started with.

EndpointWhat it does
GET /api/boards/?project=The project’s boards, oldest first. project is required (slug or id). ?campaign=<id,...> narrows to those campaigns (a campaign has at most one board, so this is how you find a campaign’s board); ?workflow=<key> narrows to a template. Each row: id, campaign (id, name, goal, archived), workflow_key, workflow_version, workflow_label, counts per stage ("" = Not started, every unstarted member), created_at. Paginated with page and page_size (up to 100).
POST /api/boards/Create a campaign’s board: {"campaign": "<id>", "template": "<key>"}, or {"campaign": "<id>", "copy_from": "<board id>"} to copy another board of the same project (template, version and settings; never cards, reservations or history). 201 with the board.
GET /api/boards/{id}/The board: campaign, workflow (the template for the board’s version), settings, columns and exclusions.
PATCH /api/boards/{id}/{"settings": {...}} only; {"settings": null} resets them. Any other field is a 400.
DELETE /api/boards/{id}/Deletes the board while no card has started (204); 409 cards_started otherwise.
GET /api/boards/combined/{workflow_key}/?project=Every non-archived board of the project on that template, as one board (below).

Errors on create: 400 keyed campaign (missing, not an id, or not a campaign of your projects), template (unknown), copy_from (not a board of the campaign’s project), detail (neither or both of template / copy_from), or any other field (“Unknown field.”). 409 {"code": "board_exists", "detail": "…", "board_id": "<id>"} when the campaign already has a board.

{
"id": "…",
"campaign": {"id": "…", "name": "…", "goal": "sales", "archived": false},
"workflow": {"key": "outreach", "version": 1, "stages": ["…"]},
"settings": {},
"columns": [
{"key": "", "label": "Not started", "count": 214, "color": "slate", "terminal": false, "cards": ["…"], "next": "20"},
{"key": "selected", "label": "Selected", "count": 3, "color": "blue", "terminal": false, "cards": ["…"], "next": null}
],
"exclusions": {"reserved_elsewhere": 2, "rejected": 11, "deferred": 4, "do_not_contact": 1}
}
  • Each column holds up to 20 cards. next is a cursor for the rest of the column (see Column requests), or null.
  • Not started (key: "") lists the campaign’s unstarted accounts, best fit score first, minus the exclusions: accounts held by another backed-trial card, judged not a fit or deferred in review, or do-not-contact. It takes the filters search, review_status, score_label, score_band, min_score, max_score.
  • Column labels follow the board’s settings; an optional column the settings hide is left out while it is empty.
  • ?today=YYYY-MM-DD sets your local day for “due” and “overdue”.

Presentation and timing only; the template owns the rules. Unknown keys answer 400 keyed settings with every problem listed.

{
"labels": {"<stage>": "up to 40 characters"},
"colors": {"<stage>": "slate | blue | amber | green | red | purple | teal"},
"hidden": ["<optional stage>"],
"timing": {"<timing key>": 7},
"owners": {"<role>": "<user id, a member of the project>"},
"views": [{"name": "up to 40 characters", "filters": {"<card list param>": "value"}}]
}
  • timing values are days, 1 to 365, keyed by the template’s timing_defaults.
  • owners maps a role to the person who gets that role’s next steps. A role with no owner leaves its steps unassigned (they show in the unassigned queue view). A copied board drops owners who are no longer members.
  • views (at most 20) are saved card-list filters. They take the card list parameters except board, project, page, page_size and cursor.

GET /api/boards/combined/{workflow_key}/?project= joins every non-archived board of the project on one template. Same shape as a board, without id, campaign and settings, plus:

  • boards: [{"id", "campaign": {"id", "name"}}].
  • Every card carries its campaign; every column carries by_campaign (campaign_id, name, count).
  • In Not started, an account that belongs to several of those campaigns appears once (its best-scoring membership is the card) with campaigns: [{"campaign_id", "name", "campaign_account_id", "board_version"}], every eligible membership, oldest first. To start it, pick one and send move/ to that campaign_account_id with that board_version. This column’s count and cursor count accounts, not memberships.
  • ?campaign=<id,...> narrows to some of the campaigns; the Not started filters work as on a board.
  • An unknown template answers 400 keyed workflow.

A card is a campaign account of a campaign that has a board. Its id is the campaign account id.

EndpointWhat it does
GET /api/board-cards/Card list (below). Needs board=<id> or project=; an unknown one answers 400 keyed by it.
GET /api/board-cards/{id}/One card (?today= accepted).
GET /api/board-cards/{id}/history/stage_history, milestones, deadlines, events and commands (command_id, kind, status_code, created_at, by).

404 for a campaign account outside your projects or whose campaign has no board.

  • stage (csv; none = Not started), workflow, campaign (csv), outcome (csv), blocked=true|false
  • owner=<user id> or owner=me: the assignee of an open board step, or the owner of the blocker
  • overdue=true: an open deadline or board step due before today; deadline_before=YYYY-MM-DD
  • milestone=<key> with milestone_after= / milestone_before=
  • search, and the review filters review_status, score_label, score_band, min_score, max_score
  • today=YYYY-MM-DD: your local day, for “due” and “overdue”
  • view= a queue view (with project=): mine (your steps and blockers), team, overdue, blocked, unassigned (started, open, with nobody assigned), decisions_due (backed-trial removal reviews), plus each template’s preset views (backed trial: ready_uncontacted, uncertain_creation, removal_decisions, cleanup_pending). A started card stays in team until it is closed and its trial cleanup is settled.

Order: Not started by fit score, best first (unscored last); started cards by how long they have been in their column (longest first); queue views by the earliest due work.

Every card list answers:

{"count": 57, "next": "https://…/api/board-cards/?…&cursor=20", "previous": null, "next_cursor": "20", "results": ["…"]}
  • cursor: the opaque position to continue from (a board column’s next, or a page’s next_cursor). Without it, page (1-based, default 1).
  • page_size: 1 to 100, default 20.
  • next_cursor is null on the last page. A page past the end is an empty list (200). A bad cursor, page or page_size answers 400 keyed by it.

A request with exactly one stage (none included), no view, and scoped by board= (or by project= plus workflow=) returns that column exactly as the board or the combined view shows it: same cards, same order, same count. Send a column’s next back as ?cursor= to load more of it.

So on a column request Not started leaves out the exclusions; with project= plus workflow= only non-archived boards count (narrow with campaign) and Not started lists one card per account with campaigns. A stage the template doesn’t have answers 400 keyed stage.

Any other request is the plain filtered list: no exclusions, archived campaigns included, one card per campaign membership.

{
"id": "…", "board_id": "…",
"campaign": {"id": "…", "name": "…", "archived": false},
"account": {"id": "…", "name": "…", "city": "…", "country": "…", "status": "prospect", "website": "…"},
"workflow": {"key": "backed_trial", "version": 1},
"stage": "ready", "stage_label": "Ready to contact",
"stage_changed_at": "…", "days_in_stage": 3, "stage_overdue": false,
"outcome": "", "started_at": "…", "closed_at": null,
"board_version": 4,
"blocked": null,
"score": {"value": 8.5, "label": "excellent", "stale": false},
"review_status": "approved",
"next_step": {"id": "…", "text": "…", "due_on": "…", "assignee": {"id": "…", "email": "…"}, "side": "ours", "board_created": true},
"open_next_steps": 2,
"last_message": {"id": "…", "direction": "outbound", "channel": "email", "sent_at": "…", "snippet": "…", "classification": ""},
"milestones": {"first_contact": {"occurred_at": "…", "source": "auto"}},
"deadlines": [{"key": "contact_review", "label": "…", "due_on": "…", "overdue": false}],
"facts": {},
"reservation": {"id": "…", "operation_id": "…", "submission_state": "created", "batch_ref": "…", "radio_pk": "…", "radio_codename": "…", "status": "active", "cleanup_state": "none", "cleanup_trial_ref": ""},
"suggestions": [{"kind": "confirm_reply", "message": "…"}],
"warnings": [{"kind": "other_campaign", "campaign_id": "…", "campaign_name": "…"}],
"allowed_moves": [{"to": "contacted", "needs": ["contact"]}]
}
  • blocked: null or {reason, owner: {id, email}, checked_at, review_on}.
  • next_step: the board’s open step, else the earliest open step on the account (board_created: false), else null.
  • reservation: null except on backed-trial cards. operation_id is stable for the reservation: pass it to your own system as the idempotency key when you create the trial.
  • suggestions:
    • confirm_reply: an inbound message logged by an agent, waiting for someone to classify it.
    • account_signal (status, optional purpose): the account became a customer, was lost or opted out. The board never closes the card by itself.
    • suggested_outcome (outcome: "declined"): in removal review after a refusal.
  • warnings: other_campaign (the account is also being worked in another campaign), first_contact_unknown.
  • allowed_moves: the manual moves the template allows from this stage. needs lists the evidence each requires; alternatives are joined with | (reply|reason). When they apply: optional (evidence accepted but not required, e.g. demo_on), outcomes and outcome_needs ({"won": ["conversion"]}) for a closing stage, and unavailable (why the move can’t be made yet, e.g. “The trial isn’t created yet.”).
  • Not started cards have stage: "", board_version: 0, and also conversations_count and open_next_steps counted across every campaign, so you can see an account already being worked elsewhere.

Every card write is a POST /api/board-cards/{id}/<command>/ with the command envelope:

{"board_version": 4, "command_id": "my-agent:card-123:contact:1", "to": "contacted", "evidence": {"message": "…"}}
  • board_version: the card’s board_version as you read it.
  • command_id: your own id for this command, 1 to 64 characters of A-Z a-z 0-9 _ . : -. Make it deterministic, and reuse it when you retry the same command.

Processing, in order, with the card locked:

  1. Replay: a command_id already used on this card with the same body returns the stored answer (same status code, same body) and changes nothing. With a different body: 400 {"command_id": ["This command id was already used with a different body."]}.
  2. Stale: a board_version different from the card’s answers 409 {"detail": "The card changed since you loaded it.", "code": "stale", "card": {...}}. Read the current card in the response, decide again, and send with its board_version and a new command_id. Stale answers are not stored.
  3. Apply: on success the card’s board_version goes up by one and the response is the updated card. Validation errors (400) and the other conflicts (409) are stored too, so retrying the same command gets the same answer.

A field the command doesn’t take answers 400 keyed by it (“Unknown field.”); so does a body that isn’t a JSON object, and a body containing a NUL character. A card’s board_version also goes up when an automatic move changes it, so read the card again after logging a message.

CommandBodyEffect
move/to, outcome?, reason?, evidence?, occurred_at?A manual move the template allows. Starting a card is a move/ out of Not started (board_version 0). A closing stage needs outcome. occurred_at (ISO date-time, not in the future) dates the facts the move records; default now.
correct/to, reason (required)Back to an earlier stage, or reopen a closed card into an open stage. Recorded as a correction; never clears recorded facts, never releases a reservation, never sets an outcome. Reopening clears the outcome and the closing date. Not for a Not started card, and not to a closing stage.
block/reason, owner?, review_on? — or {"clear": true}Sets or clears the blocked flag.
evidence/milestone, occurred_at, evidence?, message?, reason?Records or corrects a fact (a milestone). The old value stays in the history as replaced; the deadlines that depend on it are recalculated.
link-message/message, as (contact, reply or refusal)Links a message logged without a campaign (or one of this campaign) as evidence; may apply the matching automatic move.
adopt-step/step, obligationLinks an existing open next step on the account, not made by a board, as the board’s step for that obligation, without changing its owner or date.
facts/facts (partial, merged)The template’s extra card fields, validated against facts_schema. Backed trial: udon_url (the trial’s URL, up to 500 characters) and notes (up to 500).
reservation/submission_state, batch_ref?, radio_pk?, radio_codename?Backed trial only (below).
cleanup/action (approve_removal, confirm_removed or keep), reason?Backed trial only, while the card is in removal review (below).

block/, evidence/, link-message/ and adopt-step/ need a started card (400 detail otherwise).

A move’s evidence is an object keyed by kind, and the move’s reason satisfies a rule that accepts reason:

{"to": "closed", "outcome": "won", "evidence": {"conversion": {"won_on": "2026-10-28", "reference": "Invoice 2026-114"}}}

When the move takes exactly one kind of evidence (reason aside), you may send that kind’s object flat: {"evidence": {"message": "<id>"}} means {"evidence": {"contact": {"message": "<id>"}}} on a move to Contacted.

KindShape
contact{"message": "<id>"} (a sent outbound of this card, or one linked to it) or {"contacted_on": "YYYY-MM-DD", "channel": "<message channel>", "reference": "up to 200 characters"}.
reply{"message": "<inbound message id>"}
readiness{"demo_link": "<http(s) URL>", "draft_link": "<URL>"} or {"demo_link": "…", "draft_message": "<message id of this account>"}; optional checked_at (defaults to now, not in the future). The demo link and a draft are both required. The draft message may be a draft or a message already sent, logged without a campaign or on this card, but not one of another campaign.
conversion{"won_on": "YYYY-MM-DD", "reference": "invoice / order / note, up to 200 characters"}, optional message. Required for the won outcome; never inferred from the account’s status.
demo_onA date (plain outreach, moving to Demo; optional).

The evidence/ command takes the fact’s kind object directly, without the kind key.

Errors are 400 {"evidence": {"<field>": ["…"]}}, with dotted field names for a sub-field (contact.contacted_on), or the flat field name (contacted_on) when you sent the evidence flat.

Plain outreach (outreach):

FromToNeeds
Not startedselectedNothing.
selectedcontactedcontact (or automatic).
contactedrepliedreply or reason (or automatic).
replieddemoOptional demo_on.
replied, demotrialNothing.
any open stageclosedoutcome: won (needs conversion), lost, no_response or not_a_fit.

Backed trial (backed_trial):

FromToNeeds
Not startedreservedTakes the account’s reservation; 409 reserved_elsewhere if another backed-trial card holds it.
reservedmaturingThe reservation’s submission_state is created.
maturingreadyreadiness: a demo link and a draft of the first message. Manual only.
readycontactedcontact (or automatic).
contactedconversationreply or reason (or automatic).
contacted, conversationremoval_reviewreason (or automatic: the removal deadline, or a refusal).
maturing, readyremoval_reviewreason.
reservedfinishedOutcome cancelled, only while the reservation is not_submitted or failed; releases it.
removal_reviewfinishedOutcome declined, not_converted or no_response; the trial must be removed or kept first.
any open stagefinishedOutcome won with conversion; the trial is kept.

A backed-trial card holds an exclusive reservation on its account across every backed-trial board of the project, from the start until the trial is settled. Only one active reservation per account exists; when two people start the same account at once, one succeeds and the other gets 409 reserved_elsewhere with the holder.

reservation/ records what happened when you asked your system to create the trial:

  • not_submitted → uncertain when you sent the request but don’t know if it worked (a timeout). Check your system before doing anything else; don’t retry blindly.
  • created once the trial exists; needs radio_pk (the trial’s id in your system) or batch_ref. Then the card can move to maturing.
  • failed when it didn’t work; a failed reservation may go back to not_submitted to try again, with the same operation_id.

The reservation is released only by finishing with cancelled, or when the card is finished and its trial was removed or kept. Archiving the campaign or finishing the card alone never releases it.

cleanup/ settles the trial in removal review:

  • approve_removal: approves removing the trial the reservation points to now.
  • confirm_removed: the trial was removed. Refused with 409 trial_changed when the reservation now points to another trial than the approved one.
  • keep: keep the trial; needs reason.
  • approve_removal and confirm_removed answer 409 converted if the account became a customer meanwhile.

Logging a sent message on a card’s campaign account (or linking one with link-message/) updates the card shortly after: missing facts are recorded with the message’s date, and the card moves forward by the template’s automatic moves. Never backwards, never out of a closed stage, never over a date someone set by hand, and not forward again after a correct/. Only messages sent at or after the card started count (for a backed trial, at or after the reservation). A message logged late with an earlier date moves the first-contact date back. An hourly check catches up deadlines that came due and anything it missed.

Inbound messages:

  • POST /api/messages/{id}/classify/ with {"classification": "reply" | "refusal" | "bounce" | "auto_reply" | ""}. Any member of the project. Setting a value needs a sent inbound message; "" (or null) clears it. A missing or unknown value answers 400 keyed classification. Inbound POST /api/messages/ also accepts classification.
  • reply counts as a reply; refusal as a refusal; bounce and auto_reply never count.
  • Unclassified: a reply logged by a person counts as a reply. One logged by an agent (an API-only bot user) adds a confirm_reply suggestion to the card and changes nothing until someone classifies it.

Agents logging replies should classify them when they can tell, or leave them for a person to confirm.

When a card enters some columns, or a deadline comes due, the board creates a next step for the role’s owner (for example “Contact account” on Selected, “Follow up with account” at the follow-up deadline). It completes its own steps when the fact they ask for is recorded, and cancels them when the card closes. It never edits or closes a step it didn’t create: link one with adopt-step/ instead. No outreach step is created while the account is do-not-contact.

GET /api/boards/{id}/metrics/?weeks=12 and GET /api/boards/combined/{workflow_key}/metrics/?project=&weeks=12&campaign=:

{
"weeks": [{"week_start": "YYYY-MM-DD", "counts": {"started": 3, "first_contact": 2, "outcome_won": 1}}],
"totals": {},
"rates": {
"contact_rate": {"numerator": 8, "denominator": 10, "value": 0.8},
"reply_rate": {"numerator": 3, "denominator": 8, "value": 0.375},
"win_rate": {"numerator": 1, "denominator": 10, "value": 0.1}
},
"ready_uncontacted": [{"campaign_account_id": "…", "account_name": "…", "ready_since": "…", "days": 4}],
"median_days_to_win": 21,
"by_campaign": [{"campaign_id": "…", "name": "…", "totals": {}}]
}
  • weeks: 1 to 104 (larger is capped; below 1 or not a number answers 400). today= accepted.
  • Weeks start on Monday. Week counts: started, blocked, each fact (current dates only: a corrected date counts once, at its corrected week) and outcome_<name>.
  • Rates only cover cards that started at least 28 days ago (the board’s removal days on backed trials). contact_rate = contacted / started, reply_rate = replied / contacted, win_rate = won / started.
Statuscode or bodyWhen
400{"to": [...]}A move the template doesn’t allow from the card’s column.
400{"evidence": {...}}Missing or invalid evidence.
400{"command_id": [...]}A reused command_id with a different body.
400{"<field>": ["Unknown field."]}A field the command or endpoint doesn’t take.
400{"detail": "…"}A command that needs a started card, on a Not started one; or an account merge refused because both accounts have started cards on the same board, or both hold an active trial reservation.
404—A board or card outside your projects, a campaign without a board, an invalid id.
409staleboard_version doesn’t match; the current card is in card.
409reserved_elsewhereAnother backed-trial card holds the account. holder: campaign_id, campaign_name, campaign_account_id, stage.
409convertedapprove_removal or confirm_removed on an account that became a customer.
409cleanup_unresolvedFinishing a removal-review card before its current trial was removed or kept (a removal confirmed for another trial doesn’t count).
409trial_changedconfirm_removed for another trial than the one approved.
409trial_removedChanging the trial (radio_pk / batch_ref) after its removal was confirmed.
409board_existsCreating a second board for a campaign; board_id is the existing one.
409cards_startedDeleting a board that has started cards.
409card_startedRemoving an account from a campaign while its card has started. Close it on the board. (Bulk removal of low-fit accounts skips such rows and reports kept_started_cards.)
409board_in_progressDeleting a campaign whose board has open cards or an active reservation. Archive it instead.
409reservation_activeDeleting an account that holds an active trial reservation.

Undoing an import whose accounts have started cards or active reservations is also refused (400, nothing deleted).