# v2 API beta
Source: https://docs.origami.chat/agents/index
Run Origami's agent from your own code: send a brief, get back a table.
v2 is **deprecated for new integrations**. It stays fully functional, with no
removal date. New work should target the [v3 API](/v3/overview). See the
[v2 → v3 migration guide](/api-v2-to-v3-migration).
The v2 API runs Origami's agent from your code. You send it a brief in plain English,
it does the research and builds a table, and you read the result back.
The core loop uses three objects:
* **[Agent](/agents/reference/create-agent)** — a worker in your org. Create one and reuse it.
* **[Run](/agents/reference/send-run)** — one brief and the work that follows. An agent does one run at a time.
* **[Table](/agents/reference/get-table)** — what the agent builds. Read its rows with [`GET /api/v2/tables/{tableId}/rows`](/agents/reference/list-rows).
The wider API adds workspaces, documents, enrichment runs, campaigns, sequences,
scheduled agents, and projects. See [objects and relationships](/agents/objects)
for the whole map and how everything connects.
## How runs work
Every run is **asynchronous**. `POST /api/v2/agents` and `POST /api/v2/agents/{id}/runs`
respond `202 Accepted` with the run already `running` — the agent works in the background.
You poll [`GET /api/v2/agents/{id}/runs/{runId}`](/agents/reference/get-run) until `status`
is no longer `running`, honoring the `Retry-After` header (15 seconds) while it runs. Runs
typically take 1–5 minutes. See the [run object](/agents/run-object) for the full response
shape.
Pick a model per run with the `model` field — `origami-lite` or `origami-max`. The default
is the highest model your plan unlocks (`origami-lite` on starter, `origami-max` on pro and
above).
## Start here
Start an agent, follow up, poll, and fetch the data — end to end.
The first call, and every field it takes.
Status, actions, tables, and the optional stats and transcript.
Pull the rows the agent built, with typed cells, filters, and CSV.
Want the AI to do the work? Use v2. Already have the data and just need to read it back?
Use [`GET /api/v2/tables/{tableId}/rows`](/agents/reference/list-rows).
# Objects and relationships
Source: https://docs.origami.chat/agents/objects
The first-class objects in the Origami v2 API, how they fit together, and where to start.
The v2 API is organized around a small set of objects: projects, agents, runs,
workspaces, tables, campaigns, and a few more. Every response is one of these
objects (or a list of them), and each object links to the others by id. Learn
the objects once and the endpoints follow — a `GET`, a `POST`, and a delete for
each, all shaped the same way.
If you've used the Stripe API, this will feel familiar: resource-oriented URLs,
self-describing JSON, and one list envelope everywhere.
## How every object is shaped
Three conventions hold across the entire API. Internalize these and you can read
any response without checking the reference.
**Objects name their own type.** Every object carries an `object` field naming
what it is — `"agent"`, `"run"`, `"table"`, `"campaign"`, and so on. You never
have to infer a type from context.
```json theme={null}
{ "object": "agent", "id": "a1b2…", "name": "Austin founders", "workspaceId": "ws_…" }
```
**Lists share one envelope.** Every list endpoint returns the same shape, with
the page under `items` and an opaque `nextCursor`. Some lists add a top-level
`total`.
```json theme={null}
{
"object": "list",
"items": [ { "object": "table", "id": "…" } ],
"nextCursor": "eyJ…",
"url": "/api/v2/tables"
}
```
Pass `nextCursor` back as the `cursor` query parameter to get the next page;
`nextCursor: null` marks the last one. There is no `page`/`pageSize`. See
[reading data](/reading-data) for the full pagination walkthrough.
**Objects reference each other by id.** A run carries an `agentId` and a
`workspaceId`; a table carries a `workspaceId`; a sequence carries a
`campaignId`, `tableId`, and `rowId`. Follow the ids to move between objects.
## The object graph
```mermaid theme={null}
flowchart TD
Account[Account] --- Credits[Credits]
Account --> Project[Project]
Project -.->|x-origami-project| Workspace
Workspace[Workspace] --> Table[Table]
Workspace --> Document[Document]
Workspace --> Agent[Agent]
Workspace --> ScheduledAgent[Scheduled agent]
Agent --> Run[Run]
Table --> Column[Column]
Table --> Row[Row]
Row --> Cell[Cell]
Table --> TableRun[Table run]
TableRun --> EnrichmentRun[Enrichment run]
EnrichmentRun -.->|tableRunId| TableRun
Workspace --> Campaign[Campaign]
Campaign --> Sequence[Sequence]
Sequence --> Step[Step]
```
Read it top-down: your **account** contains **projects**; a project (or the
parent org itself) contains **workspaces**; a workspace holds **tables**,
**documents**, **agents**, and **campaigns**; and the rest hang off those.
## Tenancy: parent org and projects
Every API key is **parent-wide** — it belongs to your parent (agency)
organization and can act on the parent or any of its projects.
A **child org** under your parent — a customer's isolated set of workspaces
and tables. Credits and the concurrency pool stay shared at the parent; a
project can carry an optional `monthlyCredits` budget cap and an `enforcement`
mode — `"hard"` blocks new spend once the cap is reached, `"soft"` only tracks.
Manage projects from the parent with
[`GET /projects`](/agents/reference/list-projects),
[`POST /projects`](/agents/reference/create-project), and
[`GET`](/agents/reference/get-project) /
[`PATCH`](/agents/reference/update-project) /
[`DELETE /projects/{projectId}`](/agents/reference/delete-project).
Your org's plan, capabilities, and workspace usage, from
[`GET /account`](/agents/reference/get-account). Always parent-scoped.
Your credit balance, from [`GET /account/credits`](/agents/reference/get-credits).
Credits are the billing unit for agent runs and enrichment.
To act inside a project, send the `x-origami-project: ` header on any
request. Omit it to act on the parent. The `/projects/*` and `/account`
endpoints ignore the header. See [authentication](/authentication#projects-and-the-x-origami-project-header)
for details.
## Agents and runs
An AI worker in a workspace. You create one, then drive it with runs. An agent
does one run at a time. Endpoints: [`POST /agents`](/agents/reference/create-agent),
[`GET /agents`](/agents/reference/list-agents),
[`GET /agents/{id}`](/agents/reference/get-agent),
[`DELETE /agents/{id}`](/agents/reference/archive-agent).
One prompt and the work that follows it. Runs are asynchronous — you get a
`running` run back immediately and poll until `status` is terminal. Endpoints:
[`POST /agents/{id}/runs`](/agents/reference/send-run),
[`GET /agents/{id}/runs`](/agents/reference/list-runs),
[`GET /agents/{id}/runs/{runId}`](/agents/reference/get-run),
[`POST /agents/{id}/cancel`](/agents/reference/cancel-run). See the
[run object](/agents/run-object) for every field.
A recurring agent that runs on a cron schedule. Full CRUD plus
enable/disable, manual trigger, and run history under
[`/scheduled-agents`](/agents/reference/list-scheduled-agents).
## Data: workspaces, tables, rows
A container for tables, documents, agents, and campaigns. Agents auto-create
one when you don't supply a `workspaceId`. Endpoints:
[`GET /workspaces`](/agents/reference/list-workspaces),
[`POST /workspaces`](/agents/reference/create-workspace),
[`GET /workspaces/{workspaceId}`](/agents/reference/get-workspace),
[`DELETE /workspaces/{workspaceId}`](/agents/reference/delete-workspace).
A set of rows and the columns that enrich them, plus lifetime credit cost.
Read one with [`GET /tables/{tableId}`](/agents/reference/get-table); list
them with [`GET /tables`](/agents/reference/list-tables).
A field on a table, classified by `kind`: `input` (user-entered, the only
writable kind), `enrichment` (runs per row to fetch a value), `score`
(relevance), or `sequence` (drafts outreach). List with
[`GET /tables/{tableId}/columns`](/agents/reference/list-columns).
One record in a table (the wire vocabulary calls these "leads" —
`leadCount`). Cells are keyed by column slug and typed. Read with
[`GET /tables/{tableId}/rows`](/agents/reference/list-rows) or
[`GET /tables/{tableId}/rows/{rowId}`](/agents/reference/get-row). Write with
[`POST /tables/{tableId}/rows/upsert`](/agents/reference/upsert-rows) or the
CSV variant [`.../rows/upsert-file`](/agents/reference/upsert-rows-file).
Bulk soft-delete with
[`DELETE /tables/{tableId}/rows`](/agents/reference/delete-rows) (up to 100
row ids per call).
A single column's value on a single row, with run metadata where present.
Read with
[`GET /tables/{tableId}/rows/{rowId}/cells/{columnId}`](/agents/reference/get-cell).
A tracked batch of column-over-row work — every upsert and file ingest creates
one. Poll it for status, counts, credits used, and per-row upsert outcomes. For
`enrich=true` batches the response also carries a `tableRunId` — the id of the
parent `table_run` — which you can read via
[`GET /tables/{tableId}/runs/{runId}`](/agents/reference/get-table-run) for
durable completion status and terminal child counts.
Endpoints: [`GET /enrichment-runs`](/agents/reference/list-enrichment-runs),
[`GET /enrichment-runs/{runId}`](/agents/reference/get-enrichment-run),
[`GET /tables/{tableId}/enrichment-runs`](/agents/reference/list-table-enrichment-runs).
An enrichment run is the object formerly called a "batch". `GET /batches`
and `GET /batches/{batchId}` remain as deprecated aliases; its `id` and
`batchId` fields hold the same value.
The durable parent of a table operation (such as an API enrichment batch).
Carries the run's status, source, timestamps, optional failure reason, and
terminal child-outcome counts once complete. Read with
[`GET /tables/{tableId}/runs/{runId}`](/agents/reference/get-table-run).
Its id is returned as `tableRunId` on every `enrich=true` enrichment run.
A file uploaded into a workspace. Upload, list, read, rename, and delete under
[`/workspaces/{workspaceId}/documents`](/agents/reference/list-documents).
## Outreach: campaigns, sequences, steps
A first-class outreach campaign, homed in a workspace. Its queue is the set of
sequences stamped with its id — one per person. Create and edit campaigns
agentically ([`POST /tables/{tableId}/campaigns`](/agents/reference/create-campaign),
[`POST /campaigns/{campaignId}/edits`](/agents/reference/edit-campaign)); read
people and stats; and control its lifecycle with
[launch](/agents/reference/launch-campaign),
[pause](/agents/reference/pause-campaign), and
[resume](/agents/reference/resume-campaign).
One recipient's thread within a campaign — in the campaign model, a person
*is* a sequence. Read one with its steps inline
([`GET /sequences/{sequenceId}`](/agents/reference/get-sequence)), list them
in scope, [stop](/agents/reference/stop-sequence), or
[delete](/agents/reference/delete-sequence). Content edits go through the
campaign edit, not the sequence.
A single message or connection request within a sequence — channel, subject,
body, and send status. Returned inline on the sequence detail.
## Working conventions
Agent work returns a `running` run; poll until `status` is terminal, honoring
`Retry-After`.
Every list returns `{ items, nextCursor }`. Pass `nextCursor` back as
`cursor`.
Parent-wide API keys and the `x-origami-project` header for project scoping.
Map every v1 route to its v2 equivalent.
**Idempotency.** Any `POST` can send an `Idempotency-Key` header; the first
response is replayed for retries with the same key for 24 hours. Row upserts also
dedup on the body `batchId`.
**Errors.** Every error uses `{ error, code, details?, handoff? }`. Codes are
`UPPERCASE_SNAKE_CASE`. A 4xx the user can fix in-app carries a forwardable
`handoff` link.
## Where to start
Create an API key and confirm access with
[`GET /account`](/agents/reference/get-account). See
[authentication](/authentication).
Hand a brief to [`POST /agents`](/agents/reference/create-agent) and let it
build a table — follow the [quickstart](/agents/quickstart). Already have
rows? Upsert them with
[`POST /tables/{tableId}/rows/upsert`](/agents/reference/upsert-rows).
Pull rows with
[`GET /tables/{tableId}/rows`](/agents/reference/list-rows) — see
[reading data](/reading-data).
Draft a campaign with
[`POST /tables/{tableId}/campaigns`](/agents/reference/create-campaign), then
[launch](/agents/reference/launch-campaign) it.
# Quickstart
Source: https://docs.origami.chat/agents/quickstart
Start an agent, poll for the result, follow up, and fetch the data.
Create an API key under **Settings → API keys** (you need a paid plan), then set it:
```bash theme={null}
export ORIGAMI_API_KEY=og_live_your_key_here
```
## Start an agent
Send a brief to [`POST /api/v2/agents`](/agents/reference/create-agent). This creates the
agent and starts its first run.
```bash theme={null}
curl -X POST https://origami.chat/api/v2/agents \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "prompt": "Find 20 SaaS founders in Austin who raised seed in the last 6 months." }'
```
You get `202 Accepted` with the run already `running`. Keep the `agent.id` and `run.id`
from the response — you'll need both.
## Poll for the result
The agent works in the background, so poll
[`GET /api/v2/agents/{id}/runs/{runId}`](/agents/reference/get-run) until `status` is no
longer `running`. Each `running` response carries a `Retry-After` header (15 seconds) —
wait that long between polls. Polling is free.
```bash theme={null}
while true; do
RESP=$(curl -fsSL -D /tmp/headers \
"https://origami.chat/api/v2/agents/$AGENT_ID/runs/$RUN_ID" \
-H "Authorization: Bearer $ORIGAMI_API_KEY")
[ "$(echo "$RESP" | jq -r '.status')" != "running" ] && break
WAIT=$(grep -i '^retry-after:' /tmp/headers | awk '{print $2}' | tr -d '\r')
sleep "${WAIT:-15}"
done
echo "$RESP" | jq .
```
## Read the response
When the run finishes, check two things on the run object:
1. **Questions** — if `todo.pendingQuestions[]` isn't empty, the agent needs you to
clarify something before it continues. Answer it with a follow-up run.
2. **Results** — `response.tables[]` lists every table the agent built or changed, each
with a `url` you can open in Origami.
```json theme={null}
{
"status": "completed",
"response": {
"text": "Found 18 Austin SaaS founders who raised seed recently.",
"tables": [
{ "id": "tbl_77", "name": "Austin Seed Founders", "leadCount": 18,
"url": "https://origami.chat/workspace/ws_4a9b?table=tbl_77" }
]
},
"todo": { "pendingQuestions": [], "nextActions": [] }
}
```
## Follow up
To answer a question or ask for more, send another run to the same agent with
[`POST /api/v2/agents/{id}/runs`](/agents/reference/send-run). It keeps the same workspace
and conversation, so just say what's next.
```bash theme={null}
curl -X POST https://origami.chat/api/v2/agents/$AGENT_ID/runs \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "prompt": "For each founder, also find their LinkedIn URL.",
"focusTableIds": ["'$TABLE_ID'"] }'
```
Poll it the same way. Need to stop one mid-run? Call
[`POST /api/v2/agents/{id}/cancel`](/agents/reference/cancel-run) — whatever it built so
far is kept.
## Fetch the data
The agent stores results in tables. To pull the rows, hand the table id to
[`GET /api/v2/tables/{tableId}/rows`](/agents/reference/list-rows) — it's free and needs no
agent:
```bash theme={null}
curl "https://origami.chat/api/v2/tables/$TABLE_ID/rows" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
Rows come back as typed cells by default. Add `?cells=flat` for the simpler
`{ slug: value }` shape, or `?format=csv` to download a spreadsheet. See the
[list rows reference](/agents/reference/list-rows) for filters, sorting, and pagination.
## Install the skill (optional)
If you drive this API from an AI coding assistant, install the [Origami skill](/agents/skill)
so it can handle the calls above for you.
# Archive an agent (soft-delete)
Source: https://docs.origami.chat/agents/reference/archive-agent
/openapi-v2.yaml delete /agents/{id}
Soft-deletes the agent (its chat session). The deprecated
`workspace` node reports the agent's container: for a
workspace-less agent whose container is its own session, it is
deleted with the agent (`deletedAt` set); a session container
the agent was created "in", a hand-supplied legacy workspace,
and any legacy auto-created workspace that still has other
active agents are never auto-deleted.
# Cancel the agent's currently-active run (idempotent, cooperative)
Source: https://docs.origami.chat/agents/reference/cancel-run
/openapi-v2.yaml post /agents/{id}/cancel
The per-agent mutex guarantees at most one in-flight run per
agent, so cancel takes only the agentId. Wraps the same
machinery the UI's stop route uses (`markCancelling` + Redis
`cancelStream` publish).
Idempotent: returns `200` whether the agent has an active
run, is already cancelling, or has no in-flight run at all.
The response body always carries the latest run object: the
just-cancelled one if there was one, otherwise the agent's
most recent terminal run.
# Stop active cell work for a table (the API "stop" button)
Source: https://docs.origami.chat/agents/reference/cancel-table-cells
/openapi-v2.yaml post /tables/{tableId}/cancel
Cancel every active (waiting / queued / running) `cell_run` for the
table: the API equivalent of the in-app "stop" button. Running cells
finish their in-flight execution but skip further work once they observe
the cancelled status; sent history is never touched.
- Omit `columns` to stop the whole table; a full-table stop **also**
cancels active lead-source jobs.
- Pass `columns` (an array of **column slugs** from
`GET /tables/{tableId}/columns` → `columns[].slug`) to scope the stop
to those columns only. A column scope deliberately leaves lead-source
jobs running (they are table-wide, not per-column). An unknown slug →
`UNKNOWN_COLUMN`; an empty array → `VALIDATION_ERROR`.
- `dryRun: true` reports how much is active right now
(`{ dryRun, tableId, activeCells, activeLeadSourceJobs }`) with zero
writes; the "what's running?" probe. `activeCells` respects the
`columns` scope; `activeLeadSourceJobs` is always the table-wide count
(lead-source jobs aren't per-column), so a scoped dry-run still tells
you the truth about table activity.
This stops enrichment/score/sequence-drafting cell runs. To halt an
already-launched outreach use `POST /sequences/{sequenceId}/stop` (one
recipient) or `POST /campaigns/{campaignId}/pause` (the whole
campaign).
# Create an agent and admit its first run
Source: https://docs.origami.chat/agents/reference/create-agent
/openapi-v2.yaml post /agents
Creates a new agent (a chat session), inserts a synthetic user
message carrying the prompt, claims the per-org concurrent
slot, and spawns the agent work in the background. Responds
`202 Accepted` with the initial run object
(`status: "running"`). Poll `GET /agents/{id}/runs/{runId}`
until `status !== "running"`.
Workspaces are deprecated: no workspace is auto-created any
more. The returned `workspace` object describes the agent's
**session container** (its `id` is a chat-session id every
`workspaceId` parameter accepts). Passing a chat-session id as
`workspaceId` creates the agent "in" that container - it copies
the container's table/campaign links and inherits its uploaded
documents; a legacy workspace id keeps the old semantics.
# Create a campaign on a table (agentic)
Source: https://docs.origami.chat/agents/reference/create-campaign
/openapi-v2.yaml post /tables/{tableId}/campaigns
Agentic campaign creation. Body `{ instructions, autoLeadRefillEnabled? }`:
no column or channel; campaigns can be multi-channel and the agent
infers the rest from the instructions and the table. Auto Lead Refill
defaults ON (a campaign that never refills runs dry); pass
`autoLeadRefillEnabled: false` to create without daily sourcing —
same field as `PATCH /campaigns/{campaignId}`. Delegates to the
chat agent via the v2 run path and responds `202 Accepted` with
`{ agent, run, table }` (poll `GET /agents/{agentId}/runs/{runId}`
for progress/result). The agent creates the campaign and drafts its
sequences (it does not send); once the run completes, the drafted
campaign shows up under `GET /tables/{tableId}/campaigns`. The
pre-restructure `POST /tables/{tableId}/sequences` spelling is the
legacy alias.
# Create a project (child org)
Source: https://docs.origami.chat/agents/reference/create-project
/openapi-v2.yaml post /projects
Creates a project under the API key's parent org.
`monthlyCredits` is the optional per-project budget cap in
credits; `null` or omitted means uncapped. A child org's key
cannot create projects (nesting is one level deep) →
`400 PARENT_REQUIRED`.
# Create a scheduled agent (disabled by default)
Source: https://docs.origami.chat/agents/reference/create-scheduled-agent
/openapi-v2.yaml post /scheduled-agents
Requires name/prompt/cron. workspaceId is optional/nullable (a deprecated container anchor), not required. Invalid cron → INVALID_CRON / CRON_TOO_FREQUENT.
# Legacy alias: create a campaign (use POST /tables/{tableId}/campaigns)
Source: https://docs.origami.chat/agents/reference/create-sequence-legacy
/openapi-v2.yaml post /tables/{tableId}/sequences
Deprecated alias for `POST /tables/{tableId}/campaigns`; identical
body and responses (agentic campaign creation from
`{ instructions, autoLeadRefillEnabled? }`, `202` with `{ agent, run, table }`).
# Bootstrap a workspace (upload-first flows)
Source: https://docs.origami.chat/agents/reference/create-workspace
/openapi-v2.yaml post /workspaces
Deprecated surface: creates a **session container** (a
workspace-less chat session), returned shaped as a `Workspace`
object. The id is a chat-session id; upload documents to it and
pass it as `workspaceId` on `POST /agents` to run agents "in"
it. No plan cap applies (sessions are uncapped), so
`WORKSPACE_LIMIT_REACHED` is no longer returned.
# Delete a campaign (?confirm=true, ?dryRun=true)
Source: https://docs.origami.chat/agents/reference/delete-campaign
/openapi-v2.yaml delete /campaigns/{campaignId}
Soft-deletes the campaign (instantly halting its picker) and
cancels its orphaned sequences. Follows the v2 deletes convention:
- Without `?confirm=true` (or with `?dryRun=true`) → HTTP 200 with
the impact preview
`{ id, name, confirmationRequired: true, status }`; nothing removed.
- With `?confirm=true` → deletes and returns
`{ id, name, deleted: true }`.
# Delete a workspace document
Source: https://docs.origami.chat/agents/reference/delete-document
/openapi-v2.yaml delete /workspaces/{workspaceId}/documents/{documentId}
# Delete a project (two-step; ?confirm=true, ?dryRun=true)
Source: https://docs.origami.chat/agents/reference/delete-project
/openapi-v2.yaml delete /projects/{projectId}
Deletes the project (child org) and cascades across its whole
entity tree. Follows the v2 deletes convention, deliberately
two-step:
1. Without `?confirm=true` (or with `?dryRun=true`) → HTTP 200
with the impact preview
`{ id, name, confirmationRequired: true, willDelete: { workspaces, tables, rows } }`;
nothing removed.
2. With `?confirm=true` → deletes and returns
`{ id, name, deleted: true }`.
# Bulk soft-delete rows by id
Source: https://docs.origami.chat/agents/reference/delete-rows
/openapi-v2.yaml delete /tables/{tableId}/rows
Soft-deletes the given rows (up to **100** per call). Each row and its
cells / cell_runs / active sequences cascade off. Ids that don't belong
to this table/org, or are already deleted, are skipped, so `deleted`
may be less than `requested` (an idempotent re-delete returns
`deleted: 0`). Deleted rows are recoverable via the app's restore flow.
# Delete a scheduled agent (soft-delete)
Source: https://docs.origami.chat/agents/reference/delete-scheduled-agent
/openapi-v2.yaml delete /scheduled-agents/{id}
# Delete a sequence (?force=true, ?dryRun=true)
Source: https://docs.origami.chat/agents/reference/delete-sequence
/openapi-v2.yaml delete /sequences/{sequenceId}
Soft-deletes one sequence. Without `?force=true`, a sequence with a
sent footprint is guarded, `409 SEQUENCE_HAS_SENT_MESSAGES`;
`?force=true` (`forceDeletionOfSentMessages`) extends the delete to
already-sent messages. `?dryRun=true` reports the would-be effect
(`{ dryRun, sequenceId, status, force }`) with no writes. Replaces
the deprecated `POST /sequences/{sequenceId}/delete` spelling.
# Legacy alias: delete a sequence (use DELETE /sequences/{sequenceId})
Source: https://docs.origami.chat/agents/reference/delete-sequence-legacy
/openapi-v2.yaml post /sequences/{sequenceId}/delete
Deprecated alias for `DELETE /sequences/{sequenceId}`. Body-flag
protocol: soft-delete; `409 SEQUENCE_HAS_SENT_MESSAGES` unless the
body sets `forceDeletionOfSentMessages: true`. Supports body
`dryRun: true`.
# Delete a workspace (two-step; ?confirm=true)
Source: https://docs.origami.chat/agents/reference/delete-workspace
/openapi-v2.yaml delete /workspaces/{workspaceId}
Permanently deletes a workspace and its entire entity tree (tables,
rows, cells, sequences, chat history, documents). For a **session
container** the cascade covers the session, agents created in it,
and its *exclusively-linked* workspace-less tables; tables also
linked to other chats survive, and in-app chat sessions are
rejected with 403 `WORKSPACE_NOT_DELETABLE`. Follows the v2
deletes convention, deliberately **two-step** so it can't fire on a
single ambiguous instruction:
1. Call **without** `?confirm=true` → **no deletion**. Returns HTTP
200 with the impact preview
`{ workspaceId, name, confirmationRequired: true, willDelete: { tables, rows } }`.
The caller is expected to check with the user before proceeding.
2. After the user agrees, retry with `?confirm=true` → performs the
cascade soft-delete and returns `{ workspaceId, name, deleted: true }`.
A missing / cross-org / already-deleted workspace returns
`404 WORKSPACE_NOT_FOUND`.
# Legacy alias: delete a workspace (body-confirm protocol)
Source: https://docs.origami.chat/agents/reference/delete-workspace-legacy
/openapi-v2.yaml post /workspaces/{workspaceId}/delete
Deprecated alias for `DELETE /workspaces/{workspaceId}`. Keeps the
original body-confirm protocol: with `confirm` omitted/false it
returns `409 CONFIRMATION_REQUIRED` whose `details` carry the
workspace name and the `willDelete: { tables, rows }` impact preview;
the `{ "confirm": true }` retry performs the delete and returns
`{ workspaceId, name, deleted: true }`.
# Disable a scheduled agent
Source: https://docs.origami.chat/agents/reference/disable-scheduled-agent
/openapi-v2.yaml post /scheduled-agents/{id}/disable
# Request a content change to a campaign (agentic)
Source: https://docs.origami.chat/agents/reference/edit-campaign
/openapi-v2.yaml post /campaigns/{campaignId}/edits
The ONLY way to change campaign content (message templates, per-lead
copy, the brief). Minimal body `{ instructions }`, a natural-
language change request the chat agent fulfils. Delegates to the v2
run path and responds `202 Accepted` with `{ agent, run, campaign }`
(poll `GET /agents/{agentId}/runs/{runId}`). The API never mutates a
template or step directly.
# Enable a scheduled agent
Source: https://docs.origami.chat/agents/reference/enable-scheduled-agent
/openapi-v2.yaml post /scheduled-agents/{id}/enable
# Org account overview (plan, capabilities, workspace usage)
Source: https://docs.origami.chat/agents/reference/get-account
/openapi-v2.yaml get /account
# Get an agent
Source: https://docs.origami.chat/agents/reference/get-agent
/openapi-v2.yaml get /agents/{id}
# Legacy alias: track an enrichment run (use GET /enrichment-runs/{runId})
Source: https://docs.origami.chat/agents/reference/get-batch
/openapi-v2.yaml get /batches/{batchId}
Deprecated alias for `GET /enrichment-runs/{runId}`; identical response.
# Fetch a campaign
Source: https://docs.origami.chat/agents/reference/get-campaign
/openapi-v2.yaml get /campaigns/{campaignId}
# Campaign performance stats
Source: https://docs.origami.chat/agents/reference/get-campaign-stats
/openapi-v2.yaml get /campaigns/{campaignId}/stats
# Fetch one cell
Source: https://docs.origami.chat/agents/reference/get-cell
/openapi-v2.yaml get /tables/{tableId}/rows/{rowId}/cells/{columnId}
# Credit balance (supersedes v1 GET /credits)
Source: https://docs.origami.chat/agents/reference/get-credits
/openapi-v2.yaml get /account/credits
# Read a document (metadata + content)
Source: https://docs.origami.chat/agents/reference/get-document
/openapi-v2.yaml get /workspaces/{workspaceId}/documents/{documentId}
# Track an enrichment run (incl. per-row upsert outcomes)
Source: https://docs.origami.chat/agents/reference/get-enrichment-run
/openapi-v2.yaml get /enrichment-runs/{runId}
Run status, row count, enrichment counts, credits used, and, for
`upsert`-type runs, the per-row `outcomes[]` ledger plus
`outcomeCounts`. `GET /batches/{batchId}` is the deprecated alias.
# Fetch a project
Source: https://docs.origami.chat/agents/reference/get-project
/openapi-v2.yaml get /projects/{projectId}
# Fetch one row
Source: https://docs.origami.chat/agents/reference/get-row
/openapi-v2.yaml get /tables/{tableId}/rows/{rowId}
# Get a run (poll for status / final result)
Source: https://docs.origami.chat/agents/reference/get-run
/openapi-v2.yaml get /agents/{id}/runs/{runId}
Returns the run object. Works while the run is still running
(`status: "running"` with partial `actions[]`) and after it
terminates. This is the primary read path; every call after
`POST /agents` polls this endpoint until `status !== "running"`.
# Fetch a scheduled agent (+ planBlocked, last run)
Source: https://docs.origami.chat/agents/reference/get-scheduled-agent
/openapi-v2.yaml get /scheduled-agents/{id}
# Fetch a sequence with steps inline (provider internals redacted)
Source: https://docs.origami.chat/agents/reference/get-sequence
/openapi-v2.yaml get /sequences/{sequenceId}
# Get a table: name, leadCount, columns, credits, optional economics
Source: https://docs.origami.chat/agents/reference/get-table
/openapi-v2.yaml get /tables/{tableId}
The canonical "what's in this table and what did it cost?"
surface in v2. Same shape as the entries in
`Run.response.tables[]` and `GET /agents/{id}/tables`.
Default shape is lite: `leadCount`, `columns[]` with
per-column `credits.lifetimeUsed`, table-level
`credits.lifetimeUsed` (sum of the column breakdown). Pass
`?include=stats` to attach the economics block, the same
numbers the UI shows at the top of the table
(creditsPerLead, qualification breakdown, funnel, lead
sources, ...).
`credits.lifetimeUsed` accumulates across every agent run
that ever populated cells on this table; it is the unit a
human thinks in when they ask how much a list cost. It does
NOT include still-active cell-run reservations; wait for the
relevant run to terminate before reading the final number.
Scoped to the API key's org. There is no API-owned filter on
tables, so any non-deleted table in your org is fetchable.
For programmatic row reads, use
[`GET /api/v2/tables/{tableId}/rows`](https://origami.chat/reading-data).
# Get table run
Source: https://docs.origami.chat/agents/reference/get-table-run
/openapi-v2.yaml get /tables/{tableId}/runs/{runId}
Durable recovery read for table-run completion: returns the parent
run's status, source, timestamps, optional failure reason, and terminal
child counts (counts are present only once the run is terminal). This is
the fallback for a missed `table.run.completed` webhook. Purged
(retention-expired) or foreign runs return `404`.
# Fetch a workspace
Source: https://docs.origami.chat/agents/reference/get-workspace
/openapi-v2.yaml get /workspaces/{workspaceId}
# Launch a campaign: activate + run the launch pipeline (alias /send)
Source: https://docs.origami.chat/agents/reference/launch-campaign
/openapi-v2.yaml post /campaigns/{campaignId}/launch
Marks the campaign ready: sets `status: active` and runs the full
launch pipeline campaign-keyed (sender gate, duplicate auto-cancel,
per-account scheduling). Idempotent on an already-active campaign.
**Launch is "mark ready", never "force".** There are NO override
knobs: send windows, daily caps, spacing, and duplicate settings
are the campaign's own persisted config, set through the agent,
never per-call. `POST /campaigns/{campaignId}/send` is an alias.
`?dryRun=true` returns `{ dryRun: true, campaignId, wouldLaunch: true }`
with no writes.
# List tables in the agent's workspace
Source: https://docs.origami.chat/agents/reference/list-agent-tables
/openapi-v2.yaml get /agents/{id}/tables
The fallback discovery surface in v2. The run object only
carries `response.tables[]` for tables a specific run
*touched* (empty when the run made no mutations or stopped on
a question), so callers that need "every table this agent has
access to" use this endpoint instead.
Each entry is a full TableObject: same shape as
`GET /api/v2/tables/{id}` and the same shape embedded under
`Run.response.tables[]`. Pass `?include=stats` to attach the
economics block (per-table + per-column).
Scoped to the agent's own reach - its chat-linked tables plus
its legacy workspace's tables when it has one; a caller cannot
probe arbitrary containers through this path. Returned in the
canonical list envelope with `nextCursor` always `null`; an
agent's scope is rarely more than a handful of tables, so
the endpoint does not paginate.
# List agents (cursor-paginated; ?search=)
Source: https://docs.origami.chat/agents/reference/list-agents
/openapi-v2.yaml get /agents
# Legacy alias: list enrichment runs (use GET /enrichment-runs)
Source: https://docs.origami.chat/agents/reference/list-batches
/openapi-v2.yaml get /batches
Deprecated alias for `GET /enrichment-runs`; identical parameters and response.
# List the people in a campaign (keyset-paginated, filtered)
Source: https://docs.origami.chat/agents/reference/list-campaign-people
/openapi-v2.yaml get /campaigns/{campaignId}/people
The people enrolled in the campaign: one row per enrolled sequence
(recipient), with send status, fit score / explanation, and identity
profile. Keyset-paginated via an opaque `cursor`; the list envelope
additionally carries a top-level `total`.
`GET /campaigns/{campaignId}/sequences` is a synonym (same shape);
in the campaign model a person IS a sequence.
# Synonym of GET /campaigns/{campaignId}/people
Source: https://docs.origami.chat/agents/reference/list-campaign-sequences
/openapi-v2.yaml get /campaigns/{campaignId}/sequences
Identical to `GET /campaigns/{campaignId}/people`: same
`campaign_person` item shape, same `search` / `status` (CSV) /
`cursor` / `limit` params, same top-level `total`. In the campaign
model a person IS a sequence.
# List a table's columns
Source: https://docs.origami.chat/agents/reference/list-columns
/openapi-v2.yaml get /tables/{tableId}/columns
# List workspace-scoped documents (cursor-paginated)
Source: https://docs.origami.chat/agents/reference/list-documents
/openapi-v2.yaml get /workspaces/{workspaceId}/documents
# List enrichment runs (cursor-paginated; ?tableId=)
Source: https://docs.origami.chat/agents/reference/list-enrichment-runs
/openapi-v2.yaml get /enrichment-runs
Org-wide list of enrichment runs, the tracked batches of
column-over-row work (upserts, file ingests), newest first.
Optional `?tableId=` filter. `GET /batches` is the deprecated alias.
# List projects (cursor-paginated; ?search=)
Source: https://docs.origami.chat/agents/reference/list-projects
/openapi-v2.yaml get /projects
Projects are managed **from the parent**: every `/projects/*`
endpoint acts on the API key's own (parent) org and ignores the
`x-origami-project` header. Newest first; a parent with no
projects gets an empty page; "you have no projects" is not an
error.
# List rows (typed cells; ?cells=flat / ?format=csv)
Source: https://docs.origami.chat/agents/reference/list-rows
/openapi-v2.yaml get /tables/{tableId}/rows
Returns Row objects (`{ object: "row", id, cells }`) with polymorphic
typed cells by default (`scalar` / `value` (+run) / `sequence`), in
the canonical list envelope **plus `total`** (the filtered row count
for the query's scope). `?cells=flat` returns the v1-style
`{ slug: value }` rows (deliberately unstamped); `?format=csv`
streams CSV. Supports slug-keyed `filters` / `sort` JSON and cursor
pagination; this endpoint allows `limit` up to **200**.
# List runs for an agent (cursor-paginated)
Source: https://docs.origami.chat/agents/reference/list-runs
/openapi-v2.yaml get /agents/{id}/runs
# Run history (failed runs carry a workspace-chat handoff)
Source: https://docs.origami.chat/agents/reference/list-scheduled-agent-runs
/openapi-v2.yaml get /scheduled-agents/{id}/runs
Run history newest-first, in the canonical list envelope. Not
paginated; `nextCursor` is always `null`.
# List scheduled agents (cursor-paginated; filters workspaceId/enabled)
Source: https://docs.origami.chat/agents/reference/list-scheduled-agents
/openapi-v2.yaml get /scheduled-agents
# List sequences (bounded scope, cursor-paginated, filtered)
Source: https://docs.origami.chat/agents/reference/list-sequences
/openapi-v2.yaml get /sequences
Requires one of `workspaceId` / `tableId` / `columnId` (else
`400 MISSING_SCOPE`). Optional `status` / `channel` / `recipient`
filters compose with `cursor` / `limit`.
# List campaigns that send from a table
Source: https://docs.origami.chat/agents/reference/list-table-campaigns
/openapi-v2.yaml get /tables/{tableId}/campaigns
The campaigns whose enrolled sequences send from this table. A
campaign owns no table; its `tableId` is derived from the dominant
table of its enrolled sequences, so this resolves the table's
active campaign(s). Returns the canonical list envelope with
`nextCursor: null`.
# List a table's enrichment runs (cursor-paginated)
Source: https://docs.origami.chat/agents/reference/list-table-enrichment-runs
/openapi-v2.yaml get /tables/{tableId}/enrichment-runs
Same list as `GET /enrichment-runs?tableId=`, but the table is
resolved org-scoped first; a missing or cross-org table returns
`404 TABLE_NOT_FOUND` instead of an empty list.
# List a table's sequences (cursor-paginated, filtered)
Source: https://docs.origami.chat/agents/reference/list-table-sequences
/openapi-v2.yaml get /tables/{tableId}/sequences
Sequences scoped to one table; the quick "does this table have any
sequences?" read. Same filters (status / channel / recipient) and
pagination as `GET /sequences`, but the table is resolved org-scoped
first, so a missing or cross-org table returns 404 TABLE_NOT_FOUND
instead of an empty list.
# List tables (cursor-paginated; ?workspaceId=)
Source: https://docs.origami.chat/agents/reference/list-tables
/openapi-v2.yaml get /tables
# List a workspace's campaigns
Source: https://docs.origami.chat/agents/reference/list-workspace-campaigns
/openapi-v2.yaml get /workspaces/{workspaceId}/campaigns
A campaign is a first-class `campaigns` row (org-global) whose
queue is the set of sequences stamped with its id: one sequence
per person. The deprecated container scope lists a legacy
workspace's homed campaigns, or a chat session's LINKED
campaigns when the id names a session. Returns the canonical
list envelope with `nextCursor: null` (one page, newest first).
# List workspaces (cursor-paginated; ?search=)
Source: https://docs.origami.chat/agents/reference/list-workspaces
/openapi-v2.yaml get /workspaces
Deprecated surface (workspaces are retired as the container
boundary). Lists every legacy workspace **plus** the
API-created session containers that replaced auto-created
workspaces, rendered as the same `Workspace` object.
# Pause a campaign (idempotent; ?dryRun=true)
Source: https://docs.origami.chat/agents/reference/pause-campaign
/openapi-v2.yaml post /campaigns/{campaignId}/pause
Pauses the campaign. Idempotent; pausing an already-paused campaign
is a no-op. `?dryRun=true` returns
`{ dryRun: true, campaignId, wouldPause }` with no writes; a real
pause returns the transition result carrying the `pause` facts.
# Rename a document
Source: https://docs.origami.chat/agents/reference/rename-document
/openapi-v2.yaml patch /workspaces/{workspaceId}/documents/{documentId}
DB-only rename; re-slugs the basename from `name` while preserving
the directory prefix and extension; the document id is stable. A
collision with another live document at the derived path →
`409 DOCUMENT_PATH_TAKEN`.
# Legacy alias: rename a document (use PATCH .../documents/{documentId})
Source: https://docs.origami.chat/agents/reference/rename-document-legacy
/openapi-v2.yaml post /workspaces/{workspaceId}/documents/{documentId}/rename
Deprecated alias for
`PATCH /workspaces/{workspaceId}/documents/{documentId}`; identical
body and responses.
# Resume a campaign (idempotent; ?dryRun=true)
Source: https://docs.origami.chat/agents/reference/resume-campaign
/openapi-v2.yaml post /campaigns/{campaignId}/resume
Resumes the campaign from where its sequences left off (the same
`active` transition as launch; resume vs fresh-launch facts are
derived from prior state). Idempotent. `?dryRun=true` returns
`{ dryRun: true, campaignId, wouldResume }` with no writes; a real
resume returns the transition result carrying the `resume` facts.
# Alias of POST /campaigns/{campaignId}/launch
Source: https://docs.origami.chat/agents/reference/send-campaign
/openapi-v2.yaml post /campaigns/{campaignId}/send
Alias of `POST /campaigns/{campaignId}/launch`; activates the
campaign and runs the launch pipeline. (Before the campaigns
refactor this was a batch "mark sequences ready" op; it is now the
campaign-activate launch.) Same body, params, and result shape.
# Send a follow-up run
Source: https://docs.origami.chat/agents/reference/send-run
/openapi-v2.yaml post /agents/{id}/runs
Admits a new run on an existing agent. Same agent, same
workspace, same conversation context. Responds `202 Accepted`
with the initial run object; agent work runs in the
background. Use this to answer a `needs_input` question; the
agent picks up from the prior conversation.
# Stop a sequence (header stop; sent history preserved)
Source: https://docs.origami.chat/agents/reference/stop-sequence
/openapi-v2.yaml post /sequences/{sequenceId}/stop
Branches: nothing-to-stop (fresh draft) / stopped / noop (already
stopped). `?dryRun=true` reports the would-be effect with no writes.
# Manually trigger a run
Source: https://docs.origami.chat/agents/reference/trigger-scheduled-agent
/openapi-v2.yaml post /scheduled-agents/{id}/trigger
# Update a project's name, budget cap, or enforcement mode
Source: https://docs.origami.chat/agents/reference/update-project
/openapi-v2.yaml patch /projects/{projectId}
Partial update. For `monthlyCredits`, a number **sets** the cap,
an explicit `null` **clears** it, and omitting the field leaves
it alone. `enforcement` controls whether that cap hard-blocks spend
(`hard`) or only tracks it (`soft`); omitting it leaves it alone.
# Edit a scheduled agent
Source: https://docs.origami.chat/agents/reference/update-scheduled-agent
/openapi-v2.yaml patch /scheduled-agents/{id}
# Upload files into a workspace (the single ingest verb)
Source: https://docs.origami.chat/agents/reference/upload-documents
/openapi-v2.yaml post /workspaces/{workspaceId}/documents
The one ingest path for every file type: base64 file bytes inside
JSON (no multipart). CSVs become new tables (`mode: "table"`, the
default for `.csv`), append into an existing table (`mode: "append"`
+ `tableId`), or store as documents (`mode: "document"`); any other
extension is stored as a document. Preflight validation is
all-or-nothing: the whole request is rejected (count cap, per-file
mode/extension/append-target) before any file is ingested. Per-file
ingestion can still fail after preflight; the response is `201` when
at least one file landed and `422 UPLOAD_FAILED` when every file
errored. Replaces the deprecated
`POST /workspaces/{workspaceId}/uploads` spelling (same contract).
# Legacy alias: upload files (use POST /workspaces/{workspaceId}/documents)
Source: https://docs.origami.chat/agents/reference/upload-documents-legacy
/openapi-v2.yaml post /workspaces/{workspaceId}/uploads
Deprecated alias for `POST /workspaces/{workspaceId}/documents`;
identical body and responses.
# Upsert rows on matchColumns (the single v2 batch write primitive)
Source: https://docs.origami.chat/agents/reference/upsert-rows
/openapi-v2.yaml post /tables/{tableId}/rows/upsert
v2's one row-write endpoint; there is no bare per-row insert (an
insert-only call is just an upsert whose rows match nothing). All writes
go through `api_batches` and are idempotent per `batchId`.
Rows matching an existing row on **every** `matchColumns` value update
that row's input cells; non-matching rows insert. Per-row
`inserted`/`updated`/`skipped` outcomes are recorded on the batch.
- Row keys and `matchColumns` are **input column slugs** (from
`GET /tables/{tableId}/columns` → `columns[].slug`), never display
names. A non-slug row key → `UNKNOWN_FIELDS`.
- `matchColumns` (required) must be real input columns (`UNKNOWN_COLUMN`)
and every match value must be present + non-empty on every row
(`MISSING_MATCH_VALUE`).
- Duplicate request keys → `DUPLICATE_MATCH_KEY`; multiple existing rows
match one key → `AMBIGUOUS_MATCH` (409).
- `enrich` (default true) enriches freshly **inserted** rows.
- `reenrichUpdated` (default false): set true to also re-run enrichment
on rows the upsert **updated** (re-spends credits); off by default so an
upsert never silently re-enriches matched rows.
Only `input`-kind columns are writable. Enrichment, score, and
**sequence** columns are populated automatically; sequences are
agent/column-generated only (see `POST /tables/{tableId}/campaigns`),
never set via this endpoint; passing one returns `NON_INPUT_COLUMNS`.
The response references the tracking object by id: it is an
`enrichment_run` the caller polls via `GET /enrichment-runs/{runId}`.
# Upsert rows from a CSV file (same operation, CSV transport)
Source: https://docs.origami.chat/agents/reference/upsert-rows-file
/openapi-v2.yaml post /tables/{tableId}/rows/upsert-file
The CSV transport for `POST /tables/{tableId}/rows/upsert`: `content`
is a base64-encoded CSV whose headers are input-column slugs (the same
keys as JSON upsert rows) with one record per data row. Identical
match/enrich semantics, identical per-`batchId` idempotency, identical
row cap: a CSV with more than **100** data rows is rejected with
`413 TOO_MANY_ROWS` (split the file or page the JSON upsert).
# Run object
Source: https://docs.origami.chat/agents/run-object
How to read a run's status, the actions it took, and the tables it built.
A run is one brief and the work that follows it. Every call to
[`POST /api/v2/agents`](/agents/reference/create-agent),
[`POST /api/v2/agents/{id}/runs`](/agents/reference/send-run), and each poll of
[`GET /api/v2/agents/{id}/runs/{runId}`](/agents/reference/get-run) returns the same run
object. This page explains the fields you branch on.
## Status
`status` is the single discriminator for a run's lifecycle. Branch on it — non-`completed`
terminal states are reported here, not as HTTP errors.
| Status | Meaning | What to do |
| -------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `running` | Work is in progress. | Keep polling; honor the `Retry-After` header. |
| `completed` | The agent finished cleanly. | Read `response`. |
| `needs_input` | The agent asked a question before it could continue. | Answer with a follow-up run (see [Pending questions](#pending-questions)). |
| `incomplete` | The step finished but a tool call could not be parsed, so the loop ended early. | Recoverable — send a follow-up run on the same agent. |
| `step_cap_hit` | The run reached your plan's step limit. | Send a follow-up run to continue, or upgrade for a higher cap. |
| `cancelled` | You cancelled the run. | Whatever the agent built so far is kept. |
| `errored` | The run failed. | `response.text` is `null`; retry or contact support. |
| `timed_out` | The run exceeded the wall-clock ceiling. | `response.text` is `null`; send a follow-up run. |
The `Retry-After` header (currently 15 seconds) is present **only** while `status` is
`running`. Its absence on a response is the signal to stop polling.
## Response
`response` is `null` while the run is `running`. Once the run is terminal, it carries the
agent's prose plus the structured work it did.
```json theme={null}
{
"status": "completed",
"response": {
"text": "Found 18 Austin SaaS founders who raised seed recently.",
"actions": [
{ "type": "table_created", "tableId": "tbl_77", "tableName": "Austin Seed Founders" },
{ "type": "leads_added", "tableId": "tbl_77", "leadCount": 18 }
],
"tables": [
{ "id": "tbl_77", "name": "Austin Seed Founders", "leadCount": 18,
"url": "https://origami.chat/workspace/ws_4a9b?table=tbl_77" }
]
},
"todo": { "pendingQuestions": [], "nextActions": [] }
}
```
### `response.text`
The cleaned, user-facing summary of what the agent did. Internal markup is stripped
server-side. It is `null` on `errored` and `timed_out` runs.
### `response.actions[]`
The structured workspace mutations the agent performed, in the order they fired. Each
action has a `type` and a `tableId`; other fields depend on the type. v2 speaks "leads"
rather than "rows", so row mutations use `leads_added`, `leads_deleted`, and
`leads_restored`. Use this as the audit trail of what changed. It is empty when the run
made no mutations (for example, when it stopped on a question).
### `response.tables[]`
The full table objects for every table the run touched — the same shape as a single
[`GET /api/v2/tables/{id}`](/agents/reference/get-table) response. This is the quickest way
to see lead counts and table URLs without a second call. To read the actual rows, pass a
table id to [`GET /api/v2/tables/{tableId}/rows`](/agents/reference/list-rows).
## Optional projections
`GET /api/v2/agents/{id}/runs/{runId}` takes an `include` query parameter — a comma-separated
list of opt-in projections. Unknown tokens are ignored.
| Token | Effect |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stats` | Attaches the economics block (credits per lead, qualification, funnel, lead sources) to every `response.tables[]` entry, per table and per column. |
| `transcript` | Returns the full public transcript on `response.transcript`. |
Combine them:
```bash theme={null}
curl "https://origami.chat/api/v2/agents/$AGENT_ID/runs/$RUN_ID?include=stats,transcript" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
## Pending questions
When the agent needs a decision before it can continue, the run finishes with
`status: "needs_input"` and `todo.pendingQuestions[]` is populated. Surface the question to
your user, then answer it by sending a follow-up run on the same agent with
[`POST /api/v2/agents/{id}/runs`](/agents/reference/send-run) — any free-text `prompt` is
accepted. `todo.nextActions[]` carries the agent's suggested next steps, each with a `label`
you can show and an optional typed `type` you can act on directly.
# Install the skill
Source: https://docs.origami.chat/agents/skill
Teach your AI coding assistant to drive the v2 API for you.
If you drive this API from an AI coding assistant like Cursor, Claude Code, or Codex,
install the Origami skills. They teach your assistant the v2 calls — when to start an
agent, how to poll, and when to fall back to the free v1 reads — so you can just ask it
for a list and let it handle the rest.
## Install
Run the installer from your project directory. It installs every Origami skill (API,
list-building, sequencer, scheduled agents, webhooks) into the tool you choose.
```bash theme={null}
curl -fsSL https://origami.chat/skills/install.sh -o /tmp/origami-install.sh && sh /tmp/origami-install.sh
```
Then set `ORIGAMI_API_KEY=og_live_…` in your shell or project `.env` (create a key in
**Settings → Developers**), restart your AI tool, and try:
> Find 30 B2B SaaS founders in Austin who raised seed in 2025.
Re-run the installer any time to update the skills.
# Get batch
Source: https://docs.origami.chat/api-reference/batches/get-batch
/openapi-v1.yaml get /batches/{batchId}
Check the status of an async batch. When all enrichments are complete, the
response includes the full enriched row data and total credits used.
A batch is `"complete"` when every enrichment has reached a terminal state;
this includes both succeeded and failed runs. Check `enrichments.failed > 0`
to detect partial failures.
**Rows in the response:** When `status` is `"complete"`, `rows` includes every
row for this batch in a single array (not paginated). Insert requests are
limited to **100 rows** per batch (see `POST /tables/{tableId}/rows`), so
result payloads stay bounded; if that limit increases in the future, pagination
may be introduced for this field.
**Polling strategy:** For fast results, poll every 2-5 seconds. For background
processing, poll every 30-60 seconds.
# Get credit balance
Source: https://docs.origami.chat/api-reference/credits/get-credit-balance
/openapi-v1.yaml get /credits
Returns the current credit balance for the organization tied to this API key.
# Insert rows
Source: https://docs.origami.chat/api-reference/rows/insert-rows
/openapi-v1.yaml post /tables/{tableId}/rows
Insert rows into an existing table. Returns a batch ID immediately; poll
`GET /batches/{batchId}` for enrichment progress and results.
**Column matching:** Field names in each row object are matched to **input** columns
by **slug** (exact match). Only columns with `kind: "input"` are accepted;
enrichment and score columns are populated automatically. Use `GET /tables` to
discover available column slugs and their kinds.
**Enrichment:** By default (`enrich: true`), all auto-trigger enrichment columns run
immediately after insertion. Set `enrich: false` to insert data without triggering enrichment.
**Credits:** Credit shortfalls do not fail the insert synchronously; they surface later
in the batch's `failures.insufficientCredits` (poll `GET /batches/{batchId}`).
**Deduplication & exclusion lists** apply automatically; the same table-level rules
from the UI are enforced. Duplicate or excluded rows are inserted but may not enrich.
**Limits:**
- Maximum **100 rows** per request.
- Free-plan tables are capped at **30 rows** total.
# List tables
Source: https://docs.origami.chat/api-reference/tables/list-tables
/openapi-v1.yaml get /tables
Returns all tables in the organization with column metadata.
The API key scopes to the org; no workspace ID needed.
The typical workflow is: list tables → find the one you want by name → note its
input columns → use the `tableId` and column slugs in `POST /tables/{tableId}/rows`.
# Read table rows
Source: https://docs.origami.chat/api-reference/tables/read-table-rows
/openapi-v1.yaml get /tables/{tableId}/rows
Read all rows in a table with pagination, filtering, and sorting.
Rows are returned as flat objects with column **slugs** as keys. The response
includes a `columns` map (slug → display name) so you can resolve human-readable
names. Cells with no value or in an errored state are omitted.
Supports CSV export via the `format` query parameter.
# Migrating from v1 to v2
Source: https://docs.origami.chat/api-v1-to-v2-migration
Map every v1 Data API route to its canonical v2 equivalent.
`/api/v1` keeps working **unchanged** — its routes, response shapes, headers, and
status codes are identical to before. There is **no time-boxed deprecation
window** and no `Sunset` or `Deprecation` headers in this release. v1 stays fully
functional; a separate future change will introduce a dated sunset once consumers
have migrated. This guide maps every v1 route to its canonical v2 equivalent so
new integrations can target v2 directly.
## Why v2
* One canonical surface organized into clear segments — **Projects**,
**Agents**, **Runs**, **Tables**, **Workspace**, **Campaigns**, **Sequences**,
**Scheduled agents**, and **Account** — instead of v1's flat table-and-credits
surface. See [objects and relationships](/agents/objects) for the full map.
* Full data addressability: tables, columns, rows (list and **upsert**), single
row, single cell, and enrichment runs with per-row outcomes.
* Self-describing objects (every object names its type) and one list envelope
everywhere.
* A standard error envelope `{ error, code, details?, handoff? }`, where any 4xx
the user can fix in-app carries a forwardable `handoff` link.
* Campaigns, sequences, scheduled agents, projects, and account state are
reachable from the API for the first time.
## Route mapping
| v1 route | v2 equivalent | Notes |
| --------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /api/v1/tables` | [`GET /api/v2/tables`](/agents/reference/list-tables) | Cursor-paginated list envelope of full table objects. |
| `GET /api/v1/tables/{tableId}/rows` | [`GET /api/v2/tables/{tableId}/rows`](/agents/reference/list-rows) | Typed polymorphic cells by default; `?cells=flat` and `?format=csv` reproduce the v1 flat shape. |
| `POST /api/v1/tables/{tableId}/rows` (insert) | [`POST /api/v2/tables/{tableId}/rows/upsert`](/agents/reference/upsert-rows) | v2 has **no bare insert** — use upsert with `matchColumns` (an insert-only call is an upsert whose rows match nothing). Same caps and idempotent `batchId`; adds per-row inserted/updated/skipped outcomes. A CSV variant lives at [`.../rows/upsert-file`](/agents/reference/upsert-rows-file). |
| `GET /api/v1/batches/{batchId}` | [`GET /api/v2/enrichment-runs/{runId}`](/agents/reference/get-enrichment-run) | A batch is now an **enrichment run**; adds the per-row upsert outcome ledger. `GET /api/v2/batches/{batchId}` remains a deprecated alias. |
| *(none)* | [`GET /api/v2/enrichment-runs`](/agents/reference/list-enrichment-runs) | List enrichment runs org-wide or per table. `GET /api/v2/batches` is the deprecated alias. |
| `GET /api/v1/credits` | [`GET /api/v2/account/credits`](/agents/reference/get-credits) | Same balance source; [`GET /api/v2/account`](/agents/reference/get-account) adds plan and capability flags. |
## New in v2
These segments have no v1 equivalent:
* **[Projects](/agents/reference/list-projects)** — child orgs under your parent
org, selected per request with the `x-origami-project` header. See
[authentication](/authentication#projects-and-the-x-origami-project-header).
* **[Agents and runs](/agents)** — create, list, get, and archive agents; send
and poll runs; cancel an active run; and bind existing documents or tables to a
run with `attachments`.
* **Workspace** — bootstrap, list, get, and delete workspaces; upload, list,
read, [rename](/agents/reference/rename-document), and delete
[documents](/agents/reference/list-documents); read a single row or cell; and
[bulk soft-delete rows](/agents/reference/delete-rows) by id (up to 100 per
call).
* **[Campaigns](/agents/reference/list-workspace-campaigns)** — first-class
outreach campaigns: create and edit agentically, read people and stats, and
launch, pause, or resume.
* **[Sequences](/agents/reference/list-sequences)** — read per-recipient
sequences with steps inline, stop, and delete.
* **[Scheduled agents](/agents/reference/list-scheduled-agents)** — recurring
(cron) agents with full CRUD, enable/disable, manual trigger, and run history.
* **[Account](/agents/reference/get-account)** — an org overview with plan and
capability flags.
## Behavioral differences to know
* **Pagination.** v2 list endpoints return the cursor envelope
`{ object: "list", items, nextCursor, url }` — page by passing `nextCursor`
back as `cursor`, and stop when it's `null`. There is no `page`/`pageSize`.
v1's offset response fields are unchanged on v1.
* **Self-describing objects.** Every v2 object carries an `object` field naming
its type; every list is `{ object: "list", items, … }`.
* **Error envelope.** v2 returns `{ error, code, details?, handoff? }` with
validation issues under `details.issues[]`. v1 keeps its legacy
`{ error, code, path? }` body. Map your error handling per version.
* **Handoffs.** A 4xx the user can resolve in the app carries a `handoff`
(`{ kind, url, label }`) — forward `url` to your user unchanged.
* **Polymorphic cells.** v2 rows return tagged cells (`scalar`, `value` with run
metadata, or `sequence`); a sequence cell links into the Sequences API. Use
`?cells=flat` for the v1-style `{ slug: value }` shape.
* **Async agent work.** Creating an agent or sending a run returns a `running`
run; poll [`GET /api/v2/agents/{id}/runs/{runId}`](/agents/reference/get-run)
and honor `Retry-After`. See the [run object](/agents/run-object).
## Skills and tooling
The [Origami skill](/agents/skill) and the v2 OpenAPI spec teach v2 only. v1
appears solely as deprecated, with these migration pointers.
# Migrate from v2 to v3
Source: https://docs.origami.chat/api-v2-to-v3-migration
Map v2 agent-and-run flows to v3 named operations and the shared Job.
v3 (`/api/v3`) replaces "one prompt endpoint + a fat run object" with **named,
typed operations** in three sections (Account, Leads, Send) and **one shared
Job** for all async work. v1 and v2 keep working with the same keys — nothing
you run today breaks — but new integrations should target v3 only.
## The shape change
In v2 you `POST /agents` with a prompt, poll a run object with eight statuses,
then separately wait out the cell pipeline. In v3 you call the operation you
mean (`leads.searches.create`, `send.campaigns.launch`, …). Async operations
return a **Job** that stays `running` (with `phase: "enriching"`) until the work
*including enrichment* is done, so `succeeded` means the result counts are
final.
AGENT-tagged operations still run the Origami agent under the hood — the brief
is still the steer — but as a tag on specific operations, not the whole API.
## Flow map
| v2 flow | v3 |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST /agents` + "find me leads …" | One-shot: `POST /leads/searches { brief, count, quality }`. List-first: `POST /leads/lists` + column ops, then `POST /leads/lists/{list_id}/fetch` |
| `POST /agents/{id}/runs` + "get more" | `POST /leads/searches/{search_id}/fetch-more { count }` (deterministic — no prompt) |
| `POST /agents/{id}/runs` + "enrich …" | `POST /leads/lists/{list_id}/enrich { column_slugs }` or `POST /leads/lists/{list_id}/enrich_custom { instructions }` |
| Run polling `GET /agents/{id}/runs/{runId}` | `GET /jobs/{job_id}` (honor `next_poll_at`) or `job.*` webhooks |
| `runs.status: needs_input` → new run with the answer | `POST /jobs/{job_id}/input { answers }` — same Job id resumes |
| `POST /tables/{tableId}/rows/upsert` | `POST /leads/lists/{list_id}/rows/upsert` — same semantics, snake\_case, embedded `enrichment_job` |
| `GET /tables/{tableId}/rows` | `GET /leads/lists/{list_id}/rows` with `min_relevance_score`, `sort`, `include_*`, `ids`, `format=csv` |
| `POST /tables/{tableId}/campaigns` (agent) | Deterministic recipe: `POST /send/campaigns` → schema → people → templates → settings → senders → approvals → launch. Agent shortcut: `POST /send/campaigns/draft` |
| `POST /campaigns/{id}/edits` + instructions | Named ops: people upsert / template variant ops / `PATCH .../settings` / sender pool ops |
| `POST /campaigns/{id}/launch` | `POST /send/campaigns/{campaign_id}/launch` — real `dry_run`, typed 409 blockers |
| `GET /senders` | `GET /account/senders` + manage ops (patch, IMAP connect, warmup, disconnect) |
| `GET /account`, `/account/credits` | Same, plus `/account/credits/usage` and `/account/rate-limits` |
| `GET /projects` … | `/account/projects` … |
| Workspaces / `workspaceId` | Gone from v3. Chats (`/account/chats`) are the conversation container; lists and campaigns are org-scoped and linked to chats |
## Renames and status folding
* `AGENT_BUSY` → **`CHAT_BUSY`** (same 409, same per-session mutex; now carries
the blocking `details.job_id`).
* `NO_SENDING_ACCOUNT` → **`ACCOUNT_CONNECTION_REQUIRED`** (and
`ACCOUNT_RECONNECT_REQUIRED` when senders exist but all need reauth).
* `ROW_LIMIT_EXCEEDED` — unchanged.
v2's eight run statuses fold into six Job statuses. The recoverable ones
survive as `failed` + `error.code`:
| v2 run status | v3 Job |
| -------------- | --------------------------------------------------------------------- |
| `completed` | `succeeded` |
| `incomplete` | `failed`, `error.code: "AGENT_INCOMPLETE"`, `details.retryable: true` |
| `step_cap_hit` | `failed`, `error.code: "AGENT_STEP_CAP"`, `details.retryable: true` |
| `timed_out` | `failed`, `error.code: "AGENT_TIMED_OUT"`, `details.retryable: true` |
| `errored` | `failed`, `error.code: "AGENT_ERRORED"`, `details.retryable: false` |
| `cancelled` | `cancelled` (partial result kept, `result.partial: true`) |
| `needs_input` | `needs_input` — durable and webhook-emitting |
| `running` | `running` (stays running through enrichment) |
## Wire differences
* **snake\_case** everywhere (v2 is camelCase; the list envelope key is
`next_cursor`, not `nextCursor`).
* **Strict inputs**: unknown fields are `400 VALIDATION_ERROR` — a camelCase
body fails loudly instead of being silently stripped.
* Errors are a closed envelope `{ error, code, details?, handoff? }` — v2's
extra 402 top-level keys (`creditsRequired`, `topUpUrl`, …) moved into
`details` / `handoff`.
* Invalid cursors and out-of-range limits are 400s (v2 silently restarted /
clamped).
* Idempotency conflicts split: `IDEMPOTENCY_MISMATCH` (caller bug) vs
`IDEMPOTENCY_PENDING` (transient, retry after `Retry-After`).
* Rate-limit headers: v3's org bucket suffix is `Org`
(`X-RateLimit-Limit-Org`); v1/v2 keep `Global`. Same underlying buckets —
traffic on both versions shares one allowance.
* Keys now carry a **role** (existing keys were backfilled to `admin`; new keys
default to `member`). Admin-gated Account ops return `403` for member keys.
## New in v3
Job webhooks (`job.*`) and client `metadata` correlation · `result.row_ids` +
rows `ids` filter (the CRM-sync loop) · cross-list dedup (`exclude_list_ids`) ·
pool depth (`remaining_count`, `has_more`) · `relevance_weight` read/patch ·
row status fields + `format=csv` · list funnel stats · bulk exclusion sync ·
sender/domain/mailbox management · project-bound keys · approvals ops ·
joinable reply webhooks (`campaign_id`, row refs).
# Authentication
Source: https://docs.origami.chat/authentication
Create and manage API keys, scope requests to projects, and stay within rate limits.
The Origami API uses **API keys** for authentication. The same key works on v3,
v2, and v1. Every key is **parent-wide**: it belongs to your parent (agency)
organization and can act on the parent or any of its projects.
v3 keys also carry a **role**: `member` (default for new keys) or `admin`.
Existing keys were backfilled to `admin`. Admin-gated Account operations
(webhook CRUD, key create/revoke, domain purchase) return `403` for member
keys.
## Creating an API key
1. Go to **Settings → Developers** in the Origami app
2. Click **Create API key**
3. Give it a descriptive name (e.g. "n8n integration", "CRM sync")
4. Copy the key immediately — it's shown only once
API keys follow the format:
```bash theme={null}
og_live_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd
```
The `og_live_` prefix makes keys identifiable in leaked-credential scans (GitHub
secret scanning, GitGuardian, etc.).
## Using your API key
Pass the key in the `Authorization` header as a Bearer token:
```bash theme={null}
curl https://origami.chat/api/v3/account \
-H "Authorization: Bearer og_live_YOUR_KEY"
```
Every request must include this header. Requests without a valid key return
`401 UNAUTHORIZED`. Organizations whose plan doesn't include API access return
`402` with `code: "SUBSCRIPTION_REQUIRED"` — upgrade to a plan with API access.
## Projects and the x-origami-project header
Because keys are parent-wide, you choose which org a request acts on with the
`x-origami-project` header:
* **Omit it** to act on the parent org.
* **Send `x-origami-project: `** to scope the request to that
[project](/v3/reference/account-projects-list) (a child org).
```bash theme={null}
curl https://origami.chat/api/v3/leads/lists \
-H "Authorization: Bearer og_live_YOUR_KEY" \
-H "x-origami-project: 3f1c9b2a-0e5d-4a77-9c11-2b6d8e4f5a90"
```
On v3 the header applies to Leads, Send, Jobs, and — within Account — chats and
exclusion lists. It is ignored by project management, org reads, senders,
domains, mailboxes, webhooks, and API-key routes.
On v2 two surfaces ignore it: `/projects/*` always manages projects from the
parent, and `/account` is always parent-scoped.
The header fails closed. A malformed id returns `400 VALIDATION_ERROR`; an
unknown, cross-parent, or deleted project returns `404 PROJECT_NOT_FOUND`. The
plan gate and rate limits stay keyed to the parent, and the concurrent-agent pool
is shared across the whole parent. Credits spent inside a project draw on the
parent's shared wallet, subject to the project's optional monthly credit budget
cap and its enforcement mode (`"hard"` blocks spend at the cap; `"soft"` tracks
without blocking).
Manage projects with the [Projects endpoints](/v3/reference/account-projects-list).
v2 tenancy is documented under [objects and relationships](/agents/objects#tenancy-parent-org-and-projects).
## Key management
* **Multiple keys:** Create as many keys as you need — one per integration is
recommended.
* **Revocation:** Revoke a key anytime from the API keys settings page. Revoked
keys return `401` immediately.
* **Rotation:** To rotate a key, create a new one, update your integration, then
revoke the old one.
* **Identification:** The UI shows the last 4 characters of each key.
## Security best practices
Never commit API keys to source control. Use environment variables or a secrets
manager.
* Store keys in environment variables (`ORIGAMI_API_KEY`) or a secrets manager
* Use separate keys for development and production
* Revoke keys immediately if they may have been exposed
* Review the API keys settings page periodically and remove unused keys
## Rate limits
Limits apply per client IP and per organization. Both stay keyed to the parent
org, even when a request is scoped to a project.
| Scope | Limit |
| ---------------------------------------- | -------------------------------------------------------------------------------------------- |
| Per client IP | **300 requests / minute** |
| Per organization | **100 requests / minute** |
| Concurrent agent runs (per organization) | **Plan-tunable** (1 on starter, 3 on pro, 10 on scale, 20 on ultra, unlimited on enterprise) |
The scarce resource for agent work is the concurrent-run slot — exceeding it
returns `429` with `code: "CONCURRENT_LIMIT_EXCEEDED"` and a `Retry-After`
header.
When rate-limited, the API returns `429 Too Many Requests`. Responses carry
usage headers so you can track quota proactively:
| Header | Description |
| ------------------------------------------------------------------------- | ------------------------------------------------ |
| `X-RateLimit-Limit-IP` | Maximum requests allowed in the per-IP window |
| `X-RateLimit-Remaining-IP` | Requests remaining in the current per-IP window |
| `X-RateLimit-Reset-IP` | When the per-IP window resets |
| `X-RateLimit-Limit-Org` (v3) / `X-RateLimit-Limit-Global` (v1/v2) | Maximum requests allowed in the per-org window |
| `X-RateLimit-Remaining-Org` (v3) / `X-RateLimit-Remaining-Global` (v1/v2) | Requests remaining in the current per-org window |
| `X-RateLimit-Reset-Org` (v3) / `X-RateLimit-Reset-Global` (v1/v2) | When the per-org window resets |
v3 also exposes per-bucket standing at [`GET /account/rate-limits`](/v3/reference/account-rate-limits-get)
(org / expensive / insert / ip). Traffic on v1, v2, and v3 shares the same
underlying org allowance.
Rate limits use a sliding window. If you hit the limit, honor `Retry-After`
rather than retrying immediately.
# Introduction
Source: https://docs.origami.chat/index
Build, enrich, and read lead data programmatically with the Origami API.
Origami is an AI-powered lead generation and data enrichment platform. The
**v3 API** is the current way to drive it from your own code. You call named
operations for lists, campaigns, and account — and every async call returns a
**Job**.
* **Find leads.** Hand Origami a brief; it creates a list, sources rows, and
enriches them.
* **Bring your own data.** Upsert rows into a list, let its columns enrich them,
and read the results back.
* **Run outreach.** Build a campaign as a linear recipe: people, templates,
senders, launch.
The API is resource-oriented and self-describing. If you've used Stripe, it will
feel familiar. Start with [the v3 API](/v3/overview) for the conventions, then the
[quickstart](/v3/quickstart) to run your first search.
Building with an AI coding assistant? [Download the OpenAPI spec](https://raw.githubusercontent.com/Origami-Agents/mintlify-docs/main/openapi-v3.yaml)
for client generators, Postman, or your editor — or install the
[Origami skill](/v3/skill).
## Base URL
HTTP requests go to:
```text theme={null}
https://origami.chat/api/v3
```
The hosted MCP endpoint is `https://origami.chat/mcp`. Same bearer. See
[the v3 overview](/v3/overview#three-ways-to-call-it) for the client config.
Authenticate with a Bearer API key. See [authentication](/authentication).
## Core conventions
A few conventions hold across every v3 endpoint:
* **snake\_case.** Request and response fields are `snake_case`. Unknown fields
are `400 VALIDATION_ERROR`.
* **Self-describing objects.** Every object carries an `object` field naming its
type (`"job"`, `"list"`, `"campaign"`, …).
* **One list envelope.** Every list endpoint returns
`{ "object": "list", "items": [...], "next_cursor": string | null, "url": string }`.
Pass `next_cursor` back as `cursor` to page; `null` means the last page.
* **Async work is a Job.** Async POSTs return `202` with a Job. Poll until
`status` is terminal, honoring `next_poll_at`, or subscribe to `job.*`
webhooks.
* **Idempotency.** Any `POST` may send an `Idempotency-Key` header for safe
retries.
* **Errors.** Every error is `{ error, code, details?, handoff? }` with an
`UPPERCASE_SNAKE_CASE` code.
## Quick links
Named operations, the Job object, and how the three sections fit together.
Find leads with a brief, poll the Job, and read the rows back.
API keys, roles, rate limits, and project scoping.
Signed POSTs for sequencer activity, table runs, and Job transitions.
Map every v2 flow to its v3 operation.
The previous API. Still fully functional; deprecated for new work.
v1 and v2 remain available with the same keys, with no removal date. New
integrations should target v3. See the
[v2 → v3](/api-v2-to-v3-migration) and [v1 → v2](/api-v1-to-v2-migration)
migration guides.
# Quickstart
Source: https://docs.origami.chat/quickstart
Enrich your first batch of companies with the v2 API in under 5 minutes.
This is the **v2** upsert walkthrough. For new integrations, start with the
[v3 quickstart](/v3/quickstart).
This walkthrough brings your own rows into a table, enriches them, and reads the
results back. To have the agent build a table from a brief instead, follow the
[agent quickstart](/agents/quickstart).
Building with an AI coding assistant? Download the
[OpenAPI spec](https://raw.githubusercontent.com/Origami-Agents/mintlify-docs/main/openapi-v2.yaml)
and paste it into your tool, or install the [Origami skill](/agents/skill).
## Prerequisites
* An Origami account with at least one table set up
* An API key (create one in **Settings → API keys**)
```bash theme={null}
export ORIGAMI_API_KEY=og_live_your_key_here
```
## Step 1: Find your table and its columns
List tables to find the one you want to write into. The response is the standard
list envelope — objects under `items`, with `nextCursor` for paging.
```bash theme={null}
curl "https://origami.chat/api/v2/tables" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
```json Example response theme={null}
{
"object": "list",
"items": [
{
"object": "table",
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"workspaceId": "9b7c…",
"name": "Series A SaaS Companies",
"leadCount": 150,
"url": "https://origami.chat/workspace/9b7c…?table=d290f1ee…"
}
],
"nextCursor": null,
"url": "/api/v2/tables"
}
```
Then read the table's columns to get the input-column **slugs** — you'll use these
as row keys in the next step.
```bash theme={null}
curl "https://origami.chat/api/v2/tables/TABLE_ID/columns" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
```json Columns theme={null}
{
"object": "list",
"items": [
{ "object": "column", "name": "Company Name", "slug": "company-name", "kind": "input" },
{ "object": "column", "name": "Website", "slug": "website", "kind": "input" },
{ "object": "column", "name": "CEO Email", "slug": "ceo-email", "kind": "enrichment", "autoTrigger": true }
],
"nextCursor": null,
"url": "/api/v2/tables/TABLE_ID/columns"
}
```
Only `input` columns are writable. Enrichment, score, and sequence columns are
populated automatically.
## Step 2: Upsert rows
v2 has one row-write primitive: **upsert**. Rows are keyed by input-column slug.
`matchColumns` decides identity — a row matching an existing row on every match
value updates it; a non-matching row inserts. An insert-only call is just an
upsert whose rows match nothing.
```bash theme={null}
curl -X POST https://origami.chat/api/v2/tables/TABLE_ID/rows/upsert \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"rows": [
{ "company-name": "Acme Corp", "website": "acme.com" },
{ "company-name": "Beta Inc", "website": "beta.io" }
],
"matchColumns": ["website"]
}'
```
The response references an **enrichment run** — the tracked batch of work — that
you poll next.
```json Response theme={null}
{
"object": "enrichment_run",
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"batchId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"tableRunId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"counts": { "inserted": 2, "updated": 0, "skipped": 0 }
}
```
`tableRunId` points to the parent table run for `enrich=true` batches (the default).
Poll its status or await the `table.run.completed` webhook via
[`GET /api/v2/tables/{tableId}/runs/{runId}`](/agents/reference/get-table-run).
It is `null` for `enrich=false` writes.
Set `"enrich": false` to upsert without triggering enrichment. To also
re-enrich rows the upsert *updated*, set `"reenrichUpdated": true`.
For safe retries, include a `"batchId"` (any UUID you generate) in the body, or
send an `Idempotency-Key` header. A retry with the same `batchId` returns the
existing run instead of writing duplicate rows.
## Step 3: Poll the enrichment run
Use the run `id` to check progress. Keep polling until `status` is terminal.
```bash theme={null}
curl "https://origami.chat/api/v2/enrichment-runs/RUN_ID" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
```json Processing theme={null}
{
"object": "enrichment_run",
"id": "f47ac10b-…",
"tableId": "d290f1ee-…",
"type": "upsert",
"status": "processing",
"rowCount": 2,
"enrichments": { "total": 4, "completed": 1, "pending": 3, "failed": 0 },
"creditsUsed": 0,
"createdAt": "2025-07-01T14:30:00Z",
"completedAt": null,
"tableRunId": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}
```
```json Complete theme={null}
{
"object": "enrichment_run",
"id": "f47ac10b-…",
"tableId": "d290f1ee-…",
"type": "upsert",
"status": "complete",
"rowCount": 2,
"enrichments": { "total": 4, "completed": 4, "pending": 0, "failed": 0 },
"creditsUsed": 12,
"outcomeCounts": { "inserted": 2, "updated": 0, "skipped": 0 },
"createdAt": "2025-07-01T14:30:00Z",
"completedAt": "2025-07-01T14:31:15Z",
"tableRunId": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}
```
For `upsert` runs, the detail response also carries a per-row `outcomes[]` ledger
telling you exactly which input row inserted, updated, or was skipped.
## Step 4: Read the enriched rows
Pull rows from the table. Reads are free. By default cells are typed; add
`?cells=flat` for the simpler `{ slug: value }` shape, or `?format=csv` to
download a spreadsheet.
```bash theme={null}
curl "https://origami.chat/api/v2/tables/TABLE_ID/rows?cells=flat" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
See [reading data](/reading-data) for filters, sorting, pagination, and CSV
export.
## What's next
The objects the API is built from and how they connect.
Let the agent build the table from a plain-English brief.
Filter, sort, and export your enriched rows.
Managing API keys and project scoping.
# Reading data
Source: https://docs.origami.chat/reading-data
Filter, sort, paginate, export, and delete enriched table rows with the v2 API.
This page documents the **v2** row-read surface. On v3, use
[`GET /api/v3/leads/lists/{list_id}/rows`](/v3/reference/leads-lists-rows-list)
(`min_relevance_score`, `ids`, `format=csv`, `next_cursor`).
[`GET /api/v2/tables/{tableId}/rows`](/agents/reference/list-rows) returns a
table's rows with support for filtering, sorting, cursor pagination, and CSV
export. Reads are free.
## Basic request
```bash theme={null}
curl "https://origami.chat/api/v2/tables/TABLE_ID/rows" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
Rows come back in the standard list envelope, plus a top-level `total` (the
filtered row count for the whole query, across all pages). Each row is a typed
object keyed by column slug.
```json theme={null}
{
"object": "list",
"items": [
{
"object": "row",
"id": "a1b2c3d4-…",
"cells": {
"company-name": { "type": "scalar", "value": "Acme Corp" },
"website": { "type": "scalar", "value": "acme.com" },
"ceo-email": { "type": "value", "value": "ceo@acme.com" }
}
}
],
"nextCursor": "eyJ…",
"total": 150,
"url": "/api/v2/tables/TABLE_ID/rows"
}
```
By default the table's saved filters and sort order apply — the same view you see
in the Origami dashboard.
### Typed vs flat cells
Cells are polymorphic by default: `scalar` for input columns, `value` (with run
metadata where present) for enrichments, and `sequence` for outreach columns.
For the simpler v1-style `{ slug: value }` rows, pass `?cells=flat`.
```bash theme={null}
curl "https://origami.chat/api/v2/tables/TABLE_ID/rows?cells=flat" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
## Pagination
Pagination is cursor-based. Read the first page, then pass its `nextCursor` back
as the `cursor` parameter to get the next one. Stop when `nextCursor` is `null`.
```bash theme={null}
curl "https://origami.chat/api/v2/tables/TABLE_ID/rows?limit=200&cursor=eyJ…" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
`limit` defaults to 50. This endpoint allows up to **200** rows per page (most
other list endpoints cap at 100). There is no `page` or `pageSize`.
## Filtering
Pass a JSON-encoded array of filter objects in the `filters` parameter. Each
filter uses a column **slug**, an `operator`, and a `value`.
```bash theme={null}
curl -G "https://origami.chat/api/v2/tables/TABLE_ID/rows" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
--data-urlencode 'filters=[{"column":"website","operator":"is_not_empty","value":""}]'
```
Available operators: `contains`, `not_contains`, `equals`, `not_equals`,
`is_empty`, `is_not_empty`, `greater_than`, `greater_than_or_equal`,
`less_than`, `less_than_or_equal`. An unknown column slug returns
`UNKNOWN_COLUMN`.
## Sorting
Pass a JSON-encoded sort object with a column **slug** and `direction`.
```bash theme={null}
curl -G "https://origami.chat/api/v2/tables/TABLE_ID/rows" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
--data-urlencode 'sort={"column":"quality-score","direction":"desc"}'
```
## Bypassing saved defaults
By default the API applies the table's saved filters and sort order. Set
`defaults=false` to read all rows unfiltered, in insertion order.
```bash theme={null}
curl "https://origami.chat/api/v2/tables/TABLE_ID/rows?defaults=false" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
## CSV export
Set `format=csv` to stream the (flat) rows as a CSV file instead of JSON —
column names as headers, ready for a spreadsheet.
```bash theme={null}
curl "https://origami.chat/api/v2/tables/TABLE_ID/rows?format=csv&limit=200" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-o export.csv
```
## Reading a single row or cell
To read one row, use
[`GET /api/v2/tables/{tableId}/rows/{rowId}`](/agents/reference/get-row). To read
a single cell — with its run metadata — use
[`GET /api/v2/tables/{tableId}/rows/{rowId}/cells/{columnId}`](/agents/reference/get-cell).
## Deleting rows
To soft-delete rows in bulk, call
[`DELETE /api/v2/tables/{tableId}/rows`](/agents/reference/delete-rows) with up to
**100** row ids. Each row and its cells, cell runs, and active sequences cascade
off. Ids that don't belong to the table, belong to another org, or are already
deleted are skipped — so `deleted` may be less than `requested`. A repeat delete of
the same ids is idempotent (`deleted: 0`). Deleted rows are recoverable via the
app's restore flow.
```bash theme={null}
curl -X DELETE "https://origami.chat/api/v2/tables/TABLE_ID/rows" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"rowIds": [
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"b2c3d4e5-f6a7-8901-bcde-f12345678901"
]
}'
```
```json Response theme={null}
{
"deleted": 2,
"requested": 2
}
```
This page covers the v2 read endpoint. The deprecated v1 `GET /tables/{tableId}/rows`
(offset pagination, flat rows) still works — see the
[migration guide](/api-v1-to-v2-migration).
# Set up the account
Source: https://docs.origami.chat/v3/account
Senders, domains, credits, exclusions, projects, webhooks, and keys.
Account holds everything Leads and Send depend on. Most of it you configure once
and then forget.
Start by confirming what your organization can do:
```bash theme={null}
curl https://origami.chat/api/v3/account \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
That returns your plan, capability flags, how many agent runs you can have in
flight at once, and your project counts. Capability flags matter — features like
sender warmup are plan-gated and return `409 WARMUP_UNAVAILABLE` if yours
doesn't include them.
## Senders
A sender is a mailbox or LinkedIn account Origami sends from. They belong to the
organization, so connect once and use across every campaign.
OAuth never completes inside an API call. `POST /account/senders/connect`
returns a **handoff URL** that a human opens in a browser. Your code's job is
to surface that link, not to follow it.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/senders/connect \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"channel": "email"}'
```
Any SMTP/IMAP mailbox can be connected straight from the API instead:
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/senders/imap \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "dana@northwind.io",
"first_name": "Dana",
"last_name": "Okafor",
"smtp_host": "smtp.northwind.io", "smtp_port": 587,
"smtp_username": "dana@northwind.io", "smtp_password": "…",
"imap_host": "imap.northwind.io", "imap_port": 993,
"imap_username": "dana@northwind.io", "imap_password": "…"
}'
```
Credentials are write-only — no read ever returns them. Bad credentials fail
immediately with `SMTP_AUTH_FAILED` or `IMAP_UNREACHABLE` rather than failing
silently at send time.
### Keeping senders healthy
| Task | Call |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| Set the daily cap, signature, timezone, or gap between sends | [`PATCH …/senders/{sender_id}`](/v3/reference/account-senders-patch) |
| Turn on warmup (plan-gated) | [`POST …/warmup/enable`](/v3/reference/account-senders-warmup-enable) |
| Fix a sender showing `needs_reauth` | [`POST …/reconnect`](/v3/reference/account-senders-reconnect) |
| Send from an alias instead of the mailbox address | [`GET …/send-as-aliases`](/v3/reference/account-senders-send-as-aliases-list), then set `send_as_email` |
A sender whose authorization expired reports `needs_reauth` and blocks any
campaign launch that depends on it. Poll
[`GET /account/senders?status=needs_reauth`](/v3/reference/account-senders-list)
on a schedule and alert whoever owns the mailbox — a reconnect is another
handoff.
## Domains and mailboxes
Cold outreach on your primary domain risks your real email. Origami can buy
lookalike domains and run mailboxes on them for you.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/domains/search \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "northwind"}'
```
Search never charges. It returns availability and price.
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/account/domains/purchase?confirm=true" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domains": ["trynorthwind.com"], "forwarding_domain": "northwind.io"}'
```
Without `confirm=true` you get a priced preview and nothing is charged.
Charges hit the card on file, so this is admin-only and returns
`409 NO_PAYMENT_METHOD` if there isn't one. Set `forwarding_domain` so the
apex redirects to your real site — a domain that resolves to nothing hurts
deliverability.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/mailboxes \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"mailboxes": [{
"domain_id": "'"$DOMAIN_ID"'",
"local_part": "dana",
"first_name": "Dana",
"last_name": "Okafor"
}]}'
```
Always async — you get a Job. Once it succeeds the mailbox shows up as a
sender.
Domains auto-renew. Turn that off with
[`POST …/renewal/cancel`](/v3/reference/account-domains-renewal-cancel), and
change your mind with [`…/renewal/undo`](/v3/reference/account-domains-renewal-undo).
## Exclusion lists
Two lists — people and companies — checked when leads are sourced and again when
people are enrolled. This is your do-not-contact record: customers, competitors,
churned accounts, anyone who asked to be left alone.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/exclusion-lists/people \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"entries": [
{"email": "dana@northwind.io"},
{"linkedin_slug": "sam-reyes", "full_name": "Sam Reyes"},
{"company_domain": "acme.com", "company_name": "Acme"}
]}'
```
Adds are idempotent upserts, up to 1,000 per call. An entry with nothing
matchable in it returns `UNMATCHABLE_IDENTIFIER` — an email, a LinkedIn slug, or
a company domain is enough.
Each organization has one exclusion list. A project can either share its
parent's or keep a private one:
```bash theme={null}
curl -X PATCH https://origami.chat/api/v3/account/exclusion-lists \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "x-origami-project: $PROJECT_ID" \
-H "Content-Type: application/json" \
-d '{"source": "project"}'
```
Agencies usually want `project` so one client's suppression list never leaks
into another's. [`GET /account/exclusion-lists`](/v3/reference/account-exclusion-lists-get)
tells you which is in effect and how many entries it holds.
## Credits
```bash theme={null}
curl https://origami.chat/api/v3/account/credits \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
The balance is reservation-aware: credits committed to running Jobs are already
subtracted, so what you see is what you can actually spend. For a monthly
breakdown by section, use
[`GET /account/credits/usage?period=2026-08`](/v3/reference/account-credits-usage).
Reads never cost anything. Sourcing and enrichment do. Per-Job spend is on the
Job itself — see [credits on the Job object](/v3/jobs#credits).
## Projects
A project is a child organization with its own lists, campaigns, and chats. One
per client, if you run an agency.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/projects \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Northwind", "monthly_credit_cap": 5000, "enforcement": "hard"}'
```
`hard` enforcement blocks spend at the cap; `soft` tracks it and lets work
through. Credits still come from the parent's wallet either way.
Then scope requests with the `x-origami-project` header — see
[conventions](/v3/conventions#scoping-to-a-project) for exactly which routes
honor it.
## Webhooks
Rather than polling Jobs, have Origami call you.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/webhooks \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Job notifications",
"url": "https://api.northwind.io/hooks/origami",
"event_types": ["job.succeeded", "job.failed", "job.needs_input"]
}'
```
The signing secret is returned **once**, at creation. Store it immediately —
there is no way to read it back, only to
[rotate it](/v3/reference/account-webhooks-rotate). Verify every delivery with
it before trusting the payload.
[`POST …/test`](/v3/reference/account-webhooks-test) sends a `webhook.test`
event so you can confirm your endpoint works before real traffic depends on it.
Event catalog, signature verification, and retry behavior.
## API keys
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/keys \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "CRM sync", "role": "member"}'
```
Like webhook secrets, the key is shown once. A key can't be created with a role
above its creator's, and `member` keys get `403` on admin-only operations —
webhook management, key management, and domain purchase.
One key per integration. Revoking one takes effect immediately.
## Chats
The Origami app's assistant, over the API. Send a prompt and it does the work,
creating lists and campaigns as it goes.
```bash theme={null}
CHAT_ID=$(curl -sS -X POST https://origami.chat/api/v3/account/chats \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Q3 outbound"}' | jq -r '.id')
curl -X POST "https://origami.chat/api/v3/account/chats/$CHAT_ID/messages" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "Find 50 heads of RevOps at US SaaS companies and draft a campaign"}'
```
Each message returns a Job, and that Job may come back `needs_input` if the
agent has a question — answer it with
[`POST /jobs/{job_id}/input`](/v3/reference/jobs-input) and the same Job resumes.
Use chats when you want Origami to decide the steps. Use the Leads and Send
endpoints directly when your code should decide them.
# Conventions
Source: https://docs.origami.chat/v3/conventions
The rules that hold on every endpoint: paging, errors, previews, retries, and project scoping.
Learn these once and every endpoint behaves the way you expect.
## Fields are snake\_case, and typos are loud
Requests and responses use `snake_case`. Unknown request fields are **rejected**,
not ignored — a camelCase body fails with `400 VALIDATION_ERROR` rather than
silently doing nothing.
```json theme={null}
{ "error": "Unknown field: listId", "code": "VALIDATION_ERROR" }
```
That's deliberate. A misspelled optional field is a bug you want to hear about
on the first call, not after a campaign goes out with the wrong settings.
## Every collection pages the same way
List endpoints return one envelope:
```json theme={null}
{
"object": "list",
"items": [],
"next_cursor": "eyJpZCI6IjAxOTIu…",
"url": "/api/v3/leads/lists"
}
```
Pass `next_cursor` back as `cursor` to get the next page. `null` means you're
on the last one. `limit` defaults sensibly and caps at 100.
```bash theme={null}
curl "https://origami.chat/api/v3/leads/lists?limit=50&cursor=$NEXT" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
## Slow work returns a Job
Anything that can't finish inside a request returns `202` with a Job instead of
making you wait: sourcing leads, enrichment, generating copy, buying domains,
provisioning mailboxes, chat messages.
Poll [`GET /jobs/{job_id}`](/v3/reference/jobs-get) honoring `next_poll_at`, or
subscribe to [`job.*` webhooks](/webhooks/overview) and skip polling entirely.
Statuses, polling, cancelling, credits, and needs\_input.
## Destructive calls preview first
Operations that delete things or spend money return a **preview** of what would
happen unless you pass `confirm=true`. Nothing is destroyed and no card is
charged on the preview call.
```bash theme={null}
# What would this take with it?
curl -X DELETE "https://origami.chat/api/v3/leads/lists/$LIST_ID" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
# Actually do it
curl -X DELETE "https://origami.chat/api/v3/leads/lists/$LIST_ID?confirm=true" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
`confirm=true` applies to deleting a [list](/v3/reference/leads-lists-delete),
[project](/v3/reference/account-projects-delete), or
[campaign](/v3/reference/send-campaigns-delete), removing a
[campaign sender](/v3/reference/send-campaigns-senders-remove), clearing an
[exclusion list](/v3/reference/account-exclusion-lists-people-clear) or a
[template](/v3/reference/send-campaigns-templates-clear), and
[buying domains](/v3/reference/account-domains-purchase), where the preview
comes back priced.
Some operations use a `dry_run` body flag for the same idea:
[launching a campaign](/v3/reference/send-campaigns-launch) with
`{"dry_run": true}` reports exactly the gates a real launch would check, and
removing or stopping a person reports what it would affect.
## Retries are safe if you ask for it
Any `POST` may carry an `Idempotency-Key` header. Replaying the same key with
the same body returns the original result instead of doing the work twice.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/leads/searches \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Idempotency-Key: 8a1f0c3e-91d2-4c4a-8b7f-2e6d5a09c113" \
-H "Content-Type: application/json" \
-d '{"brief": "Heads of RevOps at 50-500 person US SaaS companies", "count": 25}'
```
| Response | Meaning |
| -------------------------- | ---------------------------------------------------------- |
| `409 IDEMPOTENCY_MISMATCH` | Same key, different body. Use a new key. |
| `409 IDEMPOTENCY_PENDING` | The first attempt is still in flight. Honor `Retry-After`. |
Use one key per logical operation — a UUID generated when your job starts, not
per HTTP attempt.
## Errors have one shape
```json theme={null}
{
"error": "Connect a sending account before launching.",
"code": "ACCOUNT_CONNECTION_REQUIRED",
"handoff": {
"kind": "connect-accounts",
"url": "https://origami.chat/settings/accounts",
"label": "Connect a mailbox"
}
}
```
`error` is for humans, `code` is for your `switch` statement. Branch on `code` —
the message text can change.
`handoff` appears when a person can fix the problem in the Origami dashboard but
your code can't: connecting a mailbox, re-authorizing one that expired, adding a
payment method, upgrading a plan, or confirming a card charge. Forward the URL
to whoever owns the account rather than treating it as a hard failure.
| Status | What it usually means |
| --------------------------- | ------------------------------------------------------------------------------------------------- |
| `400 VALIDATION_ERROR` | Bad or unknown field. The `details` object says which. |
| `401` | Missing, revoked, or malformed API key. |
| `402 SUBSCRIPTION_REQUIRED` | Your plan doesn't include API access. |
| `403` | A `member` key hit an admin-only operation. |
| `404` | Wrong id, or the resource is in a different project. |
| `409` | A state conflict — the `code` names it, e.g. `SEARCH_BUSY`, `NO_TEMPLATE`, `JOB_NOT_CANCELLABLE`. |
| `429` | Rate limited or out of agent-run slots. Honor `Retry-After`. |
## Scoping to a project
Keys are parent-wide. Send `x-origami-project: ` to act inside a
child project instead of the parent:
```bash theme={null}
curl https://origami.chat/api/v3/leads/lists \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "x-origami-project: 3f1c9b2a-0e5d-4a77-9c11-2b6d8e4f5a90"
```
The header applies to Leads, Send, Jobs, and — within Account — chats and
exclusion lists. It is ignored by project management, org reads, senders,
domains, mailboxes, webhooks, and API-key routes, which are always
parent-scoped.
## Rate limits
300 requests/minute per IP and 100/minute per organization, both keyed to the
parent org even for project-scoped requests. The binding constraint for agent
work is usually concurrent runs, not requests — exceeding that returns
`429 CONCURRENT_LIMIT_EXCEEDED`.
Check where you stand at any time with
[`GET /account/rate-limits`](/v3/reference/account-rate-limits-get). Full
details, including response headers, are in
[authentication](/authentication#rate-limits).
# The Job object
Source: https://docs.origami.chat/v3/jobs
How async work is represented, polled, cancelled, and pushed over webhooks.
Anything that can't finish inside a request hands you a **Job** and keeps
working in the background. Searches, fetches, enrichment, campaign drafts,
domain purchases, mailbox provisioning, and chat messages all return this same
object, so you write the waiting logic once.
```json theme={null}
{
"object": "job",
"id": "3f1c9b2a-0e5d-4a77-9c11-2b6d8e4f5a90",
"operation": "leads.searches.create",
"status": "running",
"cancel_requested": false,
"phase": "enriching",
"progress": { "done": 18, "total": 25 },
"target": { "type": "list", "id": "d290f1ee-6c54-4b01-90e6-d701748f0851" },
"metadata": { "sync_run": "2026-08-25" },
"next_poll_at": "2026-08-25T18:05:12Z",
"result": null,
"credits": { "spent": 12.5, "settled": false },
"error": null,
"needs_input": null,
"chat_id": null,
"created_at": "2026-08-25T18:04:01Z",
"updated_at": "2026-08-25T18:04:58Z"
}
```
## Status
| Status | Meaning |
| ------------- | ---------------------------------------------------------------------------- |
| `queued` | Admitted, not yet running. |
| `running` | Work is in flight. Honor `next_poll_at`. Stays `running` through enrichment. |
| `needs_input` | Paused for questions or a human handoff. Same Job id resumes. |
| `succeeded` | Done **including enrichment**. Result counts are final. |
| `failed` | Terminal failure. See `error.code`. Some agent failures are retryable. |
| `cancelled` | Cancel finished. Partial work is kept (`result.partial: true`). |
`succeeded` is the important difference from v2. A v2 run could report
`completed` while cells were still filling in. A v3 Job stays `running` through
enrichment, so when it succeeds the numbers are final and you can act on them
immediately.
## Polling
Poll [`GET /jobs/{job_id}`](/v3/reference/jobs-get) while `status` is `queued`
or `running`. Every running response carries `next_poll_at` and a `Retry-After`
header. Honor them: polling faster does not finish the Job sooner, because those
reads are served from a short-lived cache. See the
[quickstart](/v3/quickstart#step-2-poll-the-job) for a poll loop you can copy.
Lost a Job id? List them:
```bash theme={null}
curl "https://origami.chat/api/v3/jobs?status=running&target_type=list&target_id=$LIST_ID" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
Pass `metadata` when you admit the Job so you can correlate the list (and the
webhook) back to your own run id.
## Webhooks
Subscribe to the `job.*` group instead of polling:
| Event | When it fires |
| ----------------- | ------------------------------------- |
| `job.succeeded` | Job finished successfully |
| `job.failed` | Job failed |
| `job.cancelled` | Job was cancelled |
| `job.needs_input` | Job paused for questions or a handoff |
Payloads carry a compact summary (counts and resource ids), your `metadata`,
and `credits`. They never include row-level data or `row_ids` — fetch those
with [`GET /jobs/{job_id}`](/v3/reference/jobs-get). See the
[Job event reference](/webhooks/overview).
`sequence` on the payload is a monotonic generation. At-least-once retries can
arrive out of order — ignore any event whose `sequence` is not greater than the
last one you processed for that `job_id`.
## Credits
`credits.spent` is this Job's spend alone. `settled: false` on a
`quality: "accurate"` run means the number can still adjust down after
delivered-lead settlement. Reconcile billing after `settled: true`.
`credits` is `null` on operations that spend nothing.
## needs\_input
When `status` is `needs_input`, `needs_input` is one of:
* **`questions`** — answer with
[`POST /jobs/{job_id}/input`](/v3/reference/jobs-input)
`{ "answers": ["..."] }`. The same Job id resumes.
* **`handoff`** — a human step (for example Stripe SCA). Send the user the URL.
Origami resumes the Job itself after the in-app step completes.
## Cancel
[`POST /jobs/{job_id}/cancel`](/v3/reference/jobs-cancel) is cooperative. A
successful call sets `cancel_requested: true` and returns the current snapshot.
Repeating cancel on an already-flagged Job is an idempotent 200. A Job that
cannot be cancelled returns `409 JOB_NOT_CANCELLABLE`.
Partial work is kept. Credits already spent are not refunded.
## Retryable agent failures
Failed AGENT Jobs keep a retry signal on `error.code`:
| Code | Retry? |
| ------------------ | ------------------------------- |
| `AGENT_INCOMPLETE` | Yes — `details.retryable: true` |
| `AGENT_STEP_CAP` | Yes |
| `AGENT_TIMED_OUT` | Yes |
| `AGENT_ERRORED` | No |
Retryable means re-running the same call on the same resources is reasonable.
Don't create a fresh list and pay for the lookups twice.
## Where Jobs come from
| You called | You get back |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| [`leads.searches.create`](/v3/reference/leads-searches-create), [`leads.lists.fetch`](/v3/reference/leads-lists-fetch), [`fetch-more`](/v3/reference/leads-searches-fetch-more) | Sourcing, then enrichment |
| [`leads.lists.enrich`](/v3/reference/leads-lists-enrich), [`enrich_custom`](/v3/reference/leads-lists-enrich-custom) | Enrichment |
| [`leads.lists.rows.upsert`](/v3/reference/leads-lists-rows-upsert) with `enrich: true` | A sync response with an enrichment Job embedded |
| [`send.campaigns.draft`](/v3/reference/send-campaigns-draft), [`examples.generate`](/v3/reference/send-campaigns-examples-generate) | Copy generation |
| [`account.domains.purchase`](/v3/reference/account-domains-purchase), [`mailboxes.provision`](/v3/reference/account-mailboxes-provision) | Registration and provisioning |
| [`account.chats.messages.create`](/v3/reference/account-chats-messages-create) | The agent working through your prompt |
Everything else in v3 is synchronous — it either succeeds or errors on the spot.
# Build a list
Source: https://docs.origami.chat/v3/leads
Three ways to get people into a list, how columns research them, and how to read the results.
A **list** is a grid of people: rows are prospects, columns are facts about them.
Every Leads endpoint either puts rows in, defines what to find out about them, or
reads them back.
## Getting rows in
Pick whichever matches where your people come from.
You describe who you want in a sentence. Origami creates the list, picks
columns from your description, sources matching people, and researches them.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/leads/searches \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brief": "Heads of RevOps at 50-500 person US SaaS companies",
"count": 25
}'
```
Returns a Job. When it succeeds, `result` carries the new `list_id`, a
`search_id`, and the `row_ids` it added. This is the path the
[quickstart](/v3/quickstart) walks through.
There is no filter DSL — the brief *is* the query. Be specific about title,
company size, geography, and industry, and mention anything you want as a
column ("and whether they use HubSpot").
Create the list first, add the columns you care about, then source into it.
Use this when you want control over the schema, or when several searches
should land in the same place.
```bash theme={null}
LIST_ID=$(curl -sS -X POST https://origami.chat/api/v3/leads/lists \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "RevOps leads Q3"}' | jq -r '.id')
curl -X POST "https://origami.chat/api/v3/leads/lists/$LIST_ID/fetch" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brief": "Heads of RevOps at 50-500 person US SaaS companies",
"count": 25,
"quality": "accurate"
}'
```
`fetch` reuses the columns already on the list instead of inventing new ones.
Already have the people — a CRM export, a webinar signup list? Upsert them
and let Origami's columns do the research.
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/leads/lists/$LIST_ID/rows/upsert" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"match_columns": ["email"],
"enrich": true,
"rows": [
{"email": "dana@northwind.io", "name": "Dana Okafor"},
{"email": "sam@lattice-labs.com", "name": "Sam Reyes"}
]
}'
```
`match_columns` is how rows are deduplicated: a row whose `email` already
exists is updated rather than added. Up to 1,000 rows per call.
This one is **synchronous** — you get the result immediately. With
`enrich: true` the response also embeds an enrichment Job for the research
it kicked off.
### Asking for more of the same
A list keeps the search behind it, including how much of the matching pool is
left. To go deeper, continue the search instead of writing the brief again:
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/leads/lists/$LIST_ID/fetch-more" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"count": 100, "exclude_list_ids": ["'"$ALREADY_CONTACTED_LIST"'"]}'
```
Same criteria, same list, same columns, no repeats. Check
[`GET /leads/searches/{search_id}`](/v3/reference/leads-searches-get) first if
you want to know how deep the pool goes — `remaining_count` and `has_more` tell
you before you spend anything.
Start at `count: 10`, look at the rows, adjust the brief, *then* ask for
hundreds. A search you refine early is much cheaper than one you re-run.
### fast vs accurate
`quality` decides how hard Origami works to verify each lead.
| | `fast` | `accurate` |
| ------------ | ------------------- | ---------------------------------------------------------- |
| Speed | Quicker | Slower |
| Verification | Lighter | Deeper |
| Billing | Settles immediately | Settles after delivery, so `credits.spent` can adjust down |
On an `accurate` run the Job reports `credits.settled: false` until the final
number is known. Reconcile billing after it flips to `true`.
## Columns do the research
Columns are the questions you're asking about every row. Three kinds:
| Kind | What it does | How you get one |
| ---------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| **Code** | Runs Origami's research. These are what spend credits. | [Copy from another list](/v3/reference/leads-lists-columns-copy) — or let a search create them from your brief |
| **Score** | Rates each row for fit and feeds `relevance_score` | [`POST …/columns`](/v3/reference/leads-lists-columns-create) with `type: "score"` |
| **Static** | Holds values you write | [`POST …/columns`](/v3/reference/leads-lists-columns-create) with `type: "static"` |
Copying is how you standardize. Build the research columns you like on one list,
then copy them onto every new one:
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/leads/lists/$LIST_ID/columns/copy" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_list_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"source_column_id": "b7e1c2a3-4d5f-4a6b-8c9d-0e1f2a3b4c5d"
}'
```
If the column you copy depends on other columns, bring those too — otherwise
you get `409 MISSING_DEPENDENCY_COLUMNS`.
Score columns carry a `relevance_weight` from `very_low` to `required`. Set it
with [`PATCH …/columns/{column_id}`](/v3/reference/leads-lists-columns-patch);
`required` makes a failing row score zero.
### Filling cells in
Enrichment always returns a Job.
```bash theme={null}
# Fill specific columns, or omit column_slugs for everything auto-triggered
curl -X POST "https://origami.chat/api/v3/leads/lists/$LIST_ID/enrich" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"column_slugs": ["uses_hubspot", "funding_stage"]}'
```
Restrict it to certain rows with `row_ids`, or pass `reenrich: true` to redo
cells that already have values.
Need something that isn't a column yet? Describe it and let Origami add the
column and fill it in one call:
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/leads/lists/$LIST_ID/enrich_custom" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"instructions": "Do they publish a public engineering blog? Yes or no."}'
```
## Reading rows back
```bash theme={null}
curl "https://origami.chat/api/v3/leads/lists/$LIST_ID/rows?min_relevance_score=70&sort=relevance_score&order=desc&limit=100" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
Reads are free — they don't spend credits. The filters that matter:
| Parameter | Use |
| --------------------------------------------------------------- | --------------------------------------------------------------- |
| `ids` | Read exactly the rows a Job returned. Max 100 per call. |
| `min_relevance_score` | Keep only rows above a fit threshold |
| `sort` / `order` | `relevance_score` or `created_at`, either direction |
| `include_duplicates`, `include_excluded`, `include_disapproved` | Hidden rows are filtered out by default. Set these to see them. |
| `format=csv` | Export the whole thing as CSV instead of JSON |
| `cursor` / `limit` | Page through, up to 100 at a time |
Rows that are deduplicated, excluded, or disapproved stay in the list but are
hidden from normal reads — that's why a `total` in the app can be larger than
what you page through.
For the funnel behind those numbers — how many were sourced, how many survived
qualification, what it cost — use
[`GET /leads/lists/{list_id}/stats`](/v3/reference/leads-lists-stats-get).
## Keeping people out
Exclusion lists are checked when leads are sourced and again when people are
enrolled in a campaign. Add your customers, your competitors, and anyone who
opted out:
```bash theme={null}
curl -X POST https://origami.chat/api/v3/account/exclusion-lists/people \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"entries": [{"email": "dana@northwind.io"}, {"company_domain": "acme.com"}]}'
```
They live under Account because they're organization-wide — see
[account setup](/v3/account#exclusion-lists). For a one-off "not these people
again," pass `exclude_list_ids` on the search instead.
## What's next
Turn the list into outreach.
What to do while sourcing and enrichment run.
# Objects
Source: https://docs.origami.chat/v3/objects
Every noun in the v3 API: what it is, what creates it, and what it belongs to.
The API has about a dozen objects. Most of them you never create directly —
they appear as a side effect of asking for something. This page is the map.
Every object carries an `object` field naming its type, so a response is always
self-identifying:
```json theme={null}
{ "object": "list", "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "name": "RevOps leads" }
```
## Leads objects
A list holds rows (people) and columns (facts about them). It is the unit
everything in Leads hangs off, and the thing you hand to a campaign.
**Comes from** [`POST /leads/lists`](/v3/reference/leads-lists-create) for an
empty one, or [`POST /leads/searches`](/v3/reference/leads-searches-create),
which creates a list and fills it in one call.
A list is what v1 and v2 called a **table**. Rows, columns, and credits
work the same way; the URLs and field names changed.
A person in a list, plus their cells and status flags
(`relevance_score`, `is_deduplicated`, `is_excluded`).
**Comes from** a search or fetch that sources people, or from
[`POST /leads/lists/{list_id}/rows/upsert`](/v3/reference/leads-lists-rows-upsert)
when you bring your own data.
A column defines something you want to know about every row: their funding
stage, whether they use Salesforce, a fit score. Cells are the values, one
per row per column.
**Comes from** the search that created the list (it picks columns from your
brief), [`POST /leads/lists/{list_id}/columns`](/v3/reference/leads-lists-columns-create)
for a static or score column,
[`POST /leads/lists/{list_id}/enrich_custom`](/v3/reference/leads-lists-enrich-custom)
to add one from instructions, or
[`POST /leads/lists/{list_id}/columns/copy`](/v3/reference/leads-lists-columns-copy)
to reuse one from another list.
Columns come in three flavors. **Code** columns run Origami's own research
and are the ones that spend credits — you get them by copying from another
list. **Score** columns rate a row for fit and carry a
`relevance_weight`. **Static** columns just hold values you write.
A search remembers the brief you wrote and how deep the matching pool still
is (`remaining_count`, `has_more`, `tam_known`). Ask for more leads and it
continues where it left off instead of starting over.
**Comes from** [`POST /leads/searches`](/v3/reference/leads-searches-create)
or [`POST /leads/lists/{list_id}/fetch`](/v3/reference/leads-lists-fetch).
A list has at most one active search.
## Send objects
A campaign bundles the people you're contacting, the template they get, the
senders it goes out from, and the schedule. It moves through `draft` →
`active` → `paused`.
**Comes from** [`POST /send/campaigns`](/v3/reference/send-campaigns-create)
for a blank draft, or
[`POST /send/campaigns/draft`](/v3/reference/send-campaigns-draft), which
writes the schema and templates from a brief.
Each enrolled person has a `sequence_id`, contact details, per-step state,
and the exact copy that was rendered for them. Most `people` operations
address one by `sequence_id`.
**Comes from** [`POST /send/campaigns/{campaign_id}/people`](/v3/reference/send-campaigns-people-upsert),
either by pointing at a list (`list_id`, optionally specific `row_ids`) or
by passing people inline.
A list of `{ key, description, required }` fields declaring what context
each person carries into the template. `{ "fields": [] }` means
identity-only.
**Comes from** [`PUT /send/campaigns/{campaign_id}/people/schema`](/v3/reference/send-campaigns-people-schema-put),
or gets written for you by `campaigns.draft`. Set it before enrolling people
so their `context` has somewhere to land.
A template is a set of **variants** (A/B arms). Each variant has an
`instructions` steer and a list of **steps**: a channel (`email`,
`linkedin_message`, `linkedin_connect`, `linkedin_comment`,
`linkedin_react`, or `manual`), a body, a `delay_days`, and which parts
should be personalized per recipient. Every variant keeps a `variant_key`
that is never reused.
**Comes from** [`PUT /send/campaigns/{campaign_id}/templates`](/v3/reference/send-campaigns-templates-put)
to replace the whole thing, or
[`POST /send/campaigns/{campaign_id}/templates/sequences`](/v3/reference/send-campaigns-templates-sequences-append)
to add one variant at a time.
The template rendered against a specific person. Generate these before
launch to read what will go out, and edit any step that isn't right.
**Comes from** [`POST /send/campaigns/{campaign_id}/examples`](/v3/reference/send-campaigns-examples-generate).
One LLM call per person, so it returns a Job.
## Account objects
Senders belong to the organization, not to a campaign. Connect one once,
then add it to as many campaigns as you like. Each carries its own daily
limit, signature, timezone, and gap settings.
**Comes from** [`POST /account/senders/connect`](/v3/reference/account-senders-connect)
for Google, Microsoft, or LinkedIn (OAuth finishes in the browser — you get
a handoff URL), or
[`POST /account/senders/imap`](/v3/reference/account-senders-imap-create)
for any SMTP/IMAP mailbox. Credentials are write-only and never returned.
If you don't want to burn your primary domain on cold outreach, buy
lookalike domains through Origami and provision mailboxes on them. A
provisioned mailbox becomes a sender.
**Comes from** [`POST /account/domains/purchase`](/v3/reference/account-domains-purchase)
(charges the card on file) and then
[`POST /account/mailboxes`](/v3/reference/account-mailboxes-provision).
Two lists, people and companies, checked before anyone is enrolled.
Customers, competitors, churned accounts, anyone who asked to be left alone.
**Comes from** [`POST /account/exclusion-lists/people`](/v3/reference/account-exclusion-lists-people-add)
and the matching companies endpoint. Each organization has one; a project
can either share its parent's or keep a private one.
A project isolates lists, campaigns, and chats — one per client, if you're
an agency. It draws on the parent's credit wallet and can carry a monthly
cap.
**Comes from** [`POST /account/projects`](/v3/reference/account-projects-create).
Act inside one by sending `x-origami-project: `.
The same agent as the Origami app. Send it a prompt and it does the work,
creating lists and campaigns as it goes and linking them to the chat.
**Comes from** [`POST /account/chats`](/v3/reference/account-chats-create),
then [`POST /account/chats/{chat_id}/messages`](/v3/reference/account-chats-messages-create).
Each message returns a Job.
A webhook endpoint is a URL plus the event types it wants; it has a signing
secret shown once at creation and rotatable later. An API key authenticates
requests and carries a role (`member` or `admin`).
**Come from** [`POST /account/webhooks`](/v3/reference/account-webhooks-create)
and [`POST /account/keys`](/v3/reference/account-keys-create). Both are
admin-only.
## The Job
Every slow operation — sourcing, enrichment, copy generation, domain purchase,
chat — returns the same Job object instead of blocking. A Job tells you what
it's working on (`operation`, `target`, `phase`), how far along it is
(`progress`), when to check again (`next_poll_at`), and what it cost
(`credits`).
```json theme={null}
{
"object": "job",
"id": "3f1c9b2a-0e5d-4a77-9c11-2b6d8e4f5a90",
"operation": "leads.searches.create",
"status": "running",
"phase": "enriching",
"progress": { "done": 18, "total": 25 },
"target": { "type": "list", "id": "d290f1ee-6c54-4b01-90e6-d701748f0851" }
}
```
Statuses, polling, cancelling, credits, and the questions a Job can ask you.
# v3 API
Source: https://docs.origami.chat/v3/overview
What the API does, the handful of objects it works with, and where to start.
Origami finds people who look like your customers, researches them, and emails
them. The v3 API is that product with the buttons taken off:
* **Leads** builds a grid of prospects: rows are people, columns are facts about
them. You describe who you want in a sentence; Origami fills in the rows and
researches each one.
* **Send** turns those prospects into an email or LinkedIn sequence and runs it
on a schedule from mailboxes you own.
* **Account** holds everything the other two need: sending mailboxes, domains,
credits, do-not-contact lists, API keys, and webhooks.
Anything that takes longer than a request — sourcing leads, researching a
column, generating copy — hands you back a **Job** and finishes in the
background.
## How the pieces connect
```mermaid theme={null}
flowchart LR
B["Brief
'Heads of RevOps at
US SaaS companies'"] --> S[Search]
S --> L["List
rows + columns"]
L --> C["Campaign
people + template"]
C --> O["Scheduled
outreach"]
N["Senders
connected once,
reused everywhere"] --> C
```
Read it left to right and you have the whole API. A **brief** produces a
**search**, a search fills a **list**, a list feeds a **campaign**, and a
campaign sends from **senders** you connected once.
Each of those arrows is a Job — you fire the call, then either poll or take a
webhook when it lands. Same object every time, so you write that logic once.
Every noun in the API, on one page.
## Start here
Find 25 leads and read them back. Three requests, about five minutes.
The three ways rows get into a list, and how enrichment fills them in.
The send recipe in order: people, template, senders, launch.
Mailboxes, domains, credits, exclusions, webhooks, and keys.
## Making requests
Every v3 request goes to one base URL and carries an API key:
```bash theme={null}
curl https://origami.chat/api/v3/account \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
Create a key in **Settings → Developers**. Keys belong to your parent
organization; add `x-origami-project: ` to act inside a child
project instead. See [authentication](/authentication) for roles, project
scoping, and rate limits.
Beyond that there are five wire rules — snake\_case fields, one pagination
envelope, `202` plus a Job for async work, `Idempotency-Key` on retries, and a
single error shape. They are on one page:
Paging, errors, idempotency, destructive-call previews, and project scoping.
## Three ways to call it
The same operation catalog is exposed three ways, with the same names and the
same behavior:
| Transport | Use it when |
| ---------------------------------------- | --------------------------------------------------------- |
| **HTTP** — `https://origami.chat/api/v3` | You're writing code. Everything on this tab documents it. |
| **MCP** — `https://origami.chat/mcp` | You want an AI assistant to drive Origami directly. |
| **CLI** — `origami` | You're working in a terminal or a shell script. |
Operation ids like `leads.searches.create` are the same in all three, so the
reference pages here apply whichever one you use.
The MCP server is streamable HTTP at `/mcp`, with the same `og_live_…` bearer
as `/api/v3`. Point Cursor (or any MCP client) at:
```json theme={null}
{
"mcpServers": {
"origami": {
"url": "https://origami.chat/mcp",
"headers": {
"Authorization": "Bearer og_live_…"
}
}
}
}
```
Use `https://origami.chat/mcp`. `https://mcp.origami.chat` is not a host.
Building with an AI coding assistant?
[Install the Origami skill](/v3/skill) so it knows these operations, or
[download the OpenAPI spec](https://raw.githubusercontent.com/Origami-Agents/mintlify-docs/main/openapi-v3.yaml)
for client generators and Postman.
v1 and v2 still work with the same keys and have no removal date, but new
integrations should target v3. The
[v2 → v3 migration guide](/api-v2-to-v3-migration) maps every old flow to its
replacement.
# Quickstart
Source: https://docs.origami.chat/v3/quickstart
Find leads with a brief, poll the Job, and read the rows back.
Three requests, about five minutes: describe who you want, wait for Origami to
go find them, read them back. That's the whole loop, and every other Leads flow
is a variation on it.
New to the API? [The overview](/v3/overview) explains how the pieces fit
together first.
Building with an AI coding assistant? Download the
[OpenAPI spec](https://raw.githubusercontent.com/Origami-Agents/mintlify-docs/main/openapi-v3.yaml)
or [install the Origami skill](/v3/skill).
## Prerequisites
* An Origami account on a paid plan
* An API key (create one in **Settings → Developers**)
* `curl` and `jq`
```bash theme={null}
export ORIGAMI_API_KEY=og_live_your_key_here
```
## Step 1: Start a search
[`POST /leads/searches`](/v3/reference/leads-searches-create) creates a list,
sources leads, and researches them — all from one sentence. The brief is the
steer; there is no filter DSL, so say what you'd say to a researcher.
```bash theme={null}
curl -X POST https://origami.chat/api/v3/leads/searches \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brief": "Heads of RevOps at 50-500 person US SaaS companies",
"count": 25
}'
```
You get `202 Accepted` with a Job already `running`. Nothing is finished yet —
keep `id`, that's what you poll next.
```json theme={null}
{
"object": "job",
"id": "3f1c9b2a-0e5d-4a77-9c11-2b6d8e4f5a90",
"operation": "leads.searches.create",
"status": "running",
"phase": "researching",
"target": null,
"next_poll_at": "2026-08-25T18:05:12Z",
"result": null,
"credits": { "spent": 0, "settled": true }
}
```
## Step 2: Poll the Job
Poll [`GET /jobs/{job_id}`](/v3/reference/jobs-get) until `status` is no longer
`queued` or `running`. Honor `next_poll_at` (or the `Retry-After` header).
Polling faster than the hint is served from a short-lived cache — it costs you
requests without surfacing progress sooner.
```bash theme={null}
JOB_ID=3f1c9b2a-0e5d-4a77-9c11-2b6d8e4f5a90
while true; do
RESP=$(curl -fsSL -D /tmp/origami-headers \
"https://origami.chat/api/v3/jobs/$JOB_ID" \
-H "Authorization: Bearer $ORIGAMI_API_KEY")
STATUS=$(echo "$RESP" | jq -r '.status')
case "$STATUS" in queued|running) ;; *) break ;; esac
WAIT=$(grep -i '^retry-after:' /tmp/origami-headers | awk '{print $2}' | tr -d '\r')
sleep "${WAIT:-15}"
done
echo "$RESP" | jq .
```
A sourcing Job stays `running` with `phase: "enriching"` until the cells it owns
finish, so `succeeded` genuinely means done — result counts won't move
afterwards. Expect a few minutes for 25 leads.
```json theme={null}
{
"object": "job",
"id": "3f1c9b2a-0e5d-4a77-9c11-2b6d8e4f5a90",
"operation": "leads.searches.create",
"status": "succeeded",
"phase": null,
"target": { "type": "list", "id": "d290f1ee-6c54-4b01-90e6-d701748f0851" },
"result": {
"list_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"search_id": "7c4e2a11-9b80-4d33-a1f0-0c8e6b2d4a71",
"added": 23,
"row_ids": ["a1b2c3d4-1111-2222-3333-444455556666"]
},
"credits": { "spent": 47.5, "settled": true }
}
```
Prefer push over poll? Subscribe to [`job.succeeded`](/webhooks/overview) and
read the Job when the event arrives.
## Step 3: Read the rows
`result.row_ids` are exactly the rows this run added, so you can read them
without paging the whole list. Reads are free. Cap is 100 ids per call.
```bash theme={null}
curl "https://origami.chat/api/v3/leads/lists/$LIST_ID/rows?ids=$ROW_ID" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
```json theme={null}
{
"object": "list",
"items": [
{
"object": "row",
"id": "a1b2c3d4-1111-2222-3333-444455556666",
"relevance_score": 82,
"is_deduplicated": false,
"is_excluded": false
}
],
"next_cursor": null,
"url": "/api/v3/leads/lists/d290f1ee-6c54-4b01-90e6-d701748f0851/rows"
}
```
To export the whole list, pass `format=csv` on the same endpoint.
## What's next
You now have a list with 23 researched rows in it. From here:
Go deeper on the same search, bring your own rows, add research columns.
Enroll this list in an email or LinkedIn sequence.
Cancelling, credits, and the questions a Job can ask you.
Paging, errors, idempotency, and destructive-call previews.
Start at `count: 10`, read the rows, refine the brief, and only then ask for
hundreds with [`fetch-more`](/v3/reference/leads-searches-fetch-more) on the
same search. Refining early is much cheaper than re-running.
# Archive a chat
Source: https://docs.origami.chat/v3/reference/account-chats-archive
/openapi-v3.yaml delete /account/chats/{chat_id}
Archive a chat (soft-delete; no confirm required)
# Create a chat
Source: https://docs.origami.chat/v3/reference/account-chats-create
/openapi-v3.yaml post /account/chats
Create an empty chat
# Get a chat
Source: https://docs.origami.chat/v3/reference/account-chats-get
/openapi-v3.yaml get /account/chats/{chat_id}
Read one chat with linked list/campaign summaries
# Link a list or campaign
Source: https://docs.origami.chat/v3/reference/account-chats-links-create
/openapi-v3.yaml post /account/chats/{chat_id}/links
Link an existing list or campaign to a chat (idempotent)
# Unlink a list or campaign
Source: https://docs.origami.chat/v3/reference/account-chats-links-delete
/openapi-v3.yaml delete /account/chats/{chat_id}/links/{slug}
Unlink a list or campaign from a chat (does not delete it)
# List chats
Source: https://docs.origami.chat/v3/reference/account-chats-list
/openapi-v3.yaml get /account/chats
List chats (onboarding/content/scheduled excluded by default)
# Send a chat message
Source: https://docs.origami.chat/v3/reference/account-chats-messages-create
/openapi-v3.yaml post /account/chats/{chat_id}/messages
Send a chat message and run the agent (returns a Job)
# Rename a chat
Source: https://docs.origami.chat/v3/reference/account-chats-patch
/openapi-v3.yaml patch /account/chats/{chat_id}
Rename a chat
# Credit balance
Source: https://docs.origami.chat/v3/reference/account-credits-get
/openapi-v3.yaml get /account/credits
Read the reservation-aware wallet balance in credits
# Credit usage
Source: https://docs.origami.chat/v3/reference/account-credits-usage
/openapi-v3.yaml get /account/credits/usage
Credits spent in a month, with a per-section breakdown
# Set domain forwarding
Source: https://docs.origami.chat/v3/reference/account-domains-forwarding-set
/openapi-v3.yaml post /account/domains/{domain_id}/forwarding
Set apex forwarding for an owned domain
# Get a domain
Source: https://docs.origami.chat/v3/reference/account-domains-get
/openapi-v3.yaml get /account/domains/{domain_id}
Read one owned domain
# List your domains
Source: https://docs.origami.chat/v3/reference/account-domains-list
/openapi-v3.yaml get /account/domains
List owned DFY domains
# Buy domains
Source: https://docs.origami.chat/v3/reference/account-domains-purchase
/openapi-v3.yaml post /account/domains/purchase
Purchase domains on the card on file (priced preview without confirm)
# Turn off auto-renewal
Source: https://docs.origami.chat/v3/reference/account-domains-renewal-cancel
/openapi-v3.yaml post /account/domains/{domain_id}/renewal/cancel
Cancel auto-renewal for a domain
# Turn auto-renewal back on
Source: https://docs.origami.chat/v3/reference/account-domains-renewal-undo
/openapi-v3.yaml post /account/domains/{domain_id}/renewal/undo
Undo a renewal cancellation
# Search for domains
Source: https://docs.origami.chat/v3/reference/account-domains-search
/openapi-v3.yaml post /account/domains/search
Search available domains with prices (never charges)
# Exclude companies
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-companies-add
/openapi-v3.yaml post /account/exclusion-lists/companies
Add company entries (single or bulk ≤1,000; idempotent upsert)
# Clear excluded companies
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-companies-clear
/openapi-v3.yaml delete /account/exclusion-lists/companies
Clear the company exclusion list
# Un-exclude one company
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-companies-delete
/openapi-v3.yaml delete /account/exclusion-lists/companies/{entry_id}
Remove one company exclusion entry
# List excluded companies
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-companies-list
/openapi-v3.yaml get /account/exclusion-lists/companies
List company exclusion entries, filterable by identifier
# Get exclusion settings
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-get
/openapi-v3.yaml get /account/exclusion-lists
Read the effective exclusion-list source and entry counts
# Switch exclusion source
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-patch
/openapi-v3.yaml patch /account/exclusion-lists
Switch a project between the shared org list and its private list
# Exclude people
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-people-add
/openapi-v3.yaml post /account/exclusion-lists/people
Add people entries (single or bulk ≤1,000; idempotent upsert)
# Clear excluded people
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-people-clear
/openapi-v3.yaml delete /account/exclusion-lists/people
Clear the people exclusion list
# Un-exclude one person
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-people-delete
/openapi-v3.yaml delete /account/exclusion-lists/people/{entry_id}
Remove one people exclusion entry
# List excluded people
Source: https://docs.origami.chat/v3/reference/account-exclusion-lists-people-list
/openapi-v3.yaml get /account/exclusion-lists/people
List people exclusion entries, filterable by identifier
# Get your account
Source: https://docs.origami.chat/v3/reference/account-get
/openapi-v3.yaml get /account
Read the org, including plan, capability flags, concurrent agent runs, and project counts.
# Create an API key
Source: https://docs.origami.chat/v3/reference/account-keys-create
/openapi-v3.yaml post /account/keys
Create an API key (secret shown once; role ≤ creator's role)
# List API keys
Source: https://docs.origami.chat/v3/reference/account-keys-list
/openapi-v3.yaml get /account/keys
List API keys (names, suffix, role — never secrets)
# Revoke an API key
Source: https://docs.origami.chat/v3/reference/account-keys-revoke
/openapi-v3.yaml delete /account/keys/{key_id}
Revoke an API key
# List mailboxes
Source: https://docs.origami.chat/v3/reference/account-mailboxes-list
/openapi-v3.yaml get /account/mailboxes
List DFY mailboxes (no passwords)
# Create mailboxes
Source: https://docs.origami.chat/v3/reference/account-mailboxes-provision
/openapi-v3.yaml post /account/mailboxes
Provision mailboxes on owned domains (always async)
# Create a project
Source: https://docs.origami.chat/v3/reference/account-projects-create
/openapi-v3.yaml post /account/projects
Create a project (optional monthly credit cap)
# Delete a project
Source: https://docs.origami.chat/v3/reference/account-projects-delete
/openapi-v3.yaml delete /account/projects/{project_id}
Delete a project (previews lists/rows/chats/campaigns without confirm)
# Get a project
Source: https://docs.origami.chat/v3/reference/account-projects-get
/openapi-v3.yaml get /account/projects/{project_id}
Read one project
# List projects
Source: https://docs.origami.chat/v3/reference/account-projects-list
/openapi-v3.yaml get /account/projects
List projects under the parent org
# Update a project
Source: https://docs.origami.chat/v3/reference/account-projects-patch
/openapi-v3.yaml patch /account/projects/{project_id}
Rename a project or change its budget cap / enforcement
# Rate limits
Source: https://docs.origami.chat/v3/reference/account-rate-limits-get
/openapi-v3.yaml get /account/rate-limits
Current rate-limit buckets, with limit, remaining, and reset per bucket.
# Connect via OAuth
Source: https://docs.origami.chat/v3/reference/account-senders-connect
/openapi-v3.yaml post /account/senders/connect
Get a connect-accounts handoff URL (OAuth never completes in-process)
# Disconnect a sender
Source: https://docs.origami.chat/v3/reference/account-senders-delete
/openapi-v3.yaml delete /account/senders/{sender_id}
Disconnect a sender (previews stops when sends are scheduled)
# Get a sender
Source: https://docs.origami.chat/v3/reference/account-senders-get
/openapi-v3.yaml get /account/senders/{sender_id}
Read one sender (secrets never returned)
# Connect an SMTP/IMAP mailbox
Source: https://docs.origami.chat/v3/reference/account-senders-imap-create
/openapi-v3.yaml post /account/senders/imap
Connect a native SMTP/IMAP mailbox (write-only credentials)
# List senders
Source: https://docs.origami.chat/v3/reference/account-senders-list
/openapi-v3.yaml get /account/senders
List the org's email and LinkedIn senders
# Update a sender
Source: https://docs.origami.chat/v3/reference/account-senders-patch
/openapi-v3.yaml patch /account/senders/{sender_id}
Update safe sender settings (no credentials)
# Reconnect a sender
Source: https://docs.origami.chat/v3/reference/account-senders-reconnect
/openapi-v3.yaml post /account/senders/{sender_id}/reconnect
Reconnect a needs_reauth sender (handoff, or IMAP re-submit instruction)
# List send-as aliases
Source: https://docs.origami.chat/v3/reference/account-senders-send-as-aliases-list
/openapi-v3.yaml get /account/senders/{sender_id}/send-as-aliases
List verified send-as aliases (empty for IMAP/LinkedIn senders)
# Turn warmup off
Source: https://docs.origami.chat/v3/reference/account-senders-warmup-disable
/openapi-v3.yaml post /account/senders/{sender_id}/warmup/disable
Disable warmup for an email sender
# Turn warmup on
Source: https://docs.origami.chat/v3/reference/account-senders-warmup-enable
/openapi-v3.yaml post /account/senders/{sender_id}/warmup/enable
Enable warmup for an email sender (feature-gated)
# Create an endpoint
Source: https://docs.origami.chat/v3/reference/account-webhooks-create
/openapi-v3.yaml post /account/webhooks
Create a webhook endpoint (secret shown once)
# Delete an endpoint
Source: https://docs.origami.chat/v3/reference/account-webhooks-delete
/openapi-v3.yaml delete /account/webhooks/{webhook_id}
Delete a webhook endpoint
# Get an endpoint
Source: https://docs.origami.chat/v3/reference/account-webhooks-get
/openapi-v3.yaml get /account/webhooks/{webhook_id}
Read one webhook endpoint
# List endpoints
Source: https://docs.origami.chat/v3/reference/account-webhooks-list
/openapi-v3.yaml get /account/webhooks
List webhook endpoints
# Update an endpoint
Source: https://docs.origami.chat/v3/reference/account-webhooks-patch
/openapi-v3.yaml patch /account/webhooks/{webhook_id}
Update a webhook endpoint's url, events, or enabled state
# Rotate the signing secret
Source: https://docs.origami.chat/v3/reference/account-webhooks-rotate
/openapi-v3.yaml post /account/webhooks/{webhook_id}/rotate
Rotate an endpoint's signing secret (new secret shown once)
# Send a test event
Source: https://docs.origami.chat/v3/reference/account-webhooks-test
/openapi-v3.yaml post /account/webhooks/{webhook_id}/test
Send a webhook.test event to an endpoint
# Cancel a job
Source: https://docs.origami.chat/v3/reference/jobs-cancel
/openapi-v3.yaml post /jobs/{job_id}/cancel
Request cooperative cancel of a Job
# Get a job
Source: https://docs.origami.chat/v3/reference/jobs-get
/openapi-v3.yaml get /jobs/{job_id}
Read one job. Honor next_poll_at when you poll.
# Answer a job's questions
Source: https://docs.origami.chat/v3/reference/jobs-input
/openapi-v3.yaml post /jobs/{job_id}/input
Answer a needs_input Job's questions and resume it
# List jobs
Source: https://docs.origami.chat/v3/reference/jobs-list
/openapi-v3.yaml get /jobs
List the caller's visible Jobs, newest first
# Copy a column
Source: https://docs.origami.chat/v3/reference/leads-lists-columns-copy
/openapi-v3.yaml post /leads/lists/{list_id}/columns/copy
Copy a column from another list in scope (the only way to get a code column)
# Add a column
Source: https://docs.origami.chat/v3/reference/leads-lists-columns-create
/openapi-v3.yaml post /leads/lists/{list_id}/columns
Add a column (static or score; code columns are copy-only)
# Delete a column
Source: https://docs.origami.chat/v3/reference/leads-lists-columns-delete
/openapi-v3.yaml delete /leads/lists/{list_id}/columns/{column_id}
Delete a column (409 while other columns depend on it)
# Update a column
Source: https://docs.origami.chat/v3/reference/leads-lists-columns-patch
/openapi-v3.yaml patch /leads/lists/{list_id}/columns/{column_id}
Patch a column's name, is_active, or relevance_weight
# Create an empty list
Source: https://docs.origami.chat/v3/reference/leads-lists-create
/openapi-v3.yaml post /leads/lists
Create an empty list (no search, no rows, no agent)
# Delete a list
Source: https://docs.origami.chat/v3/reference/leads-lists-delete
/openapi-v3.yaml delete /leads/lists/{list_id}
Delete a list (previews without confirm; cancels its active Jobs)
# Enrich existing columns
Source: https://docs.origami.chat/v3/reference/leads-lists-enrich
/openapi-v3.yaml post /leads/lists/{list_id}/enrich
Enrich named code columns (or all auto-trigger columns)
# Enrich from instructions
Source: https://docs.origami.chat/v3/reference/leads-lists-enrich-custom
/openapi-v3.yaml post /leads/lists/{list_id}/enrich_custom
Add or fill a column from instructions (AGENT)
# Find leads for a list
Source: https://docs.origami.chat/v3/reference/leads-lists-fetch
/openapi-v3.yaml post /leads/lists/{list_id}/fetch
Fetch leads into this list from a brief (AGENT; compact list context)
# Find more leads
Source: https://docs.origami.chat/v3/reference/leads-lists-fetch-more
/openapi-v3.yaml post /leads/lists/{list_id}/fetch-more
Fetch more from the list's single active search (alias of searches fetch-more)
# Get a list
Source: https://docs.origami.chat/v3/reference/leads-lists-get
/openapi-v3.yaml get /leads/lists/{list_id}
Read one list with columns and searches
# List your lists
Source: https://docs.origami.chat/v3/reference/leads-lists-list
/openapi-v3.yaml get /leads/lists
List lead lists (exact case-insensitive name filter)
# Rename a list
Source: https://docs.origami.chat/v3/reference/leads-lists-patch
/openapi-v3.yaml patch /leads/lists/{list_id}
Rename a list
# Read one cell
Source: https://docs.origami.chat/v3/reference/leads-lists-rows-cells-get
/openapi-v3.yaml get /leads/lists/{list_id}/rows/{row_id}/cells/{column_id}
Read one cell (column id, not slug)
# Delete rows
Source: https://docs.origami.chat/v3/reference/leads-lists-rows-delete
/openapi-v3.yaml post /leads/lists/{list_id}/rows/delete
Bulk-delete rows (≤1,000; all rows when omitted; previews without confirm)
# Read one row
Source: https://docs.origami.chat/v3/reference/leads-lists-rows-get
/openapi-v3.yaml get /leads/lists/{list_id}/rows/{row_id}
Read one row (hidden rows return directly with status flags)
# Read rows
Source: https://docs.origami.chat/v3/reference/leads-lists-rows-list
/openapi-v3.yaml get /leads/lists/{list_id}/rows
Page rows with status fields, score filter, sort, and CSV export
# Add or update rows
Source: https://docs.origami.chat/v3/reference/leads-lists-rows-upsert
/openapi-v3.yaml post /leads/lists/{list_id}/rows/upsert
Upsert rows by match columns. Sync, and embeds an enrichment job when enrich is true.
# List stats
Source: https://docs.origami.chat/v3/reference/leads-lists-stats-get
/openapi-v3.yaml get /leads/lists/{list_id}/stats
Read the list's sourcing/qualification funnel and credit economics
# Search for leads
Source: https://docs.origami.chat/v3/reference/leads-searches-create
/openapi-v3.yaml post /leads/searches
One-shot search from a brief. Creates a list, runs the search, and returns the first page.
# Fetch more results
Source: https://docs.origami.chat/v3/reference/leads-searches-fetch-more
/openapi-v3.yaml post /leads/searches/{search_id}/fetch-more
Fetch more from this search (same criteria, list, and columns)
# Get a search
Source: https://docs.origami.chat/v3/reference/leads-searches-get
/openapi-v3.yaml get /leads/searches/{search_id}
Read a search with pool depth (remaining_count, has_more, tam_known)
# Approve messages
Source: https://docs.origami.chat/v3/reference/send-campaigns-approvals-approve
/openapi-v3.yaml post /send/campaigns/{campaign_id}/approvals/approve
Release approval holds (all when sequence_ids omitted; idempotent)
# List messages awaiting approval
Source: https://docs.origami.chat/v3/reference/send-campaigns-approvals-list
/openapi-v3.yaml get /send/campaigns/{campaign_id}/approvals
Page people currently held for approval, with rendered steps
# Create a campaign
Source: https://docs.origami.chat/v3/reference/send-campaigns-create
/openapi-v3.yaml post /send/campaigns
Create a blank draft campaign (no steps, people, or template required)
# Delete a campaign
Source: https://docs.origami.chat/v3/reference/send-campaigns-delete
/openapi-v3.yaml delete /send/campaigns/{campaign_id}
Delete a campaign (previews without confirm; cancels its Jobs)
# Draft from a brief
Source: https://docs.origami.chat/v3/reference/send-campaigns-draft
/openapi-v3.yaml post /send/campaigns/draft
Fill schema and templates from a brief. Never launches.
# Generate messages
Source: https://docs.origami.chat/v3/reference/send-campaigns-examples-generate
/openapi-v3.yaml post /send/campaigns/{campaign_id}/examples
Generate per-person copy (one LLM call per person; async Job)
# Read generated messages
Source: https://docs.origami.chat/v3/reference/send-campaigns-examples-list
/openapi-v3.yaml get /send/campaigns/{campaign_id}/examples
Page enrolled people with rendered steps
# Get a campaign
Source: https://docs.origami.chat/v3/reference/send-campaigns-get
/openapi-v3.yaml get /send/campaigns/{campaign_id}
Read one campaign, including schema, template, people, and settings.
# Launch a campaign
Source: https://docs.origami.chat/v3/reference/send-campaigns-launch
/openapi-v3.yaml post /send/campaigns/{campaign_id}/launch
Launch a draft (or resume a paused) campaign; dry_run reports the same gates
# List campaigns
Source: https://docs.origami.chat/v3/reference/send-campaigns-list
/openapi-v3.yaml get /send/campaigns
List campaigns (status and exact name filters)
# Pause a campaign
Source: https://docs.origami.chat/v3/reference/send-campaigns-pause
/openapi-v3.yaml post /send/campaigns/{campaign_id}/pause
Pause an active campaign (idempotent)
# Update contact details
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-contact-patch
/openapi-v3.yaml patch /send/campaigns/{campaign_id}/people/{sequence_id}/contact
Patch contact info (identity pre-start only; timezone any time)
# Delete a person
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-delete
/openapi-v3.yaml delete /send/campaigns/{campaign_id}/people/{sequence_id}
Delete a person (re-opens dedup; force required after sends)
# Get one person
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-get
/openapi-v3.yaml get /send/campaigns/{campaign_id}/people/{sequence_id}
Read one person's sequence, including identity, per-step state, and sent copy.
# List enrolled people
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-list
/openapi-v3.yaml get /send/campaigns/{campaign_id}/people
Page enrolled people (status filter includes bounced and unsubscribed)
# Cancel a sequence
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-remove
/openapi-v3.yaml post /send/campaigns/{campaign_id}/people/{sequence_id}/remove
Cancel a sequence; the person stays in the campaign's dedup set
# Cancel sequences in bulk
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-remove-bulk
/openapi-v3.yaml post /send/campaigns/{campaign_id}/people/remove
Bulk remove sequences (≤1,000; per-id outcomes)
# Undo edits to a person
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-revert
/openapi-v3.yaml post /send/campaigns/{campaign_id}/people/{sequence_id}/revert
Discard person-level edits and re-render from the current template
# Get the person fields
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-schema-get
/openapi-v3.yaml get /send/campaigns/{campaign_id}/people/schema
Read the campaign's People-tab columns (person_schema)
# Set the person fields
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-schema-put
/openapi-v3.yaml put /send/campaigns/{campaign_id}/people/schema
Replace the person schema. An empty fields list means identity-only.
# Pin a sender to a person
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-sender-pin
/openapi-v3.yaml post /send/campaigns/{campaign_id}/people/{sequence_id}/sender
Pin a pool sender to one person (optionally per channel)
# Unpin a person's sender
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-sender-unpin
/openapi-v3.yaml delete /send/campaigns/{campaign_id}/people/{sequence_id}/sender
Unpin a person's sender (optionally per channel)
# Edit one message
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-steps-patch
/openapi-v3.yaml patch /send/campaigns/{campaign_id}/people/{sequence_id}/steps/{step_index}
Edit one person's rendered copy for a not-yet-sent step
# Stop a sequence
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-stop
/openapi-v3.yaml post /send/campaigns/{campaign_id}/people/{sequence_id}/stop
Stop future steps, preserving history
# Enroll people
Source: https://docs.origami.chat/v3/reference/send-campaigns-people-upsert
/openapi-v3.yaml post /send/campaigns/{campaign_id}/people
Enroll or update people (sync; from a list or inline; ≤1,000/call)
# Resume a campaign
Source: https://docs.origami.chat/v3/reference/send-campaigns-resume
/openapi-v3.yaml post /send/campaigns/{campaign_id}/resume
Resume a paused campaign (same transition as launch on paused)
# Add a sender
Source: https://docs.origami.chat/v3/reference/send-campaigns-senders-add
/openapi-v3.yaml post /send/campaigns/{campaign_id}/senders
Add an org sender to the campaign pool
# List campaign senders
Source: https://docs.origami.chat/v3/reference/send-campaigns-senders-list
/openapi-v3.yaml get /send/campaigns/{campaign_id}/senders
Read the campaign sender pool plus available org senders
# Remove a sender
Source: https://docs.origami.chat/v3/reference/send-campaigns-senders-remove
/openapi-v3.yaml delete /send/campaigns/{campaign_id}/senders/{sender_id}
Remove a pool sender (previews affected_people without confirm)
# Get settings
Source: https://docs.origami.chat/v3/reference/send-campaigns-settings-get
/openapi-v3.yaml get /send/campaigns/{campaign_id}/settings
Read campaign settings
# Update settings
Source: https://docs.origami.chat/v3/reference/send-campaigns-settings-patch
/openapi-v3.yaml patch /send/campaigns/{campaign_id}/settings
Patch settings (legal in every status; omitted fields unchanged)
# Campaign stats
Source: https://docs.origami.chat/v3/reference/send-campaigns-stats-get
/openapi-v3.yaml get /send/campaigns/{campaign_id}/stats
Read campaign counts and rates (per-variant stats deferred)
# Clear the template
Source: https://docs.origami.chat/v3/reference/send-campaigns-templates-clear
/openapi-v3.yaml delete /send/campaigns/{campaign_id}/templates
Clear the template to an empty sequences list.
# Get the template
Source: https://docs.origami.chat/v3/reference/send-campaigns-templates-get
/openapi-v3.yaml get /send/campaigns/{campaign_id}/templates
Read the campaign template (variant_key always present on reads)
# Replace the template
Source: https://docs.origami.chat/v3/reference/send-campaigns-templates-put
/openapi-v3.yaml put /send/campaigns/{campaign_id}/templates
Replace the whole template (existing variant keys kept, new ones minted)
# Add a variant
Source: https://docs.origami.chat/v3/reference/send-campaigns-templates-sequences-append
/openapi-v3.yaml post /send/campaigns/{campaign_id}/templates/sequences
Append one template variant (fresh never-reused key)
# Delete a variant
Source: https://docs.origami.chat/v3/reference/send-campaigns-templates-sequences-delete
/openapi-v3.yaml delete /send/campaigns/{campaign_id}/templates/sequences/{variant_key}
Delete one variant (its key is never reused)
# Update a variant
Source: https://docs.origami.chat/v3/reference/send-campaigns-templates-sequences-patch
/openapi-v3.yaml patch /send/campaigns/{campaign_id}/templates/sequences/{variant_key}
Patch one variant's copy, instructions, or paused state
# Run a campaign
Source: https://docs.origami.chat/v3/send
The send recipe in order: people, template, senders, preview, launch.
A campaign is built in a fixed order, and every step is resumable. You can stop
after any of them, come back tomorrow, and
[`GET /send/campaigns/{campaign_id}`](/v3/reference/send-campaigns-get) will
tell you exactly what's still missing.
Before your first campaign, connect at least one sender. Nothing launches
without one. See [account setup](/v3/account#senders).
## The recipe
```bash theme={null}
CAMPAIGN_ID=$(curl -sS -X POST https://origami.chat/api/v3/send/campaigns \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "RevOps Q3 outbound", "channels": ["email"]}' | jq -r '.id')
```
A blank `draft`. Nothing sends until you launch it.
Senders belong to the organization; here you pick which ones this campaign
draws from. Add several and sends spread across them.
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/senders" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sender_id": "'"$SENDER_ID"'"}'
```
[`GET …/senders`](/v3/reference/send-campaigns-senders-list) shows the
current pool plus the org senders available to add.
The person schema is the People tab's columns. Declare it before enrolling
anyone, so their context has somewhere to land.
```bash theme={null}
curl -X PUT "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/people/schema" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fields": [
{"key": "funding_stage", "description": "Latest funding round", "required": false},
{"key": "crm", "description": "CRM they use today", "required": true}
]
}'
```
Pass `{"fields": []}` if you only need name, email, and company. Skipping
this step entirely gets you `409 PERSON_SCHEMA_REQUIRED` when you enroll.
From a list — this is where Leads and Send meet:
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/people" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"list_id": "'"$LIST_ID"'",
"min_relevance_score": 70,
"mapping": {
"context": {"funding_stage": "funding_stage", "crm": "crm_in_use"},
"why_this_person": "fit_rationale"
}
}'
```
`mapping` connects list columns to person-schema fields. `why_this_person`
is the one-line reason this prospect is worth contacting — it's what the
copy generator leans on, so point it at a real column or set
`default_why_this_person`.
You can also pass `people` inline instead of a `list_id`, and `row_ids` to
take only specific rows. Either way it's synchronous, up to 1,000 per call.
A template is one or more **variants** (A/B arms), each a list of steps.
```bash theme={null}
curl -X PUT "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/templates" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sequences": [{
"variant_name": "Direct ask",
"instructions": "Concise and specific. Reference their CRM. No pleasantries.",
"steps": [
{
"channel": "email",
"subject": "RevOps reporting at {{company}}",
"body": "Hi {{first_name}} — noticed you run RevOps at {{company}}. Most teams on your CRM end up rebuilding the same pipeline report every quarter.\n\nWorth 15 minutes?",
"delay_days": 0,
"personalized": ["Most teams on your CRM end up rebuilding the same pipeline report every quarter."]
},
{
"channel": "email",
"body": "Following up on the above — happy to send the teardown instead if that is easier.",
"delay_days": 3
}
]
}]
}'
```
`personalized` (and `subject_personalized`) mark phrases that should be
rewritten per recipient. A bare string is the phrase copied verbatim from
the body; `{ text, name, instruction }` also names the variable and steers
generation. If a phrase doesn't match the body exactly you get
`409 TEMPLATE_SPANS_INVALID`.
Step `channel` is `email`, `linkedin_message`, `linkedin_connect`,
`linkedin_comment`, `linkedin_react`, or `manual`. `manual` is persistable,
not sendable — include it on a GET → PUT so UI-added steps aren't dropped.
Comment and react steps act on the person's posts; pass `posts` when you
enroll people inline, or the step waits (`on_missing_post: hold`) until a
post exists.
Up to 10 variants, 15 steps each. Add arms one at a time with
[`POST …/templates/sequences`](/v3/reference/send-campaigns-templates-sequences-append)
rather than replacing the whole template; each keeps a `variant_key` that is
never reused, so stats stay comparable over time.
Don't want to write it yourself? [`POST /send/campaigns/draft`](/v3/reference/send-campaigns-draft)
takes a brief and an optional `list_id` and fills in the schema and
templates for you. It never launches — you still review everything below.
Templates are not what people receive. Generate the real per-person copy and
read it before anything sends.
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/examples" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"limit": 10}'
```
That's one LLM call per person, so it returns a Job. When it finishes, read
the results with [`GET …/examples`](/v3/reference/send-campaigns-examples-list).
A message that isn't right can be fixed in place with
[`PATCH …/people/{sequence_id}/steps/{step_index}`](/v3/reference/send-campaigns-people-steps-patch),
and [`POST …/revert`](/v3/reference/send-campaigns-people-revert) throws your
edits away and re-renders from the template.
```bash theme={null}
curl -X PATCH "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/settings" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"send_window": {"start_hour": 9, "end_hour": 17, "days": [1, 2, 3, 4, 5]},
"block_prior_contacts": true,
"require_message_approval": true
}'
```
| Setting | What it does |
| --------------------------- | ----------------------------------------------------------------------------------------------- |
| `send_window` | Hours and weekdays sends are allowed, in each recipient's timezone |
| `block_active_duplicates` | Skip anyone already in another running campaign |
| `block_prior_contacts` | Skip anyone you've contacted before |
| `block_already_connected` | Skip LinkedIn sequences for 1st-degree connections of the allocated sender |
| `require_message_approval` | Hold every message until a human releases it |
| `allow_excluded_recipients` | Send to people on the exclusion list anyway |
| `auto_lead_refill_enabled` | Top the campaign up from its list as people finish, capped by `auto_lead_refill_budget_credits` |
Settings can be patched in any status, including while the campaign is
running. Omitted fields are left alone.
Check the gates without committing:
```bash theme={null}
curl -X POST "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/launch" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dry_run": true}'
```
A dry run reports exactly what a real launch would check. Drop `dry_run` to
go live.
| Code | Fix |
| ----------------------------- | ------------------------------------------------------------------------------------------ |
| `ACCOUNT_CONNECTION_REQUIRED` | No sender in the pool — add one |
| `ACCOUNT_RECONNECT_REQUIRED` | A sender's authorization expired — [reconnect it](/v3/reference/account-senders-reconnect) |
| `NO_PEOPLE` | Nobody enrolled |
| `NO_STEPS` | The template has no steps |
| `ALL_VARIANTS_PAUSED` | Every variant is `paused: true` |
| `OUT_OF_LEADS` | Auto-refill is on but the source list is empty |
## While it's running
```bash theme={null}
curl "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/stats" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
```
[`GET …/people`](/v3/reference/send-campaigns-people-list) pages everyone with
their current status, including `bounced` and `unsubscribed`, and takes a
`search` filter.
If you turned on `require_message_approval`, messages queue up until you release
them:
```bash theme={null}
curl "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/approvals" \
-H "Authorization: Bearer $ORIGAMI_API_KEY"
# Release everything held; pass sequence_ids to release only some
curl -X POST "https://origami.chat/api/v3/send/campaigns/$CAMPAIGN_ID/approvals/approve" \
-H "Authorization: Bearer $ORIGAMI_API_KEY" \
-H "Content-Type: application/json" -d '{}'
```
### Stopping things
| Scope | Call | Effect |
| ------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Everyone | [`POST …/pause`](/v3/reference/send-campaigns-pause) | Halts the campaign. [Resume](/v3/reference/send-campaigns-resume) picks it back up. |
| One person, keep history | [`POST …/people/{sequence_id}/stop`](/v3/reference/send-campaigns-people-stop) | No further steps; what was sent stays on the record |
| One person, cancel | [`POST …/people/{sequence_id}/remove`](/v3/reference/send-campaigns-people-remove) | Cancels the sequence; they stay in the dedup set so they aren't re-enrolled |
| Many people | [`POST …/people/remove`](/v3/reference/send-campaigns-people-remove-bulk) | Up to 1,000, with a per-id outcome for each |
`stop` and `remove` both accept `{"dry_run": true}` if you want to see the blast
radius first.
Deleting a person is not the same as removing them.
[`DELETE …/people/{sequence_id}`](/v3/reference/send-campaigns-people-delete)
erases them from the campaign, which re-opens deduplication — they can be
enrolled again and contacted twice. After anything has been sent it requires
`force=true`. Prefer `remove`.
## What's next
Connect senders, buy domains, provision mailboxes.
Get replies, bounces, and opens pushed to you instead of polling.
# Install the skill
Source: https://docs.origami.chat/v3/skill
Teach your AI coding assistant to drive the v3 API for you.
If you drive this API from an AI coding assistant like Cursor, Claude Code, or
Codex, install the Origami skills. They teach your assistant the v3 operations —
when to search, how to poll a Job, and when to read rows without spending
credits — so you can ask for a list and let it handle the rest.
## Install
Run the installer from your project directory. It installs every Origami skill
into the tool you choose.
```bash theme={null}
curl -fsSL https://origami.chat/skills/install.sh -o /tmp/origami-install.sh && sh /tmp/origami-install.sh
```
Then set `ORIGAMI_API_KEY=og_live_…` in your shell or project `.env` (create a
key in **Settings → Developers**), restart your AI tool, and try:
> Find 30 B2B SaaS founders in Austin who raised seed in 2025.
Re-run the installer any time to update the skills.
## Connect MCP
The live v3 MCP endpoint is `https://origami.chat/mcp`. It uses the same
`og_live_…` bearer as `/api/v3`. Point Cursor (or any MCP client) at:
```json theme={null}
{
"mcpServers": {
"origami": {
"url": "https://origami.chat/mcp",
"headers": {
"Authorization": "Bearer og_live_…"
}
}
}
}
```
Use that path. `https://mcp.origami.chat` is not a host.
# Comment posted
Source: https://docs.origami.chat/webhooks/events/comment-posted
/openapi-webhooks.yaml webhook sequence.comment.posted
Origami POSTs this event when a sequencer LinkedIn comment is
published on the prospect's post.
This is deliberately NOT `sequence.message.sent`: a comment is a
public action on someone's post, not a message delivered to them,
so receivers that log sends to a CRM should record it as an
engagement touch rather than as outreach.
`data.comment.post_url` is the permalink of the post that was
commented on (the copy captured when the comment was written, so
it stays correct even after the prospect's newer posts arrive).
`data.comment.comment_id` is the provider-side id, when the
provider returned one.
Likes (`linkedin_react`) fire no event.
**Default in the picker:** off.
# Connection accepted
Source: https://docs.origami.chat/webhooks/events/connection-accepted
/openapi-webhooks.yaml webhook sequence.connection.accepted
Origami POSTs this event when a LinkedIn invite from a sequence
is accepted by the recipient.
`requested_at` is when you sent the invite. `connected_at` is
when they accepted. `connected_at - requested_at` is the
acceptance latency, useful for reporting.
**Default in the picker:** on.
# Connection requested
Source: https://docs.origami.chat/webhooks/events/connection-requested
/openapi-webhooks.yaml webhook sequence.connection.requested
Origami POSTs this event when a sequencer LinkedIn invite is
sent. Shape mirrors `Message sent`: `channel` is always
`"linkedin"` and `data.message.body` carries the optional
connection note (a LinkedIn premium feature; empty string
when no note was attached).
**Default in the picker:** off. The event duplicates state
the user already triggered. Useful for full lifecycle
visibility, especially paired with `sequence.connection.accepted`.
# Job cancelled
Source: https://docs.origami.chat/webhooks/events/job-cancelled
/openapi-webhooks-jobs.yaml webhook job.cancelled
Origami POSTs this event when a v3 Job is cancelled.
Partial work is kept; `data.summary` reports whatever
completed before cancel. The full Job GET carries
`result.partial: true`.
**Default in the picker:** off.
# Job failed
Source: https://docs.origami.chat/webhooks/events/job-failed
/openapi-webhooks-jobs.yaml webhook job.failed
Origami POSTs this event when a v3 Job fails. `data.error`
carries the `code` and `message`; `data.summary` may include
partial counts the work produced before failing.
Retryable agent failures use `error.code`
`AGENT_INCOMPLETE` / `AGENT_STEP_CAP` / `AGENT_TIMED_OUT`
with `details.retryable: true`.
**Default in the picker:** off.
# Job needs input
Source: https://docs.origami.chat/webhooks/events/job-needs-input
/openapi-webhooks-jobs.yaml webhook job.needs_input
Origami POSTs this event when a v3 Job pauses for
clarification (`data.needs_input.questions`) or a human
step (`data.needs_input.handoff`). Answer questions with
`POST /api/v3/jobs/{job_id}/input`. A handoff URL is for
the user — Origami resumes the Job itself after the
in-app step completes.
**Default in the picker:** off.
# Job succeeded
Source: https://docs.origami.chat/webhooks/events/job-succeeded
/openapi-webhooks-jobs.yaml webhook job.succeeded
Origami POSTs this event when a v3 Job finishes successfully.
`data.summary` carries counts and resource ids (for example
`list_id`, `added`) — never `row_ids` and never row-level
data. Read the full `result` with
`GET /api/v3/jobs/{job_id}`.
`data.credits.settled: false` on a quoted run means the spend
is provisional until delivered-lead settlement writes back.
**Default in the picker:** off.
# Message sent
Source: https://docs.origami.chat/webhooks/events/message-sent
/openapi-webhooks.yaml webhook sequence.message.sent
Origami POSTs this event to your configured URL when a sequencer
email or LinkedIn DM finishes sending.
Branch on `data.channel` (`"email"` or `"linkedin"`) to handle
the two channels. `data.message.provider_id` is the
provider-side identifier (Gmail `msgId`, LinkedIn chat message
id), useful for correlating with the user's own inbox.
**Default in the picker:** off.
# Reply received
Source: https://docs.origami.chat/webhooks/events/reply-received
/openapi-webhooks.yaml webhook sequence.reply.received
Origami POSTs this event when an inbound email or LinkedIn DM
matches one of your active sequences. We also stop any matched
sequences that should pause on reply; `data.newly_stopped` tells
you whether THIS event's sequence transitioned to stopped.
Branch on `data.channel` to handle email vs LinkedIn.
**`outreach_target` is the replier.** `sender` is the identity
they replied TO (your mailbox or LinkedIn account).
**Multi-match case.** A single inbound can legitimately match
more than one sequence if you ran parallel campaigns to the
same recipient. The primary match goes on `sequence_id` +
`newly_stopped`; tail matches appear on
`additional_matched_sequence_ids` (and
`additional_newly_stopped_sequence_ids` when relevant). Both
`additional_*` fields are **absent** from the payload in the
common single-match case.
**Default in the picker:** on.
# Table run completed
Source: https://docs.origami.chat/webhooks/events/table-run-completed
/openapi-webhooks.yaml webhook table.run.completed
Origami POSTs this event when a table operation (e.g. an API
enrichment batch) reaches a terminal state: complete, cancelled, or
failed.
`data.counts` carries aggregate terminal child-outcome counts
(`complete`, `errored`, `cancelled`), never child-id arrays, so
payload size is constant regardless of how wide the run was.
Correlate via `data.run_id` and read the durable status at
`GET /api/v2/tables/:tableId/runs/:runId`.
v1 emits this event only for API enrichment batches (`enrich=true`
on a v1 row insert or v2 upsert); agent-, UI-, and
lead-source-produced table runs are deferred.
**Default in the picker:** off.
# Test
Source: https://docs.origami.chat/webhooks/events/test
/openapi-webhooks.yaml webhook webhook.test
Origami POSTs this event when you click **Test endpoint** in the
dashboard or call `POST /api/v3/account/webhooks/{webhook_id}/test`.
Always delivered regardless of subscription; it's a
wire-connectivity check, not a business signal.
Receivers can ignore test traffic in production analytics by
filtering on `type === "webhook.test"`.
# Webhooks overview
Source: https://docs.origami.chat/webhooks/overview
Receive signed event POSTs from Origami when Jobs, sequencer, or table activity happens.
Webhooks are in **beta**. Event types and payload shapes may still
change. We'll email orgs with active endpoints before any breaking
change.
When a subscribed event happens, Origami POSTs a signed JSON envelope to
a URL you configure in **Settings → Developers → Webhooks**, or via
[`POST /api/v3/account/webhooks`](/v3/reference/account-webhooks-create).
## API Jobs events
v3-only. These fire when a [Job](/v3/jobs) changes status — searches,
fetches, enrichment, campaign drafts, domain purchases, mailbox
provisioning, and chat messages.
| Event | When it fires | Default in picker |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `job.succeeded` | A Job finished successfully. Payload carries a compact summary (counts and resource ids), never row-level data. Fetch the full result with [`GET /api/v3/jobs/{job_id}`](/v3/reference/jobs-get). | off |
| `job.failed` | A Job failed. Payload carries `error` plus any partial counts. | off |
| `job.cancelled` | A Job was cancelled. Partial work is reported. | off |
| `job.needs_input` | A Job paused for questions or a human handoff. | off |
Subscribe to specific types or the `job.*` wildcard. Payloads echo the
`metadata` you passed when admitting the Job, so you can correlate
without polling. `data.sequence` is a monotonic generation — ignore any
event whose sequence is not greater than the last one you processed for
that `job_id`.
## Tables events
| Event | When it fires | Default in picker |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `table.run.completed` | An `enrich=true` API enrichment batch (v1 insert or v2 upsert) reaches a terminal state — `complete`, `cancelled`, or `failed`. Payload carries `run_id`, `table_id`, `source`, terminal `status`, timestamps, optional `failure_reason`, and child-outcome `counts` (`complete`, `errored`, `cancelled`). | off |
Subscribe to `table.run.completed`, the `table.*` wildcard, or `*` (everything).
Missed delivery on a v3 upsert? Recover with
[`GET /api/v3/jobs/{job_id}`](/v3/reference/jobs-get) using the embedded
enrichment Job id. On v2, recover with
[`GET /api/v2/tables/{tableId}/runs/{runId}`](/agents/reference/get-table-run)
using the `run_id` from the payload (or `enrichment_run.tableRunId` from the upsert response).
## Sequencer events
| Event | When it fires | Default in picker |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `sequence.message.sent` | A sequencer email or LinkedIn DM finished sending. | off |
| `sequence.reply.received` | An inbound email or LinkedIn DM matched an outbound sequence. | on |
| `sequence.connection.requested` | A sequencer LinkedIn invite was sent. | off |
| `sequence.connection.accepted` | A LinkedIn invite from a sequence was accepted. | on |
| `sequence.comment.posted` | A sequencer LinkedIn comment was published on the prospect's post. Likes (`linkedin_react`) fire no event. | off |
| `webhook.test` | You clicked **Test endpoint** in the dashboard or called `POST /api/v3/account/webhooks/{webhook_id}/test`. Always delivered, regardless of subscription. | n/a |
Every sequencer payload carries `campaign_id` (nullable) — the join key
for routing a touch back to the campaign that produced it — and
`sender.id`, the stable sender id from
[`GET /api/v3/account/senders`](/v3/reference/account-senders-list).
Subscribe to specific event types, the `sequence.*` wildcard, the
`sequence.connection.*` sub-wildcard, `table.*`, `job.*`, or `*`
(everything, including future event types).
## Delivery guarantees
* **At-least-once delivery.** A single event can produce more than one
POST under partial failure. Receivers **must** dedupe on the
`webhook-id` header.
* **Up to 10 attempts** following the [Standard Webhooks spec retry
table](/webhooks/retries) — initial + 9 retries with ±15% jitter
spanning roughly 75 hours.
* **`410 Gone` auto-disables** the endpoint per the spec. Re-enable
manually from the dashboard.
* **`webhook-id` is stable across retries.** `webhook-timestamp` is
recomputed per attempt — use it for replay protection (±5 min).
## Signing
Every request carries three headers:
| Header | Purpose |
| ------------------- | ------------------------------------------------- |
| `webhook-id` | Unique per event delivery. **Idempotency key.** |
| `webhook-timestamp` | Unix seconds at dispatch time. |
| `webhook-signature` | One or more `v1,` HMAC-SHA256 signatures. |
Signed string is `{webhook-id}.{webhook-timestamp}.{raw-body}`, keyed
by the base64-decoded bytes of your `whsec_…` secret (after stripping
the `whsec_` prefix). This is the canonical
[Standard Webhooks](https://www.standardwebhooks.com/) scheme — verify
with Svix's `standardwebhooks` SDK or the copy-paste snippets in
[signature verification](/webhooks/signatures) (Node, Python, Go, Ruby,
Rust, `curl + openssl`).
## Next steps
* [Use webhooks with the API](/webhooks/using-with-the-api)
* [Set up an endpoint](/webhooks/setup)
* [Verify signatures](/webhooks/signatures)
* [Retry behavior](/webhooks/retries)
* [Per-event payload reference](/webhooks/events/message-sent)
# Retries & idempotency
Source: https://docs.origami.chat/webhooks/retries
The retry schedule, 410 Gone behavior, and the webhook-id idempotency key.
Webhooks are in **beta**. Event types and payload shapes may still
change. We'll email orgs with active endpoints before any breaking
change.
Delivery is **at-least-once**. If you return a retriable status
(or time out / fail to respond), Origami re-delivers on a fixed
schedule. Receivers MUST dedupe on the `webhook-id` header.
## Retry schedule
Standard Webhooks spec table. 10 attempts (initial + 9 retries),
\~75 hours cumulative, ±15% jitter per slot.
| Attempt | Delay since previous | Cumulative |
| ------: | -------------------: | ------------: |
| 1 | immediate | 0 |
| 2 | 5 s | 5 s |
| 3 | 5 min | 5 min 5 s |
| 4 | 30 min | 35 min 5 s |
| 5 | 2 h | 2 h 35 min |
| 6 | 5 h | 7 h 35 min |
| 7 | 10 h | 17 h 35 min |
| 8 | 14 h | \~31 h 35 min |
| 9 | 20 h | \~51 h 35 min |
| 10 | 24 h | \~75 h 35 min |
After attempt 10, retries stop. The delivery shows up in the
dashboard's deliveries panel as a failed attempt.
## What triggers a retry
| Your response | Outcome |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `2xx` | Successful delivery. |
| `3xx` | Treated as a misconfigured endpoint and dropped. Origami does not follow redirects on webhook POSTs. |
| `4xx` (not `408` / `429`) | Dropped — returning 4xx tells us "the request is bad" and we won't retry. |
| `410 Gone` | **Auto-disables the endpoint** per the Standard Webhooks spec. Re-enable from the dashboard after fixing. |
| `408`, `429`, `5xx` | Retried on the spec schedule. |
| Timeout (30 s body) / network error | Same as `5xx`. |
## Auto-disable
If your endpoint fails too many deliveries in a row across roughly
100 failed attempts (no successful delivery between), Origami
auto-disables it. Re-enable from the dashboard once the receiver is
fixed.
## Idempotency: dedupe on `webhook-id`
Every retry of the same delivery reuses the same `webhook-id`. Keep
a short-retention set (5 minutes is plenty) of recently-seen ids:
```ts theme={null}
const SEEN = new Set()
const TTL_MS = 5 * 60 * 1000
app.post('/webhooks/origami', (req, res) => {
const webhookId = req.headers['webhook-id'] as string
if (SEEN.has(webhookId)) return res.status(200).end()
SEEN.add(webhookId)
setTimeout(() => SEEN.delete(webhookId), TTL_MS)
// ... handle event
res.status(200).end()
})
```
Two distinct events for related state (an email send followed by a
quick reply) carry **different** `webhook-id`s. Idempotency is
per-delivery, not per-business-action.
## Header `webhook-timestamp` vs envelope `timestamp`
* **`webhook-timestamp`** (header) — dispatch time, recomputed every
send. Use it for **replay protection**: reject signatures more
than ±5 minutes off your wall clock. See
[signatures](/webhooks/signatures).
* **`data.timestamp`** (envelope) — event time, stable across
retries. Use it when you want "when did the business action
happen?"
## Replay from the dashboard
If you missed events because of an outage on your side, open the
deliveries panel and click **Redeliver** on the affected delivery.
The retry comes through with the **same** `webhook-id`, so your
idempotency layer correctly dedupes if you've already processed it.
# Set up an endpoint
Source: https://docs.origami.chat/webhooks/setup
Configure a webhook endpoint in Settings → Developers and verify it with a test event.
Webhooks are in **beta**. Event types and payload shapes may still
change. We'll email orgs with active endpoints before any breaking
change.
## 1. Open the webhooks section
Go to **Settings → Developers → Webhooks**. You need to be an org
admin on a paid plan; otherwise the section is hidden.
## 2. Create an endpoint
Click **New endpoint**:
| Field | Notes |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **URL** | Must be `https://` in production. IP literals, non-default ports, userinfo, and cloud metadata hostnames are rejected at submit. DNS is re-checked at dial time. |
| **Description** | Free-form, up to 500 characters. Surfaces in the list. |
| **Event types** | Pick from the tree, or check **Everything** / **Sequencer** / **API Jobs** for wildcards. |
## 3. Copy the signing secret
The `whsec_…` secret is revealed in a modal **once**. Copy it now —
losing it means rotating to get a new one.
The list view only shows the last 4 chars (`•••• abcd`).
## 4. Send a test event
Open the endpoint drawer and click **Test endpoint**. Origami sends a
`webhook.test` event (always delivered, regardless of subscriptions).
The deliveries drawer opens; the row should flip to **delivered**.
Non-2xx responses enter the retry schedule and surface the response
status + first 2 KB of body in the deliveries view.
## 5. Rotate the secret
**Rotate secret** in the endpoint drawer. The previous secret stays
valid for **24 hours** so you can roll without dropped deliveries.
During the window, `webhook-signature` carries two `v1,` entries
— accept either match.
## 6. Disable or delete
* **Disable** pauses delivery without losing the URL or event
subscriptions.
* **Delete** removes the endpoint. Any pending deliveries are dropped.
Delivery history stays for audit.
## Per-organization cap
Up to **5 active endpoints per organization**. Contact support if you
need more.
## Next steps
* [Verify webhook signatures](/webhooks/signatures)
* [Retry behavior](/webhooks/retries)
* [Per-event payload reference](/webhooks/events/message-sent)
# Verify webhook signatures
Source: https://docs.origami.chat/webhooks/signatures
Standard-Webhooks-compatible HMAC-SHA256 verifier snippets in Node, Python, Go, Ruby, Rust, and curl + openssl.
Webhooks are in **beta**. Event types and payload shapes may still
change. We'll email orgs with active endpoints before any breaking
change.
Every webhook POST carries a signature so you can prove it came from
Origami. Verify it before acting on the payload.
## The contract
The signature is HMAC-SHA256 over the literal string
`{webhook-id}.{webhook-timestamp}.{raw-body}` using your `whsec_…`
secret as the HMAC key. We follow the canonical
[Standard Webhooks](https://www.standardwebhooks.com/) spec exactly:
the prefix `whsec_` is stripped and the remainder is base64-decoded to
produce the raw key bytes. The Svix `standardwebhooks` SDK does this
for you in one line.
Headers we send on every POST:
| Header | Example |
| ------------------- | ------------------------------------------------------------ |
| `webhook-id` | `01J7C5K…` |
| `webhook-timestamp` | `1717800000` (Unix seconds) |
| `webhook-signature` | `v1,k1XF9w==` (or `v1,k1XF9w== v1,Yh9hSQ==` during rotation) |
Receivers SHOULD:
1. Reject signatures whose timestamp is more than ±5 minutes from
their wall clock (replay protection).
2. Compare with a **constant-time** byte equality (`timingSafeEqual`
in Node, `hmac.compare_digest` in Python, etc.). A `==` compare
leaks the signature byte-by-byte.
3. Accept **any** `v1,` entry as a match — during a 24h secret
rotation we send two.
4. Dedupe on `webhook-id` with at least 5 minutes of retention.
## One-line option (Node)
```bash theme={null}
npm install standardwebhooks
```
```ts theme={null}
import { Webhook } from 'standardwebhooks'
const wh = new Webhook(process.env.ORIGAMI_WEBHOOK_SECRET!) // whsec_…
const payload = wh.verify(req.rawBody, req.headers) // throws on failure
```
The `standardwebhooks` SDK strips the prefix and base64-decodes the
remainder for you, so it's byte-for-byte compatible with the snippets
below.
## Node
```ts theme={null}
import crypto from 'crypto'
const PREFIX = 'whsec_'
function keyForSecret(secret: string): Buffer {
const body = secret.startsWith(PREFIX) ? secret.slice(PREFIX.length) : secret
return Buffer.from(body, 'base64')
}
export function verifyOrigamiWebhook({
rawBody,
webhookId,
webhookTimestamp,
webhookSignature,
secret,
}: {
rawBody: Buffer | string
webhookId: string
webhookTimestamp: string
webhookSignature: string
secret: string
}): boolean {
const ts = Number(webhookTimestamp)
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8')
const signed = `${webhookId}.${webhookTimestamp}.${body}`
const expected = crypto
.createHmac('sha256', keyForSecret(secret))
.update(signed, 'utf8')
.digest('base64')
for (const match of webhookSignature.matchAll(/v1,([^\s,]+)/g)) {
const provided = match[1]
if (provided.length !== expected.length) continue
try {
if (crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) return true
} catch {
/* defensive */
}
}
return false
}
```
## Python
```python theme={null}
import base64
import hmac
import time
from hashlib import sha256
PREFIX = "whsec_"
def key_for_secret(secret: str) -> bytes:
body = secret[len(PREFIX):] if secret.startswith(PREFIX) else secret
return base64.b64decode(body)
def verify_origami_webhook(*, raw_body: bytes, webhook_id: str,
webhook_timestamp: str, webhook_signature: str,
secret: str) -> bool:
try:
ts = int(webhook_timestamp)
except ValueError:
return False
if abs(time.time() - ts) > 300:
return False
signed = f"{webhook_id}.{webhook_timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(
hmac.new(key_for_secret(secret), signed, sha256).digest()
).decode("utf-8")
import re
for m in re.finditer(r"v1,([^\s,]+)", webhook_signature):
provided = m.group(1)
if hmac.compare_digest(provided, expected):
return True
return False
```
## Go
```go theme={null}
package origamiwebhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"regexp"
"strconv"
"strings"
"time"
)
const prefix = "whsec_"
func keyForSecret(secret string) ([]byte, error) {
body := strings.TrimPrefix(secret, prefix)
return base64.StdEncoding.DecodeString(body)
}
var v1Re = regexp.MustCompile(`v1,([^\s,]+)`)
func Verify(rawBody []byte, webhookID, webhookTimestamp, webhookSignature, secret string) bool {
ts, err := strconv.ParseInt(webhookTimestamp, 10, 64)
if err != nil {
return false
}
if delta := time.Now().Unix() - ts; delta > 300 || delta < -300 {
return false
}
key, err := keyForSecret(secret)
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(webhookID + "." + webhookTimestamp + "."))
mac.Write(rawBody)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, m := range v1Re.FindAllStringSubmatch(webhookSignature, -1) {
if hmac.Equal([]byte(m[1]), []byte(expected)) {
return true
}
}
return false
}
```
## Ruby
```ruby theme={null}
require "base64"
require "openssl"
PREFIX = "whsec_"
def key_for_secret(secret)
body = secret.start_with?(PREFIX) ? secret[PREFIX.length..-1] : secret
Base64.decode64(body)
end
def verify_origami_webhook(raw_body:, webhook_id:, webhook_timestamp:,
webhook_signature:, secret:)
ts = Integer(webhook_timestamp) rescue (return false)
return false if (Time.now.to_i - ts).abs > 300
signed = "#{webhook_id}.#{webhook_timestamp}.#{raw_body}"
expected = Base64.strict_encode64(
OpenSSL::HMAC.digest("sha256", key_for_secret(secret), signed)
)
webhook_signature.scan(/v1,([^\s,]+)/).any? do |(provided)|
next false if provided.bytesize != expected.bytesize
# OpenSSL.fixed_length_secure_compare ships on Ruby 2.7+ and is
# the constant-time byte comparator. Pre-2.7 receivers can use
# the pure-Ruby loop in the standardwebhooks gem.
OpenSSL.fixed_length_secure_compare(provided, expected)
end
end
```
## Rust
```rust theme={null}
use base64::{engine::general_purpose, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
const PREFIX: &str = "whsec_";
fn key_for_secret(secret: &str) -> Option> {
let body = secret.strip_prefix(PREFIX).unwrap_or(secret);
general_purpose::STANDARD.decode(body).ok()
}
pub fn verify(
raw_body: &[u8],
webhook_id: &str,
webhook_timestamp: &str,
webhook_signature: &str,
secret: &str,
) -> bool {
let ts: i64 = match webhook_timestamp.parse() {
Ok(t) => t,
Err(_) => return false,
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if (now - ts).abs() > 300 {
return false;
}
let key = match key_for_secret(secret) {
Some(k) => k,
None => return false,
};
let mut mac =
Hmac::::new_from_slice(&key).expect("HMAC accepts any key size");
mac.update(webhook_id.as_bytes());
mac.update(b".");
mac.update(webhook_timestamp.as_bytes());
mac.update(b".");
mac.update(raw_body);
let expected = general_purpose::STANDARD.encode(mac.finalize().into_bytes());
webhook_signature
.split(|c: char| c == ' ' || c == ',')
.filter_map(|piece| piece.strip_prefix("v1,"))
// Constant-time compare: only equal-length byte slices may match.
.any(|provided| {
provided.len() == expected.len()
&& constant_time_eq::constant_time_eq(
provided.as_bytes(),
expected.as_bytes(),
)
})
}
```
## curl + openssl
For ad-hoc verification of a captured payload (e.g. from a copy out of
the dashboard's delivery drawer):
```bash theme={null}
WEBHOOK_ID=01J7C5K…
WEBHOOK_TIMESTAMP=1717800000
RAW_BODY='{"type":"sequence.email.sent", …}'
SECRET=whsec_XXXXXXXX
KEY=$(printf '%s' "$SECRET" | sed 's/^whsec_//' | base64 -d)
echo -n "${WEBHOOK_ID}.${WEBHOOK_TIMESTAMP}.${RAW_BODY}" \
| openssl dgst -sha256 -binary -hmac "$KEY" \
| base64
# Compare to one of the v1, entries in `webhook-signature`.
```
# Using webhooks with the API
Source: https://docs.origami.chat/webhooks/using-with-the-api
Combine the API and webhooks into one submit, notify, resolve loop.
The API and webhooks are two halves of the same integration. The API is a **pull**
surface — you request state and ask Origami to change it. Webhooks are a **push**
surface — Origami tells you the moment something happens, so you don't have to poll.
| Plane | Direction | Answers |
| ------------------------------ | ---------------------------- | ------------------------------------------ |
| [v3 API](/v3/overview) | pull (request → response) | "What is the state, and please change it." |
| [Webhooks](/webhooks/overview) | push (event → your endpoint) | "Tell me the moment something happens." |
They are not alternatives. A real integration uses both: the API to submit work and read
results, webhooks to learn when long-running or externally-triggered things happen.
For v3 Jobs, subscribe to `job.*` and skip the poll loop: the webhook tells you
the Job is terminal, then you `GET` it once for the full `result`.
## The submit, notify, resolve loop
The rule of thumb: **the API gives you the id and the final read; the webhook tells you when
to do that read.**
Sequence outreach is the clearest example you can wire up today:
Start a sequence — draft one with `POST /api/v2/tables/{tableId}/sequences` or launch it
in the app.
`sequence.message.sent` fires when each step is durably sent.
`sequence.reply.received` fires on an inbound reply.
Read the sequence with `GET /api/v2/sequences/{sequenceId}`, then stop or adjust it from
the API or in the app.
For v3 async work (searches, fetches, enrichment, drafts), subscribe to
`job.succeeded` / `job.failed` / `job.cancelled` / `job.needs_input`. The
payload's `job_id` is the same id you poll at
[`GET /api/v3/jobs/{job_id}`](/v3/reference/jobs-get). Pass `metadata` on
admit so the webhook carries your correlation key.
For `enrich=true` upsert batches, subscribe to `table.run.completed` — the webhook fires when
the parent table run reaches a terminal state (`complete`, `cancelled`, or `failed`). On v3,
the upsert response embeds an `enrichment_job` you can also watch via `job.*`. Missed
delivery on v2? Recover by polling [`GET /api/v2/tables/{tableId}/runs/{runId}`](/agents/reference/get-table-run)
using the `tableRunId` from the upsert receipt.
## Correlation keys
A webhook payload joins back to API resources through stable ids, so you can round-trip from
a push event to a pull read with no extra mapping.
| Payload field | Read it with |
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
| `data.job_id` (on `job.*`) | [`GET /api/v3/jobs/{job_id}`](/v3/reference/jobs-get) |
| `data.metadata` (on `job.*`) | Echo of the object you passed when admitting the Job. |
| `data.campaign_id` (sequencer events) | [`GET /api/v3/send/campaigns/{campaign_id}`](/v3/reference/send-campaigns-get) |
| `data.sender.id` (sequencer events) | [`GET /api/v3/account/senders/{sender_id}`](/v3/reference/account-senders-get) |
| `data.run_id` (on `table.run.completed`) | [`GET /api/v2/tables/{tableId}/runs/{runId}`](/agents/reference/get-table-run) |
| `outreach_target.{table_id, row_id}` | [`GET /api/v3/leads/lists/{list_id}/rows/{row_id}`](/v3/reference/leads-lists-rows-get) |
| `sequence_id` | v3: the campaign person (`sequence_id`). v2: `GET /api/v2/sequences/{sequenceId}` |
## Auth and security
The two planes use independent credentials. The API authenticates with your `og_live_` API
key. Webhooks authenticate **to your receiver** with an HMAC signature over the delivery body,
which you [verify](/webhooks/signatures) before acting on the payload. Rotating an API key does
not affect webhook signatures, and vice versa.
Webhook payloads expose only user-visible identifiers (email address, LinkedIn handle, display
name) — the same redaction the API serializers apply.