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, data:export | Plan, subscription status, and usage against every quota |
| GET | /mcp/connections | crm:read | crm:read, crm:write, crm:configure, crm:admin, 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, 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, data:export | Recent mutations and their diffs, or everything since a watermark |
| POST | /v1/changes/{id}/revert | crm:revert | crm:admin | Compensate a reversible change |
| GET | /v1/companies | crm:read | crm:read, crm:write, crm:configure, crm:admin, 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, 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, data:export | Bounded company context for an agent |
| GET | /v1/companies/{id}/interactions | crm:read | crm:read, crm:write, crm:configure, crm:admin, data:export | Recent interactions for a company |
| GET | /v1/contacts | crm:read | crm:read, crm:write, crm:configure, crm:admin, 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, 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, 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, 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, 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, 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, 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, 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, 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, data:export | List opportunities |
| POST | /v1/opportunities | crm:write | crm:write, crm:admin | Create an opportunity |
| GET | /v1/opportunities/{id} | crm:read | crm:read, crm:write, crm:configure, crm:admin, 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, data:export | Describe the CRM model, pipelines, custom fields, and limits |
| GET | /v1/search | crm:read | crm:read, crm:write, crm:configure, crm:admin, data:export | Search across the CRM |
| GET | /v1/stale | crm:read | crm:read, crm:write, crm:configure, crm:admin, data:export | Records nobody has touched for a while, quietest first |
| GET | /v1/tasks | crm:read | crm:read, crm:write, crm:configure, crm:admin, 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, data:export | Read one 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, 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/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, 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 |
| 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 |
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. |
| 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. |