Documentation
REST API
This page is generated from the same contracts the API validates every request against. It cannot describe an endpoint that does not exist, and it cannot miss one that does.
Responses are a separate promise and worth stating separately, because a handler's return type does not survive to runtime. Every operation publishes one, and each is driven against the running code and checked against the shape published for it, so one that drifted would fail our build.
The OpenAPI document it is generated from is at
api.indraft.io/v1/openapi.json,
and needs no account to read.
Routes express product operations, not tables. There is no generic row endpoint, because one pushes every business rule out to the caller.
Two columns, because they are two different things. Permission is what the person or credential must be allowed to do. Token scope is which scopes on an API token can carry that permission, and a token never exceeds the role of whoever created it. Where the scope column says signed in as a person, no token can make the call however it is scoped: paying, administering members, and taking a copy of everything out are things a person does.
Endpoints
| Method | Path | Permission | Token scope | Purpose |
|---|---|---|---|---|
| POST | /billing/checkout | billing:manage | signed in as a person | Start a Stripe Checkout session for a plan |
| POST | /billing/portal | billing:manage | signed in as a person | Open the Stripe Customer Portal |
| GET | /billing/usage | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Plan, subscription status, and usage against every quota |
| GET | /mcp/connections | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | List agent connections and their state |
| DELETE | /mcp/connections/{id} | members:manage | signed in as a person | Revoke one agent connection, effective immediately |
| GET | /v1/aggregate | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Count, sum, average, min or max, optionally grouped |
| POST | /v1/bulk | crm:write | crm:write, crm:admin | Apply a bounded batch of operations |
| GET | /v1/changes | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Recent mutations and their diffs, or everything since a watermark |
| POST | /v1/changes/{id}/revert | crm:revert | crm:admin, crm:revert | Compensate a reversible change |
| GET | /v1/companies | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | List companies |
| POST | /v1/companies:archive | crm:write | crm:write, crm:admin | Archive or restore up to 500 companies in one call |
| POST | /v1/companies:merge | crm:write | crm:write, crm:admin | Merge a duplicate company into a target |
| POST | /v1/companies:upsert | crm:write | crm:write, crm:admin | Create or safely match a company |
| GET | /v1/companies/{id} | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Read one company |
| POST | /v1/companies/{id}/archive | crm:write | crm:write, crm:admin | Archive a company, or restore it |
| GET | /v1/companies/{id}/context | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Bounded company context for an agent |
| GET | /v1/companies/{id}/interactions | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Recent interactions for a company |
| GET | /v1/contacts | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | List contacts |
| POST | /v1/contacts:archive | crm:write | crm:write, crm:admin | Archive or restore up to 500 contacts in one call |
| POST | /v1/contacts:merge | crm:write | crm:write, crm:admin | Merge a duplicate contact into a target |
| POST | /v1/contacts:upsert | crm:write | crm:write, crm:admin | Create or safely match a contact |
| GET | /v1/contacts/{id} | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Read one contact |
| POST | /v1/contacts/{id}/archive | crm:write | crm:write, crm:admin | Archive a contact, or restore it |
| POST | /v1/contacts/{id}/company | crm:write | crm:write, crm:admin | Attach a contact to a company, detach them, or set which is primary |
| GET | /v1/contacts/{id}/context | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Bounded contact context for an agent |
| POST | /v1/contacts/{id}/erasure | crm:erase | signed in as a person | Erase this person: destroy every value that identifies them, keep the ledger's shape, and return a receipt. Cannot be undone |
| POST | /v1/custom-fields | crm:configure | crm:configure, crm:admin | Define or update a typed custom field |
| DELETE | /v1/custom-fields/{id} | crm:configure | crm:configure, crm:admin | Retire a custom field definition, keeping the values already written |
| GET | /v1/duplicates | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Propose pairs of records that look like the same record |
| GET | /v1/exchange-rates | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Every exchange rate this workspace has asserted |
| POST | /v1/exchange-rates | crm:configure | crm:configure, crm:admin | Assert an exchange rate, with its effective date and source |
| GET | /v1/export | data:export | data:export | Export every record, custom object type, and the change ledger, as one JSON file |
| GET | /v1/export.zip | data:export | data:export | Export the whole workspace as an archive of CSV files and one JSON document |
| GET | /v1/interactions | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Every interaction in the workspace, newest first |
| POST | /v1/interactions | crm:write | crm:write, crm:admin | Record a normalized interaction |
| POST | /v1/merges/{id}/unmerge | crm:write | crm:write, crm:admin | Invert a merge, restoring exactly the references it moved |
| GET | /v1/object-types | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Every custom object type this workspace has defined |
| POST | /v1/object-types | crm:configure | crm:configure, crm:admin | Define a custom object type |
| DELETE | /v1/object-types/{type} | workspace:delete | signed in as a person | Permanently destroy an archived custom type, its table, and its records |
| PATCH | /v1/object-types/{type} | crm:configure | crm:configure, crm:admin | Add a field to a custom type, or archive and restore it |
| POST | /v1/objects:archive | crm:write | crm:write, crm:admin | Archive or restore up to 500 custom records of one type in one call |
| POST | /v1/objects:merge | crm:write | crm:write, crm:admin | Merge a duplicate custom record into a target, naming both ids |
| GET | /v1/objects/{type} | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Every record of one custom type, newest first |
| POST | /v1/objects/{type} | crm:write | crm:write, crm:admin | Create a record of a custom type |
| GET | /v1/objects/{type}/{id} | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Read one custom record |
| PATCH | /v1/objects/{type}/{id} | crm:write | crm:write, crm:admin | Change a custom record's declared fields |
| POST | /v1/objects/{type}/{id}/advance | crm:write | crm:write, crm:admin | Move one custom record to a stage of its type's pipeline |
| POST | /v1/objects/{type}/{id}/archive | crm:write | crm:write, crm:admin | Archive or restore one custom record |
| GET | /v1/opportunities | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | List opportunities |
| POST | /v1/opportunities | crm:write | crm:write, crm:admin | Create an opportunity |
| POST | /v1/opportunities:upsert | crm:write | crm:write, crm:admin | Create or safely match a deal on an external identity |
| GET | /v1/opportunities/{id} | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Read one opportunity |
| PATCH | /v1/opportunities/{id} | crm:write | crm:write, crm:admin | Change an opportunity's name, amount, probability, or close date |
| POST | /v1/opportunities/{id}/advance | crm:write | crm:write, crm:admin | Move an opportunity to a stage |
| POST | /v1/pipelines | crm:configure | crm:configure, crm:admin | Create or reconfigure a pipeline and its ordered stages |
| GET | /v1/schema | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Describe the CRM model, pipelines, custom fields, limits, and starter models |
| GET | /v1/search | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Search across the CRM |
| GET | /v1/stale | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Records nobody has touched for a while, quietest first |
| GET | /v1/tasks | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | List tasks |
| POST | /v1/tasks | crm:write | crm:write, crm:admin | Create a follow-up task |
| GET | /v1/tasks/{id} | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Read one task |
| PATCH | /v1/tasks/{id} | crm:write | crm:write, crm:admin | Correct a task |
| POST | /v1/tasks/{id}/cancel | crm:write | crm:write, crm:admin | Call a task off, which is not the same as completing it |
| POST | /v1/tasks/{id}/complete | crm:write | crm:write, crm:admin | Complete a task |
| GET | /v1/tokens | credentials:manage | signed in as a person | List API tokens and their state, never their secrets |
| POST | /v1/tokens | credentials:manage | signed in as a person | Create an API token, returning its secret exactly once |
| DELETE | /v1/tokens/{id} | credentials:manage | signed in as a person | Revoke one API token, effective on its next request |
| GET | /v1/unconfirmed | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Values an agent asserted that no person has since confirmed |
| POST | /v1/unconfirmed:confirm | crm:write | signed in as a person | Record that a person has checked a value and agrees with it |
| GET | /v1/watches | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Every saved question this workspace asks on a clock |
| POST | /v1/watches | crm:configure | crm:configure, crm:admin | Save a question, a threshold, and how often to ask it |
| DELETE | /v1/watches/{id} | crm:configure | crm:configure, crm:admin | Stop asking |
| GET | /v1/webhooks | crm:configure | crm:configure, crm:admin | List registered webhook endpoints |
| POST | /v1/webhooks | crm:configure | crm:configure, crm:admin | Register a webhook endpoint, returning its signing secret once |
| DELETE | /v1/webhooks/{id} | crm:configure | crm:configure, crm:admin | Disable a webhook endpoint |
| GET | /v1/webhooks/deliveries | crm:read | crm:read, crm:write, crm:configure, crm:admin, crm:revert, data:export | Recent delivery attempts and their outcomes |
| POST | /v1/webhooks/events/{id}/replay | crm:configure | crm:configure, crm:admin | Queue an event for delivery again |
| PATCH | /v1/workspace | crm:configure | crm:configure, crm:admin | Change the workspace name, display timezone, or default currency |
| DELETE | /v1/workspace/deletion | workspace:delete | signed in as a person | Cancel a scheduled destruction |
| POST | /v1/workspace/deletion | workspace:delete | signed in as a person | Schedule this workspace for destruction, cancellable for seven days |
| PATCH | /v1/workspace/standing-override-policy | members:manage | signed in as a person | Set what this workspace permits overrideStanding to do |
| GET | /v1/workspaces | workspaces:provision | workspaces:provision | List the workspaces this credential can reach |
| POST | /v1/workspaces | workspaces:provision | workspaces:provision | Create a workspace and return a credential scoped to it |
Six calls, captured from the running API
Real requests against a real workspace, pasted rather than written. Three of them fail on purpose, because the shape of a refusal is the half you have to write code against and the half nobody publishes.
Authenticate
One header on every call. An API token, or a session or MCP access token issued through AuthKit. Nothing else is read: there is no key in a query string and no cookie the API depends on.
curl https://api.indraft.io/v1/schema \
-H "Authorization: Bearer $INDRAFT_TOKEN" Create a company, safely twice
The idempotency key is what makes the call safe to retry. Send the same key with the same input and the second call returns the first result rather than a second company.
curl -X POST https://api.indraft.io/v1/companies:upsert \
-H "Authorization: Bearer $INDRAFT_TOKEN" \
-H "content-type: application/json" \
-d '{
"name": "Halden Systems",
"domain": "haldensystems.io",
"industry": "Logistics software",
"assertionKind": "imported",
"attributionLabel": "CRM export",
"idempotencyKey": "halden-2026-08-25"
}' {
"status": "created",
"record": {
"id": "co_01M0XRJKFDV1QPXA9FT3V5K9YK",
"name": "Halden Systems",
"domain": "haldensystems.io",
"domainNormalized": "haldensystems.io",
"industry": "Logistics software",
"version": 1,
"createdAt": "2026-08-26T00:47:29.261Z"
},
"mutation": {
"mutationId": "mut_01M0XRJKFDV1QPXA9FT3V5K9YM",
"status": "created",
"matchedBy": null,
"replayed": false,
"targetId": "co_01M0XRJKFDV1QPXA9FT3V5K9YK"
}
} matchedBy is how identity was decided, and it is null here
because nothing was matched: this created a record. On a second call it
reads domain, and replayed turns true.
An upsert that refuses to guess
A company with no domain, whose name exactly matches one already there. Nothing is committed and both possibilities come back, because an exact name with no domain is the weakest evidence available: two businesses share a name far more often than they share a domain.
{
"status": "ambiguous",
"candidates": [
{
"id": "co_01M0XRJKZ9FM7VA4R05FARE42G",
"label": "Bramley Warehousing",
"matchedOn": "exact name, no domain supplied"
}
],
"requestId": "1ac9f764-a031-4e4c-9031-067ffe9752a7"
}
Recover by supplying the domain, by sending the id you meant,
or by setting createDistinct when it really is a second
company with the same name.
A write that lost a race
Send expectedVersion and the write applies only if the record
is still the one you read. This is the answer to two agents editing the
same account a second apart.
{
"code": "stale_version",
"message": "expectedVersion does not match the current record. Nothing was overwritten.",
"recoveryHint": "Re-read the current record and apply the intended change again.",
"requestId": "e80b8e0f-3e0f-4609-bf69-6ef9de9fd530",
"detail": {
"currentVersion": 1,
"expectedVersion": 99
}
} Both versions are in the detail, so the decision to re-read and retry or to stop is one your code can make without another request.
A batch where one row fails
Up to a hundred operations in one call. Each is applied independently, so one refusal does not discard the rest, and the result reports every row. The third row here is the ambiguous company from above.
{
"succeeded": 2,
"failed": 1,
"results": [
{
"index": 0,
"operation": "upsert_company",
"ok": true,
"result": {
"status": "created",
"targetId": "co_01M0XV7D53P2Q4S48VYR2N8V4R"
}
},
{
"index": 1,
"operation": "upsert_company",
"ok": true,
"result": {
"status": "created",
"targetId": "co_01M0XV7D53P2Q4S48VYR2N8V4Y"
}
},
{
"index": 2,
"operation": "upsert_company",
"ok": false,
"error": {
"code": "ambiguous_identity",
"message": "Upsert matched multiple plausible records. Nothing was committed.",
"candidates": [
{
"id": "co_01M0XRJKZ9FM7VA4R05FARE42G",
"label": "Bramley Warehousing",
"matchedOn": "exact name, no domain supplied"
}
]
}
}
]
} Everything that changed since you last asked
The read an outside copy is built on. Keep the cursor and hand it back;
do not parse it. retention says how far back the feed goes,
which is the answer to what happens if your job is broken for a fortnight.
curl "https://api.indraft.io/v1/changes?limit=2" \
-H "Authorization: Bearer $INDRAFT_TOKEN" {
"items": [
{
"id": "mut_01M0XV7D53P2Q4S48VYR2N8V4Z",
"operation": "upsert_company",
"targetType": "company",
"targetId": "co_01M0XV7D53P2Q4S48VYR2N8V4Y",
"outcome": "created",
"accessPath": "rest",
"assertionKind": "imported",
"createdAt": "2026-08-26T01:33:48.067Z"
}
],
"nextCursor": "aWRmYzE6bXV0XzAxTTBYVjdENTNQMlE0UzQ4VllSMk44VjRT",
"retention": {
"oldestRetainedAt": "2026-08-26T00:47:29.261Z"
}
} Each entry names the operation, the outcome and the route it came in by, so a row your own automation wrote is distinguishable from one a person typed. That is what stops a two-way sync echoing itself forever.
Asking a narrower question
Every list takes filter, which repeats, and sort. A predicate is
field:op:value, and every predicate you send must hold. Up to
8 of them.
GET /v1/opportunities
?filter=status:eq:open
&filter=amount_minor:gte:5000000
&filter=owner_actor_id:eq:act_01J9Z8QK7M4T2VB3XCDEFGHJKM
&sort=expected_close_date
The comparisons are eq, ne, gt, gte, lt, lte, in, isNull, isNotNull and contains. Sort by a field
name for ascending, or prefix it with a minus for descending. The default is newest created
first, and every ordering is broken on the record id, so a record cannot appear on two pages
or be skipped between them.
Fields your workspace defined on a built-in object are addressed with
cf. in front of the key, so a field you happen to call
status cannot be confused with the one we ship. Your own object types filter on
the fields you declared indexed.
Not everything is filterable, and that is on purpose. A workspace is one
database serving one customer, and a query that cannot use an index would hold up every
other read and write in it rather than only being slow for you. So a field that would need a
full scan is refused, by name, with a list of what you can use instead. Ask
GET /v1/schema once and it tells you the whole vocabulary for every object type,
including your own. There is no substring match and no OR: keyword matching has its own
endpoint, and two narrow calls are predictable where one wide call is sometimes fast.
A cursor from a sorted list remembers the sort it came from. Asking for the next page with a different order is refused rather than answered, because the answer would look complete and quietly be missing records.
Asking for a number instead of records
When the question is how many or how much, ask for the number. The database computes it; nothing summarizes or estimates it, and it does not get slower as your workspace grows the way reading every record and adding them up does.
GET /v1/aggregate
?objectType=opportunity
&function=sum
&field=amount_minor
&filter=status:eq:open
&groupBy=expected_close_date
&bucket=quarter
The functions are count, sum, avg, min and max. count counts records and takes no field. Filters are the same predicates
as above, so an aggregate is a filter plus a reduction rather than a second language.
Grouping is restricted to dimensions that make sense to group by: a stage, a status, an
owner, a lifecycle, a tag, or one of your own enum fields. A date must be bucketed to
day, week, month, quarter or year, because grouping by a raw timestamp gives you one group per record.
GET /v1/schema lists what each object type can be grouped by and reduced.
An answer with more than 200 groups is refused, not shortened. A truncated total reads exactly like a real one and you would have no way to tell, which is the one mistake this product is built not to make. Narrow it with a filter, or group by something coarser.
What we will and will not change
The /v1 in the path is a namespace, not a stability promise, and it would be
dishonest to let it read as one before we have customers to keep it for. Until general
availability this API can change in ways that break a caller, and when it does we will say
so here.
Four things are already frozen and will not change quietly, because they are what your error handling and your identifiers are built on: the error codes, the identifier prefix for every object type, the built-in object types, and the webhook event names. Each is published as a fixed list in the document above, so you can generate against it rather than copy it.
There is no deprecation window yet. Saying that plainly is more use to you than implying one we have not committed to.
Limits
Published as numbers so you can size a scheduler before you write one. Two mechanisms,
and the difference decides your recovery: infrastructure protection returns
rate_limited, which means wait, and a product limit returns
quota_exceeded or workspace_capacity, where retrying the same
call unchanged can never succeed. Plan quotas are separate again and are on the
pricing page.
Every response carries ratelimit-limit, ratelimit-remaining and
ratelimit-reset for the window closest to refusing you, and
ratelimit-policy lists them all. A refusal adds retry-after in
seconds. Read them on the way past and you never have to discover a limit by being
refused.
| Limit | Value | Kind |
|---|---|---|
| Requests per workspace | 1,200 per minute, burst 30 per second | Infrastructure |
| Requests per credential | 600 per minute | Infrastructure |
| Concurrent bulk requests | 4 per workspace | Infrastructure |
| Bulk batch size | 100 operations per request | Infrastructure |
| Request payload | 1 MB | Infrastructure |
| Single record | 256 KB | Infrastructure |
| Search or list page size | 100 | Infrastructure |
| Context expansion | 200 per collection | Infrastructure |
| Idempotency key retention | 30 days | Product |
| Evidence text | 2,000 characters | Product |
| Interaction summary | 4,000 characters | Product |
| Attribution label | 120 characters | Product |
| Change history retention | Kept in full to the workspace ceiling | Product |
| Workspace ceiling | 9.0 GB, about 3,500,000 companies or 10,000,000 records with full history | Product |
Errors
Every failure carries a stable code and a recovery hint written for an agent rather than for a human reading a log. An agent's error handling is only as good as this contract: if it cannot tell an ambiguous identity from a duplicate one, every failure degrades to a blind retry or a give-up.
| Code | Status | Retry? | What to do |
|---|---|---|---|
| unauthorized | 401 | No | Re-authenticate. |
| forbidden_scope | 403 | No | Request authorization with write or configure permission, or use another credential. A token can never exceed the role of the member who created it. |
| standing_override_refused | 403 | No | Send the write again without overrideStanding. It will apply to every field it is strong enough to move and report the rest in held[], and a person can confirm the contested value if it is right. |
| workspace_not_found | 404 | No | Check the selected workspace. |
| validation_error | 422 | No | Correct the listed fields and retry. |
| record_not_found | 404 | No | Search for or create the intended record. |
| ambiguous_identity | 409 | No | Inspect candidates and choose one explicitly by id, or set createDistinct to create a separate record. |
| duplicate_identity | 409 | No | Use the existing record, correct the identity, or merge the two records. |
| stale_version | 409 | No | Re-read the current record and apply the intended change again. |
| invalid_stage_transition | 422 | No | Read the pipeline schema and choose a stage from the same pipeline. |
| idempotency_conflict | 409 | No | Use the original result, or a new key for a new intent. |
| not_reversible | 409 | No | Apply an explicit corrective mutation. Creations are undone by archiving, and a merge is undone by unmerge. |
| merge_conflict | 409 | No | Inspect both records and resolve explicitly. |
| rate_limited | 429 | Yes | Retry after the supplied delay. |
| quota_exceeded | 402 | No | Open billing to raise the plan, or wait for the period to reset. Retrying the same request will not succeed. |
| payload_too_large | 413 | No | Split the request or narrow the query. |
| workspace_capacity | 507 | No | Contact the workspace owner. Reads, history, and deletion all continue to work. |
| payment_required | 402 | No | Open billing or Checkout, or contact the workspace owner. |
| billing_state_stale | 503 | Yes | Retry shortly. An operator should reconcile Stripe state if it persists. |
| operation_in_flight | 409 | Yes | Retry shortly with the SAME key. The original result will be returned once it finishes; a new key would create a second workspace. |
| internal_error | 500 | Yes | Retry once. If it persists, this is our defect rather than your request; report the requestId. |