Nineteen SAGARIS API operations, scopes and the gates
Nineteen 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, a Social checklist and a connected LinkedIn agent, and it refuses the things a person is supposed to decide.
Pipeline contacts from another system
Use PUT /api/v1/contacts/assert with a workspace API key that has contacts.write. Email is required and matched case-insensitively within that workspace. Supply first_name, last_name, company, job_title, phone, linkedin_url or website as needed. Omitted fields stay unchanged; an empty optional field clears it. Unknown fields are rejected. The result contains the contact id: retain it for activities, tags and subsequent updates.
PUT /api/v1/contacts/assertAuthorization: Bearer <workspace-api-key>Content-Type: application/json{ "email": "alex@example.com", "first_name": "Alex", "company": "Example" }Assertion returns 201 for a new contact and 200 for an update. It uses email identity rather than the Idempotency-Key response cache. A concurrent insert is re-resolved and updated through the same gated path. It neither enrolls the contact nor grants outreach consent. New contacts are visible in the default CRM list.
POST /api/v1/contacts/queryAuthorization: Bearer <workspace-api-key-with-contacts.read>Content-Type: application/json{ "filter": { "updated_since": "2026-09-01T00:00:00Z" }, "sort": "updated_at", "direction": "asc", "limit": 100, "offset": 0 }Filters are ANDed. Email is literal case-insensitive equality; company and source are exact equality; updated_since is inclusive. Unknown filters are rejected. Limits are 1 to 100, offsets 0 to 100000, and ties sort by id. Pages are not a snapshot while records change: overlap incremental sync windows and deduplicate returned ids. This query omits archived records and is not a deletion feed. It shares the write-rate budget.
The surface
Every path below is relative to /api/v1. Eight operations read and eleven 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.
- PUT /contacts/assert
- Create or update a contact by email, preserving omitted fields. Scope contacts.write. Safe to retry against the same email.
- POST /contacts/query
- Filter live contacts by email, company, source and changed-since timestamp, with deterministic sorting and pagination. Scope contacts.read.
- GET /contacts/{id}
- Retrieve one live contact. Scope contacts.read. Foreign and archived records return 404.
- 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.
- GET /social/checklist/items
- The Social checklist with the workspace's status, evidence, next due date, executor record and card state for every item, filterable by kind, frequency and status. Scope social_checklist.read.
- GET /social/checklist/due
- The checklist items due now, by the runner's own rules and order: a requested change first, then the default checklist by priority, each with why it is due. Scope social_checklist.read.
- GET /social/checklist/items/{id}/context
- One item in full with the brand voice, profile facts, connection states and recent posts it is done from. Scope social_checklist.read.
- POST /social/checklist/items/{id}/submissions
- Hand an item's output to a person: a post lands in the Social review queue at needs_review, and a document becomes one pending approval card for an item whose person action is Approve, Request change or Deny (the redo, when a person asked for a change). 409 card_waiting while a card waits. Scope social_checklist.submit.
- POST /social/checklist/items/{id}/evidence
- Record what was found on an observation item whose done-when is a recorded finding, as an assistant's mark that never replaces a person's. Any other item is 409 approval_required. Scope social_checklist.record.
- GET /linkedin/actions
- The LinkedIn actions assigned to the key's connected agent, with recipient, copy and campaign context, filterable by status. Scope linkedin.read.
- POST /linkedin/actions
- A connected agent's commands: read its setup and its operating brief, report its session, sender health, accepted connections, schedules and runs, sync observed replies and accepted invitations, acknowledge a reconnect, and claim, start and report a LinkedIn action. Scope linkedin.execute.
There is no delete in the public API, and no endpoint that sends or publishes anything. Enrolling a contact arms a sequence the scheduler later sends from, and it is heavily gated. A Social checklist submission only creates a draft or a card for a person to decide on. POST /linkedin/actions, described under the MCP endpoint below, goes further: it authorizes a connected agent to send a LinkedIn action from its own browser session.
An OpenAPI 3.1 document for exactly these nineteen 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
Apart from /linkedin/actions, every response, success or failure, is a JSON object carrying both data and error, so a client reads the same two fields on every other 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.
The two /linkedin/actions operations follow this envelope only in part: some of their successes carry data without error, some of their refusals carry only error, and their 409 refusals carry no code.
Paging contacts and sequences
GET /contacts and GET /sequences 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 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
Nine of the eleven write operations honour an Idempotency-Key header; PUT /contacts/assert matches on email instead, and POST /linkedin/actions does not read the 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 and POST /linkedin/actions first runs the billing read-only gates: an expired trial, an elapsed financed term, or an enforced delinquency shutoff. For those writes, a key is not a way to keep writing after the dashboard has stopped. POST /linkedin/actions runs no billing read-only gate; its only billing check is the plan's LinkedIn allowance, before claim and start.
- 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 has 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.
Read tools are derived from the public API's GET operations, plus Brain views under contacts.read. A write operation becomes a tool only when it asserts a scope on one of two short lists fixed in code. Which tools a key is offered is filtered by its scopes, so a key with no scopes connects successfully and sees no tools at all.
The approval-bound list is social_checklist.submit and social_checklist.record. Their routes create a needs_review post or an approval card, or record an assistant's evidence, and never publish, send or approve.
The execution list is linkedin.execute, and its one tool is POST /api/v1/linkedin/actions. A connected agent uses it to read its operating brief, report its session, sender health, schedules and runs, sync the replies and accepted invitations it observed, acknowledge a reconnect request, and then to claim, start and report a LinkedIn action that it performs in its own browser session. The tool is annotated as destructive, because a sent message cannot be taken back.
Until LinkedIn execution is activated for the environment, which the tool's setup command reports, claim and start answer 409; setup, heartbeats, health observations, results, accepted connections, the operating brief, schedule reports, run reports, synced observations and reconnect acknowledgements are handled either way. An action can be claimed only after a person approved it or set its LinkedIn account to run automatically. A sent result is logged as an activity and moves the contact's LinkedIn status forward; for a sequence step it also advances the sequence. A reported accepted connection marks the contact connected and is logged too. SAGARIS makes those changes, not the agent.
Every CRM write asserts a scope outside both lists, so it cannot appear as a tool by being added. A write tool calls the route's own handler, so the route's scope, rate limit, validation and workspace scoping apply. The Social checklist routes also run the billing write gates; the LinkedIn route's only billing check is the plan's LinkedIn allowance, before claim and start.
The endpoint also implements MCP Events (draft specification, protocol 2026-07-28): events/list, events/subscribe and events/unsubscribe, with three events, linkedin.work.available, linkedin.schedule.changed and linkedin.reconnect.requested. A subscription names a webhook URL and a secret; SAGARIS verifies the URL with a signed challenge first, signs every delivery with Standard Webhooks headers, retries transient failures with backoff, and sends ids, counts and the account's label in SAGARIS, never a contact's name, a profile URL or message copy.
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/social/checklist/items/route.ts
- src/app/api/v1/social/checklist/due/route.ts
- src/app/api/v1/social/checklist/items/[id]/context/route.ts
- src/app/api/v1/social/checklist/items/[id]/submissions/route.ts
- src/app/api/v1/social/checklist/items/[id]/evidence/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
- src/app/api/v1/linkedin/actions/route.ts
- src/lib/linkedin/action-api.ts
- src/lib/linkedin/action-store.ts
- src/lib/linkedin/engine/automation.ts
- src/lib/linkedin/engine/store.ts
Was this page helpful?
In this topic