SealDeal API
A REST API over your contacts, companies, lists, deals, sequences, drafts, action items, suppression list, team directory, pre-sales questionnaires and platform events, plus pipeline and funnel analytics and webhooks for event driven integrations. Batch endpoints, upserts by your own record id and incremental sync make it a fit for a CRM or data warehouse sync as well as a single Zap. All 86 operations are documented below. Machine readable OpenAPI 3.1 schema: /api-docs/openapi.json
Base URL: https://sealdeal.ai/api/v1
Authentication
Every request is authenticated with an API key belonging to exactly one organization. Mint keys at /admin/integrations/api-keys, choosing the scopes the key should carry. The secret is shown once at creation and stored only as a hash, so it cannot be recovered later.
curl https://sealdeal.ai/api/v1/me \
-H "Authorization: Bearer obk_live_<your-secret>"A missing header returns 401, an unknown or expired key returns 401, and a valid key that lacks the scope a route requires returns 403 naming the missing scope.
Quickstart
A first integration in four calls: check the key, create a list, add a contact to it, then read back the drafts SealDeal writes for that contact. The key needs lists.write, contacts.write and drafts.read.
export SEALDEAL_KEY=obk_live_<your-secret>
API=https://sealdeal.ai/api/v1
# 1. Which org and scopes does this key have?
curl -s $API/me -H "Authorization: Bearer $SEALDEAL_KEY"
# 2. Create a list. The Idempotency-Key makes a retry safe.
curl -s -X POST $API/lists \
-H "Authorization: Bearer $SEALDEAL_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2b2e-first-list" \
-d '{"name": "Q4 webinar attendees", "sourceType": "CONFERENCE"}'
# 3. Add a contact to it (201 created, 200 if it already existed)
curl -s -X POST $API/contacts \
-H "Authorization: Bearer $SEALDEAL_KEY" \
-H "Content-Type: application/json" \
-d '{"listId": "<list id>", "email": "dana@example.com", "name": "Dana Lee",
"company": {"name": "Example Co", "domain": "example.com"}}'
# 4. Drafts arrive once the list generates; approve one to queue it for sending
curl -s "$API/drafts?contactId=<contact id>" -H "Authorization: Bearer $SEALDEAL_KEY"Approving (POST /drafts/:id/approve, scope drafts.write) never sends by itself: approved drafts go out on their schedule through your workspace's verified mailbox. To be told when things happen instead of polling, subscribe a webhook.
Scopes
| Scope | Endpoints | Grants access to |
|---|---|---|
| actions.read | 2 | Action items |
| analytics.read | 3 | Analytics |
| contacts.erase | 1 | Contacts |
| contacts.read | 4 | Contacts, Inbound leads |
| contacts.write | 10 | Contacts, Inbound leads, Action items |
| deals.read | 8 | Companies, Deals, Deal stages |
| deals.write | 8 | Companies, Deals |
| drafts.read | 3 | Drafts, Messages |
| drafts.send | 1 | Drafts |
| drafts.write | 6 | Drafts |
| events.read | 2 | Events, Usage |
| lists.read | 2 | Lists |
| lists.write | 3 | Lists |
| presales.read | 3 | Questionnaires |
| presales.write | 4 | Questionnaires |
| sequences.read | 2 | Sequences |
| sequences.write | 2 | Contacts, Sequences |
| suppression.write | 4 | Suppression |
| team.read | 3 | Members, Teams |
| webhooks.manage | 11 | Webhooks |
Rate limits
- Requests per API key in each fixed 60 second window, by plan: Starter 60, Team 120, Growth 300, Scale 600, Enterprise 1,200. It applies to every authenticated endpoint.
- All the keys in an organization together get twice that per window (Growth: 300 per key, 600 across every key). Past it, every key in the organization gets 429 until the window resets.
- Every response to an accepted key carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(unix seconds) for whichever limit is closer to running out, andX-RateLimit-Scopesaying which one that is:keyororganization. - On exhaustion: 429 Too Many Requests with
Retry-After(seconds until the window resets) andX-RateLimit-Remaining: 0. - A batch request counts one unit per 10 rows (at least one), so a full 200 row batch costs 20 units, not 200.
- Mint one key per integration rather than sharing one across workflows: each key gets its own plan limit, up to the combined ceiling for the organization.
Idempotency
POST /lists, POST /deals and POST /deals/:id/activities accept an Idempotency-Key header (up to 255 characters), and so does every batch endpoint. Retrying with the same key returns what the first call created (for a batch, the same per row results) instead of creating it again, so a retry after a timeout is always safe. Reusing a key with a different body is a 422. POST /contacts, POST /sequences and POST /webhooks need no header: they are idempotent on the email, the contact and template pair, and the URL respectively.
Pagination
List endpoints return cursor paginated results:
{
"data": [ ... ],
"nextCursor": "eyJpZCI6Ii4uLiIsInQiOiIyMDI2LTA1LTIzVDEwOjAwOjAwLjAwMFoifQ"
}Pass ?cursor=<value> on the next call to fetch the following page. A null nextCursor means the last page. Default page size is 50 and the maximum is 200, set with ?limit=. Contacts, companies and deals also take ?sort=createdAt|updatedAt|name and ?order=asc|desc, and ?include=total adds a total that respects every filter.
Syncing with another system
- Every list endpoint takes
?updatedSince=(ISO 8601), so a sync fetches only what changed since its last run;GET /eventstakes?createdAfter=. PUT /contacts/by-external-id/:idandPUT /companies/by-external-id/:idcreate or update a record keyed by your own id, and?externalId=finds it again.- Batch endpoints for contacts and companies (200 rows), draft approve and reject (100) and suppression (500) answer with a result for every row, so one bad row never fails the rest.
GET /membersandGET /teamsresolve the owner ids every record carries to a person and a team.
Long running work
Research can run in the background: POST /companies/:id/research?mode=async answers 202 with a job to poll at GET /research-jobs/:id, and a company.research_completed or contact.research_completed event fires when it lands. The research queue runs every minute, about 8 jobs per run and 4 at once, and organizations take turns, so a long queue from one customer never holds up another; a queued job usually lands within a minute or two. A questionnaire uploaded through POST /questionnaires is answered the same way: start it, then poll GET /questionnaires/:id until its status reads review. Its upload URL is signed for the sizeBytes you declared: PUT exactly that many bytes, or storage refuses the upload with 403 and stores nothing.
Errors
Error responses use RFC 7807 application/problem+json. The type URL is a real page: every problem type is documented at /errors, including whether retrying the same request can ever succeed.
{
"type": "https://sealdeal.ai/errors/insufficient-scope",
"title": "Insufficient scope",
"status": 403,
"detail": "The API key is missing required scopes: deals.write."
}Endpoints
Identity
Confirm what a key can do before wiring anything else to it. Needs no scope.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/me | any valid key | Get current API key + org infoNo scope required — any valid key works. Useful for integrations to sanity-check their auth wiring. |
Members
The team directory: every seat with its role, team and manager, so an owner id on a contact, deal or list can be mapped to a person. Needs the team.read scope.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/members | team.read | List members (team directory)Every seat in your organization: active members, pending invites and deactivated members (not self-serve signups still awaiting an admin approval). Cursor paginated (createdAt DESC, id DESC). Use it to map an owner id on a contact, deal or list to a person. |
| GET | /api/v1/members/{id} | team.read | Get a member404 when the id is unknown, belongs to another organization, or is a signup awaiting approval. |
Teams
Your teams with their manager, parent team and member count. Needs the team.read scope.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/teams | team.read | List teamsThe teams in your organization with their manager, parent team and active member count. Cursor paginated (createdAt DESC, id DESC). |
Contacts
The person records outreach is built from. Includes the research dossier (running contact research costs 1 action, like company research) and the GDPR erasure door, which is gated on role as well as scope.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/contacts | contacts.read | List contacts |
| POST | /api/v1/contacts | contacts.write | Create a contactIdempotent, one contact per person. If the email is already on the requested list, the existing contact is returned with 200. If it is already live on ANOTHER list in the org, that existing contact is returned with 200 and the header `X-SealDeal-Existing-List: true` — no copy is created, and the body's `list` is the list it lives on, not the one requested. "Live" means not deleted, on a list that is neither archived nor a test list; a copy anywhere else does not block the create. A 200 never creates or changes a contact. When you passed an explicit `listId`, the existing contact is ATTACHED to that list (it then appears in `GET /contacts?listId=`), while its own list, owner and sequence stay where they are; with no `listId` nothing is attached. 201 = created, 200 = already existed. Only the fields below are accepted: any other field (for example phone, notes or leadSource, which PATCH /contacts/{id} sets) is a 400 naming it, rather than silently dropped. Lengths are capped (email 254, name and title 200, company.name 200, company.domain 253 characters); fitScore and urgencyScore are whole numbers. OWNER: the contact's owner is the member its drafts are written and signed as. Created into an explicit `listId`, it is the LIST's owner (as the in-app upload files a list's rows), falling back to the key's acting member when the list owner is deactivated or view-only; on the CRM import list it is the key's acting member. `ownerSalesPersonId` names the owner explicitly: someone other than the acting member needs an ADMIN, OWNER or MANAGER key (else 403) and an active, non-view-only member (else 400). A 200 (already existed) never changes the existing contact's owner. Re-creating the address of an ERASED contact creates a new contact that the erasure's suppression entry still blocks from every send. |
| GET | /api/v1/contacts/{id} | contacts.read | Get single contact |
| PATCH | /api/v1/contacts/{id} | contacts.write | Update a contact (whitelisted fields)Zapier "Update Contact" action. Allowed fields: name, title, phone, notes, leadSource, companyId, qualificationStatus, fitScore, urgencyScore, ownerSalesPersonId. Lengths are capped (name, title and leadSource 200, phone 50, notes 2000 characters); fitScore and urgencyScore are whole numbers. An archived or erased contact is 404. `ownerSalesPersonId` reassigns the contact exactly as the app does: its open sequences stand down for the new owner to review, its SCHEDULED SENDS ARE CANCELLED and its unsent drafts are re-signed as the new owner. It needs a key created by an ADMIN, OWNER or MANAGER (403 otherwise); an unknown, deactivated or view-only target, or one in another organization, is a 400. The response then carries a `reassignment` block with the counts. |
| DELETE | /api/v1/contacts/{id} | contacts.write | Soft-delete a contact (reversible)W6 — SOFT delete. This is the REVERSIBLE operation and what most callers want: the row and its history are kept, the contact disappears from default reads and the send queue, and an admin can restore it. There is no purge cron. **This is NOT the GDPR verb** — it hides a contact, it does not erase anything; use POST /contacts/{id}/erase for that. `undoAvailableUntil` (30 days) is ADVISORY ONLY: nothing is destroyed when it passes and an admin can still restore afterwards, so do not build a client that treats it as a deadline. Idempotent: deleting an already-deleted contact returns 200 with the ORIGINAL `deletedAt` and does not re-fire `contact.deleted`. An ERASED contact is 404, as on GET. |
| PUT | /api/v1/contacts/by-external-id/{externalId} | contacts.write | Create or update a contact by your own idUpsert keyed on YOUR id for the person, so a sync never has to store SealDeal ids. SealDeal stores it as `api:<externalId>` (returned as `externalId`), so it can never match or overwrite a CRM link (`hubspot:...`, `salesforce:...`) or an uploaded prospect_id. URL-encode the id. Send it without the `api:` prefix (an id starting with `api:` is a 400). The body is the POST /contacts body (email required). A live contact already carrying the id is UPDATED (200, `created: false`): name, title, qualificationStatus, fitScore and urgencyScore, through the same writer as PATCH /contacts/{id}. `email` must be that contact's address (409 otherwise). listId, company and ownerSalesPersonId apply only to a create; on an update they are not applied and are listed in `ignoredFields` (change an owner with PATCH /contacts/{id}). Otherwise the contact is created exactly as POST /contacts would (201), or, when the person already exists in your organization, that contact is linked to the id and returned (200). A person who already carries another external id (a CRM link or an uploaded prospect_id) is a 409 naming the contact: SealDeal never overwrites it. A second request for the same id while one is in progress waits up to 5 seconds, then is a 409 to retry. |
| POST | /api/v1/contacts/reassign | contacts.write | Reassign many contacts to one ownerMove contacts to a new owner, for example when a rep leaves. Send `contactIds` (1 to 500) or `fromOwnerSalesPersonId` (the contacts that member owns, 500 per call: call again while `hasMore` is true). Each moved contact is handled exactly as the bulk Reassign in the app: its open sequences stand down for the new owner to review, its SCHEDULED SENDS ARE CANCELLED and its unsent drafts are re-signed as the new owner. Contacts the target already owns are skipped, so a retry changes nothing. Needs a key created by an ADMIN, OWNER or MANAGER (403 otherwise); an unknown, deactivated or view-only target, or one in another organization, is a 400. Unknown or deleted contact ids are listed in `notFoundContactIds`; when none of the ids exist the answer is 404. |
| POST | /api/v1/contacts/batch | contacts.write | Create up to 200 contacts in one callEach row is a POST /contacts body and runs through the same code, so every rule of that endpoint holds per row: idempotent on list and email, one contact per person, the owner rule, the CRM import list for an omitted listId. Row status: `created`, or `existing` (the person already exists; `existingList: true` when they live on another list, as POST /contacts says with X-SealDeal-Existing-List). Unlike POST /contacts, a batch fires no rules-engine contact.created event and no CRM push per contact (the CSV upload's rule, so a batch cannot become hundreds of rule runs or a CRM rate-limit storm); use POST /contacts when a rule must see each new contact. Answers 200 whatever each row did: `results` holds one entry per row, in order (`index` is the row's position in `rows`), and `summary` counts them. A row that fails carries `error`, the problem the single-row endpoint would have answered for it (`status`, `type`, `title`, `detail`); the other rows still run. A body that is not `{ "rows": [...] }`, has no rows, or holds more than the cap is a 400 before any write. Rate limit: the call counts one unit per 10 rows (rounded up) against the key's per-minute limit. With an `Idempotency-Key`, a repeat with the same key and body answers with the first call's results (header `Idempotent-Replayed: true`) and writes nothing; the same key with a different body is a 422. |
| POST | /api/v1/contacts/{id}/erase | contacts.erase | Permanently erase a contact (GDPR Art. 17, IRREVERSIBLE)W6 — GDPR erasure. **IRREVERSIBLE.** Requires the `contacts.erase` scope **AND** the acting API key to be attributed to an org admin (MANAGER/ADMIN/OWNER role) — holding the scope alone is NOT sufficient, because scopes are granted per key while this check is per person. A REP-owned key with the scope still gets 403. This is the single most common integration mistake with this endpoint: build your error handling to expect 403 even when the scope is present. Body (BOTH fields REQUIRED — 400 without either): `requesterRef` (1-200 chars, the data-subject request reference/ticket id — the evidence trail for the erasure) and `reason` (1-500 chars). Returns 503 when the deployment has no erasure hash secret configured (`ERASURE_EMAIL_HASH_SECRET` unset or <32 chars) — refused rather than performed with a weak, dictionary-reversible tombstone. Erasure REDACTS IN PLACE rather than deleting rows: Message/EmailDraft/SendEvent/AuditLog/PlatformEvent/Deal all survive with PII stripped (Deal keeps its revenue data, only `primaryContactId` is nulled). Inbound leads for the address (and any lead converted into the contact) are redacted and tombstoned: they disappear from GET /leads, and a lead from an erased address cannot be converted. A hash-backed suppression entry is written in the same transaction so the address stays permanently suppressed for the org. Idempotent: erasing an already-erased contact returns 200 with `alreadyErased: true` and does not re-fire the event. Erases the PERSON: every contact record in the org with the same address (one per list the person was imported into, whichever rep owns it, archived records included) is erased in the same request and listed in `erasedContactIds`, each with its own evidence row. All or nothing: if any record fails, none is erased and the request returns 500, so a retry starts clean. The address match is the whole address, case-insensitive: a plus-tag variant (`jo+news@x.com` for `jo@x.com`) is a separate record that is NOT redacted, though the suppression entry (matched on the address with plus tags stripped) still blocks sending to it. Erase such a record by its own id. Emits `contact.erased` for each erased record, carrying ONLY the contactId, never the address. Honest boundary (GDPR Art. 19): SealDeal erases its own data and emits the event so subscribers can propagate, but a payload already delivered to a customer's Zapier/CRM/webhook endpoint is in THAT system and SealDeal cannot reach it. Most callers want DELETE /contacts/{id} instead (reversible soft delete) — use this endpoint only for an actual data-subject erasure request. |
| GET | /api/v1/contacts/{id}/research | contacts.read | Get cached research dossier (L4) for a contact |
| POST | /api/v1/contacts/{id}/research | contacts.write | Trigger a fresh research pipeline run for a contact (bypasses cache)PAID: runs web search and model extraction. Contact must have a linked Company with a domain; an archived or erased contact is 404 and nothing runs. SYNCHRONOUS by default: the request stays open while the web search and extraction run (typically tens of seconds), and the response carries the finished result. Set a client timeout of at least 120 seconds. Rate-limited by the org research budget (429 when exceeded). CHARGED: a contact research run costs 1 action on an actions-metered organization, like company research (free when the contact's company is already covered by a researched prospect, refunded when the run finds nothing); at zero the answer is 402 `actions-exhausted` and nothing runs. ASYNC (`?mode=async`): the same checks run first (404, 400, 402, 429), then the run is queued and the answer is 202 `{ jobId, statusUrl }` at once; poll GET /research-jobs/{id} (the `statusUrl`) until `status` is `completed`, `failed` or `skipped`, then read the dossier at `resultUrl`. A request for a record that already has a job queued or running returns that job (`coalesced: true`). LATENCY: queued research is run by a background worker every minute, about 8 jobs per run across all customers, 4 at a time; API jobs go ahead of automatic refreshes, and organizations take turns, so a long queue from one customer does not hold up another's jobs. Expect a minute or two, longer when many jobs are queued. Poll no more than once every 30 seconds. At most 25 queued API research jobs per organization (429 `research-queue-full` past it). Emits `contact.research_completed` (resourceType Contact, payload `{ contactId, jobId, mode }`) when the run completes, in either mode. |
| POST | /api/v1/contacts/{id}/pause-sequence | sequences.write | Pause a contact's active sequence(s)Zapier "Pause Sequence" action. Pauses every ACTIVE/QUEUED/DRAFTED/APPROVED ContactSequence for the contact. Emits `sequence.paused` (resource Contact) when pausedCount > 0; a replay that pauses nothing emits nothing. |
Companies
Account records, identified by normalized domain. Freemail domains never mint a company.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/companies | deals.read | List companiesCompany rows for the account grain of the deal surface. Powers the Zapier "Create Deal" company dropdown; `?search=` narrows by name/domain as the user types. |
| POST | /api/v1/companies | deals.write | Create a companyCreate (or match) a company by domain. Identity is (organization, normalized domain) - the same key every other write path uses - so this is naturally idempotent: 201 when the row was created, 200 when it already existed. A repeat call never duplicates and never overwrites a name that research or a rep already curated (backfill-only). Freemail domains (gmail.com, outlook.com, ...) are rejected with a 400: a personal-email provider is not an account. Accepts a bare domain or a pasted URL. Only `domain` and `name` are accepted (any other field is a 400); domain is capped at 253 characters and name at 200. |
| PUT | /api/v1/companies/by-external-id/{externalId} | deals.write | Create or update a company by your own idUpsert keyed on YOUR id for the account. Stored as `api:<externalId>` in `crmExternalId`, so it never matches or overwrites a CRM account link; one company per id in your organization. URL-encode the id; send it without the `api:` prefix. Body: the POST /companies body. A company already carrying the id is UPDATED (200, `created: false`): `name`, when sent, replaces the stored name; `domain` must be that company's domain (409 otherwise: the domain is a company's identity). Otherwise the company is created or matched by domain exactly as POST /companies does (201 created, 200 matched) and linked to the id; a matched company that already carries another external id (a CRM account) is a 409 naming it. |
| POST | /api/v1/companies/batch | deals.write | Create or match up to 200 companies by domain in one callEach row is a POST /companies body (`{ domain, name? }`) and runs through the same code: a bare domain or a pasted URL, freemail refused, an existing company's name never overwritten. Row status: `created` or `existing` (`id` is the company either way). Answers 200 whatever each row did: `results` holds one entry per row, in order (`index` is the row's position in `rows`), and `summary` counts them. A row that fails carries `error`, the problem the single-row endpoint would have answered for it (`status`, `type`, `title`, `detail`); the other rows still run. A body that is not `{ "rows": [...] }`, has no rows, or holds more than the cap is a 400 before any write. Rate limit: the call counts one unit per 10 rows (rounded up) against the key's per-minute limit. With an `Idempotency-Key`, a repeat with the same key and body answers with the first call's results (header `Idempotent-Replayed: true`) and writes nothing; the same key with a different body is a 422. |
| GET | /api/v1/companies/{id} | deals.read | Get a single companyA foreign / non-existent company 404s, matching GET on contacts/deals. |
| GET | /api/v1/companies/{id}/research | deals.read | Get a company's cached research dossierMirrors GET /contacts/{id}/research applied to the account grain (Company) instead of the person grain (Contact). |
| POST | /api/v1/companies/{id}/research | deals.write | Trigger a fresh company-seeded research pipeline run (bypasses cache)No contact to research, only the account — companySeeded mode. Unlike the contact research route there is no "missing domain" 400 case (Company.domain is NOT NULL). Emits `company.research_completed` (resourceType Company) with payload `{ companyId, hasMinimumCoverage, lowConfidence }`, the same telemetry-derived shape as `research.completed` on the contact side. SYNCHRONOUS by default: the request stays open while the search and extraction run (typically tens of seconds) and returns the finished result. Set a client timeout of at least 120 seconds. CHARGED: a researched company costs 1 action on an actions-metered organization; at zero the answer is 402 `actions-exhausted` and nothing runs. ASYNC (`?mode=async`): the same checks run first (404, 400, 402, 429), then the run is queued and the answer is 202 `{ jobId, statusUrl }` at once; poll GET /research-jobs/{id} (the `statusUrl`) until `status` is `completed`, `failed` or `skipped`, then read the dossier at `resultUrl`. A request for a record that already has a job queued or running returns that job (`coalesced: true`). LATENCY: queued research is run by a background worker every minute, about 8 jobs per run across all customers, 4 at a time; API jobs go ahead of automatic refreshes, and organizations take turns, so a long queue from one customer does not hold up another's jobs. Expect a minute or two, longer when many jobs are queued. Poll no more than once every 30 seconds. At most 25 queued API research jobs per organization (429 `research-queue-full` past it). An async company job is charged when it runs, and emits `company.research_completed` with payload `{ companyId, jobId, mode: "async" }` when it completes. |
Research jobs
Research queued with mode=async on a contact or company research call. Poll the job until it finishes, then read the dossier where it points. Reading a job needs the read scope of the record it researches.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/research-jobs/{id} | any valid key | Poll a queued research jobReads a job queued by POST /contacts/{id}/research?mode=async or POST /companies/{id}/research?mode=async (the `statusUrl` they return). `status` moves pending → running → completed, or ends failed (after 3 attempts) or skipped (nothing to research, e.g. the contact has no company domain; `lastError` says why). Once completed, read the dossier at `resultUrl`. Scope follows the record: a contact job needs `contacts.read`, a company job `deals.read` (403 otherwise). A job in another organization is 404. |
Lists
Groups of contacts, and the door that generates drafts for all of them. Generation is asynchronous: poll GET /lists/{id}, whose `generation` block says when the run finished and what happened to each contact. Deleting a list with sequences in flight is refused unless forced.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/lists | lists.read | List contact lists |
| POST | /api/v1/lists | lists.write | Create a contact listThe list owner is the member whose identity its drafts are written and signed as. It defaults to the key's acting member; `ownerSalesPersonId` (or its alias `userId`, the name responses use for the owner) names another. Naming someone else needs an ADMIN, OWNER or MANAGER key (else 403), and they must be an active, non-view-only member of your organization (else 400, the same message for an unknown id, another organization's, or a deactivated or VIEWER seat). Other unknown body fields are ignored, as on POST /deals and POST /contacts. |
| GET | /api/v1/lists/{id} | lists.read | Get single contact listW6 — a soft-deleted list (see DELETE below) 404s here, same posture as a deleted contact. Distinct from `archivedAt` (a rep tidying their sidebar), which still returns 200. `generation` reports the latest draft-generation run and what happened to each contact (see ListGeneration); it is the status to poll after POST /lists/{id}/generate. |
| DELETE | /api/v1/lists/{id} | lists.write | Soft-delete a list and its membershipsW6 — soft-deletes the LIST and its ContactListMembership rows. **The CONTACTS ALWAYS SURVIVE**: a contact whose only membership was this list becomes unlisted, never deleted; nothing on this path touches the Contact table. Returns 409 (`list-has-in-flight-sequences`) when the contacts whose own list this is have ACTIVE/QUEUED/DRAFTED/APPROVED sequences, unless `?force=true`, which PAUSES those sequences first (a contact merely attached to this list from another list keeps its sequence, owned by that list) and reports the count — deleting silently would strand a sequence mid-cadence while the prospect keeps receiving later steps. `?force=true` pauses rather than cancels, so the schedule is intact for a future restore. Idempotent: deleting an already-deleted list returns 200 with the original `deletedAt` and does not re-fire anything. No platform event is emitted (the webhook catalog has no `list.deleted` type) — an AuditLog row records the deletion instead. |
| POST | /api/v1/lists/{id}/generate | lists.write | Trigger AI draft generation for every contact in a listIdempotent no-op if a generation run is already in flight. Async. POLLING: poll GET /lists/{id} (the `statusUrl` in the response) until `generation.state` is `finished`; `generation.contacts` then says how many contacts were drafted, suppressed, held (with `heldReasons`) or failed, and GET /drafts?listId={id} returns the drafts. Do not poll GET /drafts alone: a run can finish with zero drafts. `totalContacts` counts every live contact the run covers; `suppressedContacts` of them get a SUPPRESSED marker row, not a draft. Gated by the org outbound entitlement and, for an actions-metered org, its balance (402). A soft-deleted list is a 404. |
Drafts
Generated emails awaiting a human. Approve and send are separate scopes on purpose, so a key can queue work without being able to send it. An edit carries the version you read, so a change made since is never overwritten.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/drafts | drafts.read | List drafts |
| GET | /api/v1/drafts/{id} | drafts.read | Get single draft (includes full body)No new path, no new scope. `?include=evidence` (comma-separated; unknown tokens are ignored, not rejected — future includes never break a caller) adds four keys on top of the default `{ id, organizationId, contactId, contactName, subject, body, step, status, sentAt, openedAt, repliedAt, createdAt, decision, standDown, version, superseded, latestVersionDraftId }`: `citations` — Citation[], resolved against the contact's research sources (the same 1-based numbered "Sources:" list the drafting prompt emitted). `[]` when the draft has no provenance, never an error. **`resolved: false` is a real state, not an error**: provenance is written fail-closed, so an index that no longer resolves means the contact's research was re-run since the draft was written; the entry is still returned (with null sourceUrl/sourceTitle) because dropping it would understate what the model cited, while returning it unmarked would let `citations.length` be read as "verified, link-backed citations" and overstate grounding. Consumers that want a grounding count MUST use `citations.filter(c => c.resolved).length`. `unresolvedCitationCount` — integer, how many of `citations` have `resolved: false`. `guardrailReport` — `{ clean, attempts, repaired[], advisories[] }` plus optional keys (see the schema) or null for legacy/fallback rows that never got one. Null means "no report", NOT "clean". New optional keys may appear; ignore the ones you do not use. `provider` — string or null. Despite the name this is the effective MODEL identifier (e.g. `gpt-4o`); the sentinel `fallback` means the deterministic template ran and no LLM was involved. **This response is NOT gated on the `draft_provenance_ui` feature flag** — that flag gates rendering in the rep UI only; gating the API on it would make a UI flag silently change API semantics across orgs. |
| PATCH | /api/v1/drafts/{id} | drafts.write | Edit a draft's subject and/or bodyThe same edit the product makes: the draft is edited in place (its id does not change), goes back to review (an approval is cleared, so approve it again), the text it replaces is kept as a revision, and the edit runs the same content check as an edit in the app (an edit that check refuses is a 400 naming the problems). `expectedVersion` is required: send the `version` GET /drafts/{id} returned. If the stored text changed since, nothing is saved and the answer is a 409: read the draft again and re-apply. A sent draft, one with a send scheduled (cancel it first), or an archived contact's draft is a 409. A key whose member neither owns the contact nor is a manager or admin gets a 403. |
| POST | /api/v1/drafts/{id}/approve | drafts.write | Approve a generated draftThe draft must be in one of: DRAFT_GENERATED, EDITED, REGENERATED. Approving a draft that is already APPROVED and still the live version of its step returns 200 again (safe to retry). Approving does NOT send: approved drafts are sent later by the dispatcher, gated by the org's verified email transport. On a PAUSED sequence (a reply, a removed rep, a stood-down contact) the approve is recorded, as in the app, but the sequence stays paused and nothing is scheduled until it is resumed. FLAGGED DRAFTS: when the draft carries claims the grounding checks could not support, the first approve is a 400 whose detail names the flags. Review them, then repeat with `{ "acknowledgeFlags": true }` to approve anyway. Other 400s: the contact cannot be emailed (suppressed, bounced, do-not-contact), the draft is a superseded version, or it is not in an approvable status. Note the response `status` is lowercase `approved`; the draft itself reads `APPROVED` on GET. CONCURRENCY: approve and reject are decided atomically; when the draft leaves review while this request runs (a concurrent reject, edit or regenerate wins) the answer is 409 `conflict` and nothing is written: re-read the draft. The body is optional; a body that is not JSON, not an object, or has fields other than acknowledgeFlags is a 400. Emits `draft.approved` (resource EmailDraft) on a real approval; an idempotent re-approve emits nothing. |
| POST | /api/v1/drafts/{id}/regenerate | drafts.write | Rewrite one draft from an instruction (returns a NEW draft id)PAID: one model generation plus two claim-check passes, through the same rewrite the app's Regenerate runs (same guardrails, draft allowance, cooldown). Costs 1 action, charged to the key's user, when it returns a reviewable draft (a `heldFallback` version is free). SYNCHRONOUS: typically 10 to 25 seconds; set a client timeout of at least 120 seconds. THE ID CHANGES: a rewrite writes a new version of the step (`versionNumber` + 1) with a new id, returned as `draftId` (also in the Location header); the old id becomes a superseded version, hidden from GET /drafts unless `includeSuperseded=true`. Approve, reject, edit and send the new id. Access as in the app: the key's user must be a manager (ADMIN, OWNER, MANAGER) or own the draft's contact; a VIEWER is refused. 409 when the step is approved, queued or sent, or when a rewrite of the same step is running or finished seconds ago (wait, then retry). 402 when the draft allowance is used up (`draft-allowance-exhausted`) or an actions-metered organization is at zero (`actions-exhausted`). 502 `generation-failed` when the model run failed; the draft is unchanged. `heldFallback: true` means the new version fell back to a template and is held (status FAILED), not reviewable. |
| POST | /api/v1/drafts/{id}/reject | drafts.write | Reject a generated draftThe draft must be in one of: DRAFT_GENERATED, EDITED, REGENERATED. The response `status` is lowercase `rejected`; the draft itself reads `REJECTED` on GET. Optional body: `reasonCode`, one of the review queue's reasons, is stored on the decision and echoed; `reasonText` is free text kept only with `reasonCode: "other"` (text alone means "other"). An unknown reasonCode or field is a 400. Concurrency as on approve: when the draft leaves review while this request runs (a concurrent approve wins) the answer is 409 and nothing is written. Emits `draft.rejected` (resource EmailDraft, payload carries reasonCode / reasonText when given). Rejecting a draft that is already REJECTED is 200 with `unchanged: true`; nothing is written and no event is emitted. The stored reason is read back on GET /drafts/{id} as `decision`. |
| POST | /api/v1/drafts/batch/approve | drafts.write | Approve up to 100 drafts in one callEach row is POST /drafts/{id}/approve for `draftId`, through the same approval engine, one draft at a time (approving does not send; each approval emits its own draft.approved event). Row status: `approved`, or `unchanged` (already approved, nothing written). A row error is what the single endpoint answers: 404 not in your organization, 400 not reviewable or flagged sentences not acknowledged (send `acknowledgeFlags: true` on that row), 409 the draft changed while approving. Answers 200 whatever each row did: `results` holds one entry per row, in order (`index` is the row's position in `rows`), and `summary` counts them. A row that fails carries `error`, the problem the single-row endpoint would have answered for it (`status`, `type`, `title`, `detail`); the other rows still run. A body that is not `{ "rows": [...] }`, has no rows, or holds more than the cap is a 400 before any write. Rate limit: the call counts one unit per 10 rows (rounded up) against the key's per-minute limit. With an `Idempotency-Key`, a repeat with the same key and body answers with the first call's results (header `Idempotent-Replayed: true`) and writes nothing; the same key with a different body is a 422. |
| POST | /api/v1/drafts/batch/reject | drafts.write | Reject up to 100 drafts in one callEach row is POST /drafts/{id}/reject for `draftId`, with the same optional `reasonCode` / `reasonText` rules, through the same engine (each rejection emits its own draft.rejected event). Row status: `rejected`, or `unchanged` (already rejected, nothing written). A row error: 404 not in your organization, 400 a bad row or not reviewable, 409 the draft changed while rejecting. Answers 200 whatever each row did: `results` holds one entry per row, in order (`index` is the row's position in `rows`), and `summary` counts them. A row that fails carries `error`, the problem the single-row endpoint would have answered for it (`status`, `type`, `title`, `detail`); the other rows still run. A body that is not `{ "rows": [...] }`, has no rows, or holds more than the cap is a 400 before any write. Rate limit: the call counts one unit per 10 rows (rounded up) against the key's per-minute limit. With an `Idempotency-Key`, a repeat with the same key and body answers with the first call's results (header `Idempotent-Replayed: true`) and writes nothing; the same key with a different body is a 422. |
| POST | /api/v1/drafts/{id}/send | drafts.send | Enqueue an APPROVED draft's sequence for real sendingReal-mail action, double-gated: requires the `drafts.send` scope AND the org-level `v1_2_api_send` feature flag (off by default everywhere — 404s when off). Only APPROVED drafts qualify. Enqueues via the same collision/suppression/qualification guards as the in-app send path; the dispatch cron then sends under the full gate battery (EMAIL_PROVIDER kill-switch, per-org transport verification, mailbox/domain daily caps, suppression). Safe to retry: a draft that is already QUEUED or SENT (the first call succeeded) answers 200 with `queued: false`, `queuedJobCount: 0` and a skippedReason, and nothing is queued again. Other statuses are a 400. |
Sequences
Multi-step outreach state per contact.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/sequences | sequences.read | List contact sequences |
| POST | /api/v1/sequences | sequences.write | Enqueue a sequence for a contact + templateAsync — the GenerationJob worker takes it from there. Optional productId is checked against the multi-product collision guard (an active different-product sequence on the contact refuses with 400). |
| GET | /api/v1/sequences/{id} | sequences.read | Get a single contact sequenceSame shape as an item of GET /sequences. An id from another organization is a 404, exactly like an unknown one. |
Deals
Pipeline records. Stage transitions run through the methodology gate, so a move can be refused with the unmet checklist attached. Notes, calls and meetings can be logged on a deal and read back.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/deals | deals.read | List deals |
| POST | /api/v1/deals | deals.write | Create a dealRoutes through the same createDeal() invariants as the in-app path (DealContact link, initial DealStageHistory row). Name the stage with EITHER stageId (opaque id) or stage (slug) - both are validated against the org's stages, and two keys naming different stages is a 400. If neither is given, the org's default stages are seeded on first use and the first stage is assigned. ownerSalesPersonId, when given, must be an active, non-read-only member of your organization (else 400, with the same message whether the id is unknown, another organization's, or deactivated); omitted, the key's acting salesperson owns the deal. |
| GET | /api/v1/deals/{id} | deals.read | Get a single deal (Zapier "find_deal" + generic detail lookup)Field shape is identical to a GET /deals list item — both serialize from the same shared shape. |
| PATCH | /api/v1/deals/{id} | deals.write | Update a deal (whitelisted fields)Zapier "Update Deal" action. Allowed fields: name, valueCents, probability, closeDate, primaryContactId (must be a live contact in your org, else 400), ownerSalesPersonId. Unknown fields are a 400. Stage changes are NOT accepted here — use POST /deals/{id}/stage. primaryContactId: null is refused (400) on a deal that has a primary contact: a primary is replaced, never removed (send another contact's id). ownerSalesPersonId reassigns the deal (for example a departing rep's pipeline): it needs a key whose member is a manager, admin or owner (else 403, checked against the member's role now, not when the key was made; naming the current owner again is a no-op that needs no role), and the new owner must be an active, non-read-only member of your organization, else 400; the change is audited as an owner change and the deal's open next-best-action cards move to the new owner. A valueCents or closeDate change emits deal.amount_changed / deal.close_date_changed; a re-save of the same value emits nothing. |
| POST | /api/v1/deals/{id}/stage | deals.write | Transition a deal to a new pipeline stageRuns the same stage-gate + eventing engine as the in-app Kanban move (transitionDealStageWithEffects). A blocked/gated transition returns 422 with the gate detail so a Zap can surface the unmet checklist. Moves of one deal are serialised: a move that loses a race with another move of the same deal is a 409 (reload and retry), so two opposite closes can never both succeed. A move into the stage the deal is already in is a no-op: 200 with changed: false, no history row and no event. Closing sets the deal's probability and forecastCategory (Won: 100 and closed_won; Lost: 0 and closed_lost) and its closeDate to the day it closed (the optional closeDate; else today, unless the planned close date is already in the past, which is kept; the planned date is restored if the deal is reopened); reopening clears probability (the stage default applies) and forecastCategory. |
| GET | /api/v1/deals/{id}/activities | deals.read | List the touches (notes, calls, meetings) logged on a dealNewest first by occurredAt, cursor paginated. Lists touches logged through this API and through the product (the contact page's Log a call / meeting when it lands on this deal). Only these three kinds are listed: stage moves are GET /deals/{id}/history, and the engine's own entries on the same ledger are not touches. 404 when the deal is not in your organization. |
| POST | /api/v1/deals/{id}/activities | deals.write | Log a touch (note, call, or meeting) on a dealRecords a human activity on the deal through the same ledger the in-app Activity tab and the next-best-action engine read, so a deal you work from your own tooling never reads as "no activity". Pass occurredAt to backfill a touch that happened earlier; omitted, it is now. Honours Idempotency-Key: a replay returns the activity the first call created. As in the product, a new touch is written to the audit log (as the key's member, via this key), a call or meeting notifies colleagues watching the account, and the next-best-action cards it fulfils are cleared at once. |
| GET | /api/v1/deals/{id}/history | deals.read | Get a deal's pipeline-stage transition logCursor paginated (createdAt DESC, id DESC), newest-first. 404 when the deal is not in the API key's org — checked before touching DealStageHistory at all, so a foreign deal's history is never distinguishable from a foreign deal that does not exist. Stage names are resolved server-side via a join so a caller never needs a second call just to turn an id into a label. |
Deal stages
The pipeline stage ladder configured for your organization. Read this before mapping stages from another system.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/deal-stages | deals.read | List the org's pipeline stagesEnumerates the pipeline stages for the calling org, ordered by sortOrder. This is how you discover a valid stageSlug: POST /v1/deals/{id}/stage expects one and GET /v1/deals?stageSlug= filters on one, but before this route existed a caller had to guess, and a wrong guess is a 400. Unpaginated, because stages are a small admin-curated set per org (same precedent as GET /v1/me). Returns an empty list for an org that has never opened the deals board; it does NOT seed the default stages as a side effect of being called. |
Inbound leads
Captured inbound leads, and the doors to convert one into a deal or mark it spam.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/leads | contacts.read | List inbound leadsDiscovery for the two lead verbs. POST /v1/leads/{id}/convert and /spam both existed with no way to find an id, so they were unreachable to any caller that had not already read one out of the database. Newest first by capturedAt. Excludes erased leads. Deliberately omits payload (the raw unbounded form submission), ipHash (a fraud signal, not business data) and qualSnapshot (internal scoring detail already summarised by fitScore and intentConfidence). |
| POST | /api/v1/leads/{id}/convert | contacts.write | Convert an inbound lead on demandThin wrapper over the same engine fn the rep-facing "Convert now" button calls — skips waiting for the inbound-lead-sweep cron. PAID: a conversion runs the intent classifier and the qualification extractor (model calls) and is metered as a qualification action. A SPAM or DUPLICATE lead is not converted: 200 with `outcome: "skipped"` and null `contactId`/`dealId`. A lead already converted returns 200 `already_converted` with its contact. A lead tombstoned by a GDPR erasure is 404; a lead whose address was erased is 409. |
| POST | /api/v1/leads/{id}/spam | contacts.write | Mark an inbound lead as spamRefuses on an already-CONVERTED lead (would orphan a Contact/Deal); idempotent on an already-SPAM lead. |
Action items
The work queue surfaced to reps, and the verbs that resolve an item.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/action-items | actions.read | List action items (the NBA / work queue)Cursor paginated (createdAt DESC, id DESC), mirroring GET /drafts exactly (same cursor encoding, same problem+json shape). The read half that makes POST /action-items/{id}/{verb} reachable in practice — without a list, a headless caller has no way to learn an item's id. |
| GET | /api/v1/action-items/{id} | actions.read | Get a single action item404 when the id does not exist OR belongs to a different org — org-scoping makes cross-org lookup structurally impossible, not just policy. |
| POST | /api/v1/action-items/{id}/{verb} | contacts.write | Resolve an NBA / action itemverb ∈ complete | reopen | dismiss | snooze. Same explicit-auth engine the /sales/actions queue + the Slack route use. Own-item-unless-manager (403); org-scoped (404). |
Actions
A single dispatch endpoint for capability actions. The `action` enum is generated from the same registry the endpoint reads.
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /api/v1/actions | any valid key | Generic headless action endpoint (execute a capability-registry action against a contact or deal)The endpoint requires no fixed scope itself — each `action` carries its OWN required scope (checked per-call against the key's scopes), so 403 vs 404 stays precise. Routes through the SAME governed dispatcher a custom rule uses: reversible auto_capable actions execute; advisory-locked or flag-gated ones degrade to an advisory ActionItem rather than a silent no-op. Actions: **pause_sequence** (scope `sequences.write`, entity `contact`): Pause the contact's active sequence. **resume_sequence** (scope `sequences.write`, entity `contact`): Resume the contact's paused sequence — regenerates the remaining unsent steps for approval (does not send). **snooze_contact** (scope `contacts.write`, entity `contact`): Snooze the contact for N days (pauses outreach until then). **tag_contact** (scope `contacts.write`, entity `contact`): Add a tag/label to the contact. **add_to_suppression** (scope `suppression.write`, entity `contact`): Suppress the contact's email (stops all outreach). **handoff_to_ae** (scope `contacts.write`, entity `contact`): Open an SDR→AE handoff package for the contact. **draft_reply** (scope `drafts.write`, entity `contact`): Draft a grounded reply to the contact's latest inbound (queued for review). |
Messages
Inbound and outbound conversation history.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/messages | drafts.read | List inbound conversation messages (replies from contacts) |
Suppression
Addresses and domains that must never be contacted. Read uses the same scope as write, deliberately: the list is sensitive.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/suppression | suppression.write | Look up or list suppression entries`?email=<address>` — direct lookup, the main reason this route exists ("is this address suppressed?"). Returns `{ email, suppressed, entry }`. `suppressed` is the same answer the send path gives: it is true when the address has its own entry, when a DOMAIN entry covers its domain (then `entry` is that domain entry, `level: "DOMAIN"`), and for an erased address, which is kept only as a hash (then `entry` is null). `limit` and `cursor` are ignored in this mode. Without `?email=`, returns the org's suppression list, cursor paginated (createdAt DESC, id DESC), same shape as GET /drafts. There is no dedicated `suppression.read` scope, so this reuses `suppression.write` — every existing caller of this endpoint already holds it, and a lookup is strictly less sensitive than the write it is already trusted with. |
| POST | /api/v1/suppression | suppression.write | Suppress an email address or a whole domain (stops all future outreach in this org)Zapier "Suppress Email" action. Idempotent — re-suppressing an already-suppressed address is a no-op (200, `alreadySuppressed: true`; the existing entry's reason and source are never rewritten). Always recorded with reason enum MANUAL_BLOCK (the caller's `reason` is stored as a free-text label in `source`, not asserted as UNSUBSCRIBE/HARD_BOUNCE/COMPLAINT provenance). Scheduled sends to the address are stood down in the same transaction (`cancelledJobs`): as in the app, the block is reversible, so their drafts go back to review (DRAFT_GENERATED, approval withdrawn) rather than to a final state, and the approval queue hides them while the entry stands. DOMAIN: send `{ "level": "DOMAIN", "domain": "example.com" }` to block every address at a domain (the app's "Suppress domain"). It cancels every colleague's scheduled sends there, so it needs an ADMIN, OWNER or MANAGER key (else 403). Wildcards are not supported: `*@example.com` is a 400 that points at the DOMAIN form, never a stored entry. Emits `suppression.added` (resource SuppressionEntry) when an entry was created; a replay emits nothing. |
| DELETE | /api/v1/suppression | suppression.write | Remove a suppression entry (lift a suppression)Honors a re-consent. A query param (`?email=`) is used instead of a `/suppression/{email}` path segment because email addresses contain `+`, `.`, and other characters that are easy to mis-encode in a path segment. Idempotent — removing an address with no suppression entry still returns 200 (replay-safe public automation, see commit c174d9e0). Emits `suppression.removed` only when a row was actually deleted, so a replay never double-fires the event for subscribers. |
| POST | /api/v1/suppression/batch | suppression.write | Suppress up to 500 addresses or domains in one callEach row is a POST /suppression body (`{ email, reason? }` or `{ level: "DOMAIN", domain, reason? }`) and runs through the same code: one transaction per row that also stands the address's scheduled sends down, and a suppression.added event for each entry actually added. Row status: `suppressed` (`id` is the new entry) or `existing` (already suppressed, unchanged). A DOMAIN row needs a key created by an ADMIN, OWNER or MANAGER: from any other key that row is a 403 error and the rest still run. Answers 200 whatever each row did: `results` holds one entry per row, in order (`index` is the row's position in `rows`), and `summary` counts them. A row that fails carries `error`, the problem the single-row endpoint would have answered for it (`status`, `type`, `title`, `detail`); the other rows still run. A body that is not `{ "rows": [...] }`, has no rows, or holds more than the cap is a 400 before any write. Rate limit: the call counts one unit per 10 rows (rounded up) against the key's per-minute limit. With an `Idempotency-Key`, a repeat with the same key and body answers with the first call's results (header `Idempotent-Replayed: true`) and writes nothing; the same key with a different body is a 422. |
Webhooks
Manage endpoints, inspect deliveries, replay a failed one, and rotate a signing secret. One scope covers the whole surface.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/webhooks | webhooks.manage | List registered webhook endpoints |
| POST | /api/v1/webhooks | webhooks.manage | Register a new webhook endpointReturns the endpoint with its signingSecret (`whs_*`); store the secret, GET never returns it. IDEMPOTENT ON URL: posting a URL that already has an ACTIVE endpoint in your org does not create a second one. It answers 200 with that endpoint, REPLACES its eventTypes with the ones you sent, and returns its current signing secret (or a fresh one, with `secretRotated: true`, if the stored secret cannot be recovered). This is how subscription-style clients such as Zapier re-subscribe. To change the secret, call POST /webhooks/{id}/rotate-secret. |
| GET | /api/v1/webhooks/{id} | webhooks.manage | Get a single webhook endpoint |
| PATCH | /api/v1/webhooks/{id} | webhooks.manage | Update a webhook endpoint (url, eventTypes, or status)Reactivating (status → active) resets consecutiveFailures to 0. |
| DELETE | /api/v1/webhooks/{id} | webhooks.manage | Hard-delete a webhook endpoint |
| POST | /api/v1/webhooks/{id}/test-deliver | webhooks.manage | Fire a synthetic test event at the endpointA live-fire smoke test — NOT persisted as a WebhookDelivery row. 10s timeout. |
| GET | /api/v1/webhooks/{id}/deliveries | webhooks.manage | List recent deliveries for a webhook endpoint |
| POST | /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay | webhooks.manage | Re-queue a webhook delivery for redeliveryInserts a NEW WebhookDelivery row (attempt reset to 1, scheduledAt = now); the original delivery row is left untouched. |
| GET | /api/v1/webhooks/{id}/deliveries/{deliveryId} | webhooks.manage | Fetch a single webhook deliveryReturns one delivery (status, httpStatus, errorMessage, timestamps) by id — completes the deliveries resource for headless debugging. |
| POST | /api/v1/webhooks/{id}/rotate-secret | webhooks.manage | Rotate a webhook signing secretGenerates a fresh signing secret for the endpoint (id/url/subscriptions/history preserved) and returns the new plaintext ONCE. |
| GET | /api/v1/webhooks/events | webhooks.manage | Discover the canonical subscribable event catalogReturns the SAME event list the webhook picker + dispatcher use, plus the payload envelope version — so a client (or a Zapier app trigger dropdown) can enumerate valid `eventTypes` without hand-copying docs. |
Events
The org event stream, the same events that drive webhooks and chat notifications.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/events | events.read | Paginated platform event log |
Usage
Consumption counters for the current billing period, and the actions balance for an org metered by actions.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/usage | events.read | Get the org's current-period usage + plan limitsCheck `billingModel` first. `actions`: the org is metered by its pooled actions balance (`actions.balance`); the draft allowance does not apply, so `drafts.limit` and `drafts.remaining` are null. `drafts`: the monthly draft allowance applies; `actions` is still returned (for information) when the org holds action lots. |
Analytics
Funnel counts (deals and wins attributed to outbound only, not every deal in the org), the open pipeline with quota coverage for a quarter (every open deal, whatever door it came in by), and the holdout lift readout. The pipeline and lift endpoints return the same numbers the product shows, computed the same way.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/analytics/funnel | analytics.read | The outbound to pipeline funnel over a bounded windowGET-only, aggregate-only — no per-contact or per-person row is ever returned. Org-scoped by construction: every read goes through the calling key's organization, so a key minted by one org can never observe another's numbers. `from` and `to` are REQUIRED UTC calendar dates (YYYY-MM-DD), both inclusive. There is deliberately no default window and no unbounded mode: an omitted bound would be a scan of the org's whole history, and a defaulted rolling window would make the same URL return different numbers on every run, which a BI job cannot pin. Maximum window: 366 days (one inclusive calendar year). Every response, and every 400, carries the header `X-SealDeal-Analytics-Max-Window-Days: 366`. Responses also carry `X-SealDeal-Cache: hit|miss` — the serialized payload is memoized in-process for 5 minutes per (org, from, to). Contract notes that matter for reading the numbers correctly: every numeric field names its unit in the field name (`_count`, `_cents`, `_pct` 0-100, `_pp` percentage points) so there are no bare numbers a consumer cannot interpret. Rates are null when the denominator is 0, never 0 (a rep who sent nothing has an unknown reply rate, not a 0% one); counts and sums are honest zeros. Every rate names its own denominator (`repliedPerSequenceSent_pct`, not `replyRate`); `dealsPerSequenceStarted_pct` can legitimately exceed 100 (several deals may attribute to one sequence) and is deliberately not clamped. Attribution is sequence-grain and single-attribution, so `totals.dealsAttributed_count` is always <= `attribution.dealsCreatedAllSources_count` by design — the gap is pipeline outbound cannot honestly claim, not missing data. The same holds for wins: `dealsWon_count` and `wonValue_cents` count only outbound-attributed won deals, never all won deals in the org. `truncation.dealScanTruncated: true` means the engine hit its 5,000-deal read limit for the window and every attributed number is a floor, not a total. For deals and wins from EVERY source, read `bySource`: every deal created in the window, grouped by its entry path (`Deal.source`: outbound, crm_import, manual, api, inbound_form, offer, meeting, and `unknown` for a deal with no source recorded; every key is always present, zeros included). A deal is won when its current stage is a closed-won stage, the same rule as the funnel. `wonValue_cents` is in `bySource.baseCurrency` (the organization's currency); a won deal in another currency counts in `dealsWon_count` and `excludedOtherCurrency_count` but is never added to `wonValue_cents`. `bySource.totals.dealsCreated_count` equals `attribution.dealsCreatedAllSources_count`. These counts come from one aggregate query, so they are exact and are not subject to the 5,000-deal read limit. |
| GET | /api/v1/analytics/lift | analytics.read | The two holdout experiments ("is the AI working?")`ai_email_lift` (unit of analysis: account) and `ai_actions` (unit of analysis: deal). Takes NO parameters — passing `from` or `to` is a 400, not a silent ignore: a holdout experiment measures from each unit's assignment to now and withholds its verdict until a pre-registered analysis horizon, so an arbitrary window would produce something that looks like a lift result but is not the pre-registered analysis. Each arm reports `horizonDays_count`/`horizonAt` instead. `X-SealDeal-Cache: hit|miss`, 5-minute in-process memoization per org. Rates are null when the denominator (`assigned_count`) is 0, never 0. `lift_pp`/`ci95Low_pp`/`ci95High_pp` are real percentage points: the upstream LiftComparison fields are named "pp" but hold fractions (0.04 = +4pp) — this API converts them; anything reading the internal modules directly must not assume the same units. |
| GET | /api/v1/analytics/pipeline | analytics.read | Open pipeline, weighted value and quota coverage for a quarterGET-only, aggregate-only (member names, no contact data). Org-wide: deals owned by a deactivated member or by nobody are in every total and reported as their own bucket (`open.notActiveOwner`, and the `ownerSalesPersonId: null` row of `byOwner`). `period` is REQUIRED: a calendar quarter `YYYY-Qn` (quotas are quarterly). There is no default, for the same reason the funnel has no default window: a defaulted period changes its numbers when the quarter turns. What the period changes: `quotaCoverage` and the `quota_cents` / `wonInPeriod_cents` / `attainment_pct` of each `byOwner` row are for that quarter (the quota rows set for it, deals won in it, and the open deals due by its end, overdue ones included). `open`, `byStage` and the open figures in `byOwner` are the CURRENT open book (every deal not in a closed stage, whatever its close date) as of `asOf`. These are the numbers /sales/forecast shows, computed the same way (the org-wide forecast read). `weighted_cents` is each deal's value times its AI win probability, or its own probability when the model cannot score it (`open.unscorable` says how many). They are NOT the outbound-attributed deal numbers of GET /analytics/funnel; do not add or compare the two. Every cents figure is in `baseCurrency`; open deals in another currency are left out of every sum and counted in `excludedOtherCurrency_count`, never converted. Field names carry their unit (`_count`, `_cents`, `_pct` 0-100, `_multiple`); a figure that needs a quota is null without one. `X-SealDeal-Cache: hit|miss`: the body is memoized in-process for 5 minutes per (org, period). |
Public templates
The public outreach template gallery. Needs no scope.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/public-templates | any valid key | List public sequence templates (no auth required)Public, unauthenticated endpoint (CORS: any origin, no API key, not rate limited per key). Returns active PublicTemplate rows. Optional filters: useCase (alias useCaseSlug), industry, sequenceStep. |
Questionnaires
Pre-sales RFPs and security questionnaires: upload the buyer file (a presigned upload in three steps), have it answered from your knowledge base and approved answer library, read the answers with their sources, and download the answered file. Answering is paid in AI actions and runs in the background; poll the questionnaire for its status. Needs the pre-sales module and the presales.read or presales.write scope.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/questionnaires | presales.read | List questionnairesPre-sales questionnaires (RFPs, security questionnaires), newest first (createdAt DESC, id DESC), cursor paginated. Needs the pre-sales module on the plan (403 `presales-not-entitled`) and the presales.view capability for the key's creator (403 `forbidden`). |
| POST | /api/v1/questionnaires | presales.write | Start a questionnaire upload (presigned URL)Step 1 of 3. Upload is two requests because the file goes straight to storage, never through this API: (1) this call checks the file and returns `uploadUrl`, a presigned URL valid for 10 minutes; (2) PUT the file bytes to `uploadUrl` with the header `Content-Type` set to exactly the `contentType` you declared (no Authorization header); the body must be exactly `sizeBytes` bytes, because the URL is signed for that length: storage refuses any other length with 403 and stores nothing (send one plain body with a Content-Length, not a chunked stream); (3) POST /questionnaires/{id}/confirm with the same `fileName`. Nothing is stored and nothing counts against the 200-questionnaire limit until confirm. Accepted files: .xlsx, .csv and .docx up to 25 MB (contentType one of the four listed, and `fileName` must end in .xlsx, .csv or .docx, any case). One file imports at most 500 questions; rows past that are left out and counted in `skippedRows` on the questionnaire. A `dealId` must be a deal the key's creator can see and work (owner, collaborator, or a team/all pre-sales scope); `productId` a product in this workspace. Both are checked again at confirm. 503 `storage-not-configured` when this deployment has no file storage. |
| GET | /api/v1/questionnaires/{id} | presales.read | Get a questionnaire (status and counters)The poll target. After confirm, `status` moves importing to ready (or failed, with `importError`); after POST /questionnaires/{id}/answer it moves answering to review, and the counters show progress. Rows the import left out (duplicates, unreadable cells, past the 500-question limit) are counted in `skippedRows` with a sample in `skippedSample`. Poll every 10 to 30 seconds. 404 for an unknown, deleted or another organization's questionnaire. |
| POST | /api/v1/questionnaires/{id}/confirm | presales.write | Confirm a questionnaire upload (starts the import)Step 3 of 3, after the file was PUT to `uploadUrl`. Creates the questionnaire in status `importing` and parses the file in the background (one row per question). Poll GET /questionnaires/{id} (the Location) until `status` is `ready`, or `failed` with `importError`. `fileName` must be the one sent to POST /questionnaires. 409 when no file was PUT (or the 10-minute URL expired first), or when this upload was already confirmed. 400 when the stored file is over 25 MB, whatever `sizeBytes` declared: the file is deleted and nothing is created; start again with a smaller file. |
| POST | /api/v1/questionnaires/{id}/answer | presales.write | Answer a questionnaire (PAID, asynchronous)PAID: spends AI actions. Starts answering every pending question in the background and returns 202 at once. Answers a person already approved in this organization's answer library are reused first at no charge; the rest are drafted from the knowledge base in batches of ten, each batch charged to the AI actions allowance. A question the knowledge base does not support is not guessed: it comes back `abstained` with an `abstainReason`. Poll GET /questionnaires/{id} until `status` is `review`, then read GET /questionnaires/{id}/items. Long questionnaires continue in the background across several minutes. `retryAbstained: true` first puts the abstained questions back in the queue (after you added knowledge). `knowledgeBaseEmpty: true` warns that nothing is indexed yet, so most questions will abstain. 402 `actions-exhausted` when the allowance left cannot cover one batch; 409 while another run holds it, while it is still importing, when it failed to import, or when no question is waiting. |
| GET | /api/v1/questionnaires/{id}/items | presales.read | List a questionnaire's questions and answersThe questions in the buyer's row order (rowIndex ascending), cursor paginated, each with its answer, status, confidence, citations and, for one the model would not answer, `abstainReason`. 404 for an unknown, deleted or another organization's questionnaire. |
| GET | /api/v1/questionnaires/{id}/export | presales.write | Export the answered questionnaire (presigned download)Builds the answer file and returns `downloadUrl`, a presigned URL valid for 5 minutes (the bytes are never in this response). Only APPROVED answers are written, into the buyer's own workbook for an .xlsx or .csv upload; a .docx questionnaire comes back as a tabular .xlsx. `approvedAnswersWritten` counts the answers placed; `unplaced` those that could not be placed in the original file. Building it marks the questionnaire `exported`, as the app does, so it needs presales.write. Call again for a fresh URL. 409 when it has no questions yet; 503 `storage-not-configured` when this deployment has no file storage. |
Webhooks
Register an endpoint with POST /api/v1/webhooks (or from /admin/integrations/webhooks), naming the event types to subscribe to. Retrieve the catalogue of available types from GET /api/v1/webhooks/events.
Deliveries arrive as a stable JSON envelope:
{
"apiVersion": "2026-07-21",
"id": "evt_2a7f9c00example",
"type": "deal.won",
"createdAt": "2026-07-21T10:14:00.000Z",
"organizationId": "org_example",
"data": { "dealId": "d_1", "dealName": "Acme, Platform" }
}Each delivery carries an X-Outbound-Signature header in the form t=<unix-seconds>,v1=<hex>. Verify it by computing HMAC-SHA256("<t>." + rawBody, signingSecret) and comparing in constant time. The signing secret is returned when the endpoint is registered (registering the same URL again returns the current secret) and never by a GET. Rotate it with POST /api/v1/webhooks/:id/rotate-secret.
import crypto from 'node:crypto'
// rawBody: the exact bytes received, NOT JSON.stringify(parsedBody)
function verifySealDeal(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest()
const given = Buffer.from(parts.v1 ?? '', 'hex')
return given.length === expected.length && crypto.timingSafeEqual(given, expected)
}Rejecting a timestamp more than a few minutes old stops a captured delivery being replayed at you later. Failed deliveries are retried with backoff; after repeated failures the endpoint is marked dead and deliveries stop until you set it back to active. Inspect attempts with GET /api/v1/webhooks/:id/deliveries and replay an individual attempt if needed. The exact retry schedule, timeouts and every event's payload are in the webhooks guide.
Multi-tenancy
Every API key belongs to exactly one organization, and every endpoint scopes its queries by that key's organization. There is no cross-tenant access path: a key minted by one organization cannot read another's data even if it knows the other's resource ids.