SealDeal API
A REST API over your contacts, companies, lists, deals, sequences, drafts, action items, suppression list, and platform events, plus analytics and webhooks for event driven integrations. All 61 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.
Scopes
| Scope | Endpoints | Grants access to |
|---|---|---|
| actions.read | 2 | Action items |
| analytics.read | 2 | Analytics |
| contacts.erase | 1 | Contacts |
| contacts.read | 4 | Contacts, Inbound leads |
| contacts.write | 7 | Contacts, Inbound leads, Action items |
| deals.read | 7 | Companies, Deals, Deal stages |
| deals.write | 5 | Companies, Deals |
| drafts.read | 3 | Drafts, Messages |
| drafts.send | 1 | Drafts |
| drafts.write | 2 | Drafts |
| events.read | 2 | Events, Usage |
| lists.read | 2 | Lists |
| lists.write | 3 | Lists |
| sequences.read | 1 | Sequences |
| sequences.write | 2 | Contacts, Sequences |
| suppression.write | 3 | Suppression |
| webhooks.manage | 11 | Webhooks |
Rate limits
- 60 requests per minute per API key (default).
- 120 requests per 30 seconds (burst window) for short bursts.
- Response headers:
X-RateLimit-Limit,X-RateLimit-Reset. - On exhaustion: 429 Too Many Requests with a
Retry-Afterheader.
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=.
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. |
Contacts
The person records outreach is built from. Includes the research dossier 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 contact |
| 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. |
| 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`. |
| 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). 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. Emits `contact.erased` 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)Contact must have a linked Company with a domain. Runs synchronously and returns the freshly computed research. Rate-limited by the org research budget (429 when exceeded). |
| 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. |
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. |
| 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. |
Lists
Groups of contacts, and the door that generates drafts for all of them. 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 list |
| 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. |
| 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 list has ACTIVE/QUEUED/DRAFTED/APPROVED sequences, unless `?force=true`, which PAUSES those sequences first 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 — poll GET /drafts?status=DRAFT_GENERATED for results. Gated by the org outbound entitlement. |
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.
| 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 }`: `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[] }` or null for legacy/fallback rows that never got one. Null means "no report", NOT "clean". `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. |
| POST | /api/v1/drafts/{id}/approve | drafts.write | Approve a generated draftDraft must be in DRAFT_GENERATED status. Approving does NOT send — the dispatch cron sends approved drafts later, gated by the EMAIL_PROVIDER kill-switch + per-org verification. |
| POST | /api/v1/drafts/{id}/reject | drafts.write | Reject a generated draft |
| 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). |
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). |
Deals
Pipeline records. Stage transitions run through the methodology gate, so a move can be refused with the unmet checklist attached.
| 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. |
| 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. Stage changes are NOT accepted here — use POST /deals/{id}/stage. |
| 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. |
| 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. |
| 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 }` where `entry` is null when not suppressed. 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 (stops all future outreach in this org)Zapier "Suppress Email" action. Idempotent — re-suppressing an already-suppressed address is a no-op. 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). |
| 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. |
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 one-time signingSecret (`whs_*`) — store it immediately, it is never shown again. |
| 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.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/usage | events.read | Get the org's current-period usage + plan limits |
Analytics
Funnel counts, and the holdout lift readout. The lift endpoint returns 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. `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. |
| 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. |
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). Returns active PublicTemplate rows. Optional filters: useCase, industry, sequenceStep. |
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 once when the endpoint is created and can be rotated with POST /api/v1/webhooks/:id/rotate-secret.
Failed deliveries are retried with backoff. Inspect attempts with GET /api/v1/webhooks/:id/deliveries and replay an individual attempt if needed.
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.