DevelopersREST API
REST API
Nine operations under /api/v1, authenticated with a workspace API key. It is a deliberately small surface: it does the things an integration needs to do to a CRM, and it refuses the things a person is supposed to decide.
The surface
Every path below is relative to /api/v1. Two operations read and seven write. Each one asserts exactly one scope, and the scope named here is the one the route handler actually passes to the authenticator, not one copied from a specification.
- GET /contacts
- List contacts, newest first. Scope contacts.read.
- POST /contacts
- Create a contact. Scope contacts.write.
- PATCH /contacts/{id}
- Update a contact's fields, and its lifecycle stage if the gate allows. Scope contacts.write.
- POST /contacts/{id}/tags
- Add tags to a contact. Scope contacts.write.
- POST /contacts/{id}/activities
- Log an activity against a contact. Scope activities.write.
- POST /deals
- Create a deal against a contact. Scope deals.write.
- GET /sequences
- List email sequences, newest first. Scope sequences.read.
- POST /sequences/enroll
- Enroll one contact into one sequence. Scope sequences.write.
- POST /suppression
- Add an address to the workspace suppression list. Scope suppression.write.
There is no delete in the public API, and no endpoint that sends anything. Enrolling a contact arms a sequence the scheduler later sends from; it is the closest the API gets to outbound, and it is the most heavily gated operation on the list.
An OpenAPI 3.1 document for exactly these nine operations is served at /api/v1/openapi.json. It is unauthenticated, because it is documentation, and it is held equal to the real route files by a test that fails when a route is added, renamed or deleted without the document following.
The response envelope
Every response, success or failure, is a JSON object carrying both data and error, so a client reads the same two fields on every path. On success error is null. On failure data is null and error carries a human-readable message, alongside a stable code a client can branch on.
single { "data": { ... }, "error": null }list { "data": [ ... ], "meta": { ... }, "error": null }failure { "data": null, "error": "...", "code": "..." }A 500 additionally carries a correlationId. The message on a 500 is deliberately generic: the underlying error is written to the server log against that id rather than returned to the caller, so quoting the id is how you get it looked up.
Paging the two list endpoints
Both list endpoints take limit and offset as query parameters, and both order by creation time, newest first. Paging is offset-based; there are no cursors.
- limit defaults to 25 and is capped at 100. A larger value is clamped to 100 rather than rejected, and a value that is absent, zero, negative or not a number falls back to 25.
- offset defaults to 0. A negative or unparseable offset is treated as 0.
- The meta block returns total, limit, offset and hasMore. total is an exact count.
Neither list endpoint takes a filter or a search parameter. Listing contacts returns the workspace's contacts in creation order and nothing narrows that server-side.
Idempotent writes
All seven write operations honour an Idempotency-Key header. Send one and the response is stored against the key; present the same key again to that same endpoint and the stored response is replayed verbatim, status included, without a second write. The value is any stable string you choose, so the natural one is an identifier from the system you are syncing from rather than a fresh random token.
curl -X POST https://www.sagaris.ai/api/v1/contacts \ -H "Authorization: Bearer $SAGARIS_API_KEY" \ -H "Idempotency-Key: crm-sync-2026-08-29-row-1043" \ -H "Content-Type: application/json" \ -d '{"email":"dana@example.com","first_name":"Dana"}'The key is scoped to the workspace and the single endpoint, not to the request body. Reusing one key across two different endpoints is fine, and they will not collide. Reusing one key on the same endpoint with a different body replays the first response and never applies the second body, so generate a fresh key per intended write.
A key is only recorded when the write succeeded, so a request that was refused can be retried with the same key once you have fixed what it complained about.
Writing to contacts
Creating a contact needs at least one of email, first_name or last_name. company, job_title, phone, linkedin_url and website are optional. The created row is stamped with source public_api, so contacts that arrived through an integration stay distinguishable from ones a rep typed.
Tags merge rather than replace: the tags you send are unioned with the ones already on the contact, de-duplicated, sorted, and capped at 50 per contact. There is no endpoint that removes a tag.
Activities accept eight types, and only eight: note_added, call_made, call_received, voicemail_left, meeting_booked, meeting_completed, sms_sent and linkedin_message. These are the ones a rep could have logged by hand. The system's own event types, an email open or a stage change or an AI action among them, are not writable here, so an integration cannot manufacture a system event that the product's own reporting would then believe. A note requires body; every other type requires subject.
Gates the API does not bypass
An API key is not an administrator, and this surface was built on the assumption that an integration will eventually be asked to do something a person should have decided. The refusals below are the product's own gates, applied to the key rather than waived for it.
- 402 on a read-only workspace
- Every write except suppression first runs the billing read-only gates: an expired trial, an elapsed financed term, or an enforced delinquency shutoff. A key is not a way to keep writing after the dashboard has stopped.
- 429 on a plan cap
- Creating a contact also consumes the per-plan contact allowance, and is refused when the workspace is at its cap. This is a different 429 from the rate limit and carries a different code.
- 403 force_not_permitted
- Sending force: true on a contact update is refused outright rather than ignored. A gated lifecycle promotion can be overridden by a manager; a key holds no workspace role, so every overridable gate is a hard one for it.
- 400 bant_required
- Some lifecycle stages require a recorded BANT confirmation before a deal can exist against the contact. The API applies the same check as the internal path.
- 409 sequence_not_authorized
- A sequence must carry an applied approval before the API will enroll anyone into it. Approval cannot be granted through the API, and the public write path accepts only the applied state, not merely an approved one.
- 409 contact_not_authorized
- Even under an applied approval, the contact must fall inside the audience that approval covers.
- 409 company_confirmation_required
- A contact with no company on record is held out of enrollment until the company is enriched or a rep confirms it.
- 423 WORKSPACE_PAUSED
- The workspace-level emergency outreach pause blocks enrollment. This check fails closed: if the pause state cannot be read, the request is treated as paused rather than allowed.
Enrollment is also refused while the deployment-wide outbound gate is shut. That gate is default-closed in code and reads only the environment, so it is shut unless the deployment opens it. The check runs before any database work, on the reasoning that arming a sequence while sending is globally stopped would only build a backlog.
Suppression is the one write with no billing gate, and the omission is deliberate and documented in the route. Adding an address to the do-not-contact list only ever removes a recipient from outbound. A workspace that has stopped paying must still be able to honour an opt-out, so gating that write would turn a billing guardrail into a compliance defect.
Tenancy
The workspace is resolved from the key. No endpoint takes a workspace parameter and no request-supplied workspace id is read anywhere on this surface, so there is nothing for a caller to tamper with.
Every operation that names another record, a contact id on a deal, a sequence id on an enrollment, an account id on a contact update, re-checks that the record belongs to the key's workspace before it writes. A record in another workspace comes back as a 404 naming the record type rather than as an authorization error, because from the key's position that record does not exist.
The MCP endpoint
The same key authenticates a Model Context Protocol endpoint at /api/v1/mcp, so an agent can be pointed at a workspace without a second credential. It speaks the Streamable HTTP transport in stateless JSON mode: one JSON-RPC message per POST, no batching, no SSE stream.
The MCP tool surface is read-only, and structurally so rather than by policy. The tool list is derived from the same nine operations filtered to GET, so a write endpoint cannot appear there by being added: only the two read operations, plus Brain views under contacts.read, are exposed. Which of those a given key is offered is filtered by its scopes, so a key with no scopes connects successfully and sees no tools at all.
Behaviour on this page is read from
- src/lib/public-api/openapi-spec.ts
- src/lib/public-api-v1.ts
- src/lib/public-api-auth.ts
- src/app/api/v1/contacts/route.ts
- src/app/api/v1/contacts/[id]/route.ts
- src/app/api/v1/contacts/[id]/tags/route.ts
- src/app/api/v1/contacts/[id]/activities/route.ts
- src/app/api/v1/deals/route.ts
- src/app/api/v1/sequences/route.ts
- src/app/api/v1/sequences/enroll/route.ts
- src/app/api/v1/suppression/route.ts
- src/app/api/v1/mcp/route.ts
- src/lib/billing/api-write-gate.ts
- src/lib/activity-log.ts
- src/lib/mcp/tool-surface.ts
Was this page helpful?