# 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.