Skip to content
indraft
Start free

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

MethodPathPermissionToken scopePurpose
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.

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

CodeStatusRetry?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.