{"openapi":"3.1.0","info":{"title":"SealDeal Public API","version":"1.0.0","description":"Read + write access to contacts, lists, drafts, deals, sequences, suppression, messages, action items, webhooks, and platform events. Auth via Bearer obk_live_* keys minted at /admin/integrations/api-keys. RATE LIMIT: 60 requests per 60 seconds per API key, on EVERY endpoint. Exceeding it returns 429 problem+json with a Retry-After header; X-RateLimit-* headers are on every response. This is a per-key ceiling, so a busy automation sharing one key across many workflows will hit it: mint a key per integration rather than reusing one. Note the two research endpoints also return 429 for a separate reason (research budget exhausted), which is not this limit."},"servers":[{"url":"https://sealdeal.ai/api/v1"}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"http","scheme":"bearer","bearerFormat":"obk_live_<32hex>","description":"Scopes: contacts.read, contacts.write, contacts.erase, lists.read, lists.write, drafts.read, drafts.write, events.read, webhooks.manage, deals.read, deals.write, sequences.read, sequences.write, suppression.write, drafts.send, actions.read, analytics.read. contacts.erase additionally requires the acting API key to be attributed to a MANAGER/ADMIN/OWNER — see POST /contacts/{id}/erase."}},"schemas":{"Problem":{"type":"object","properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"required":["type","title","status","detail"]},"Contact":{"type":"object","properties":{"id":{"type":"string"},"organizationId":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"},"company":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"name":{"type":"string","nullable":true},"domain":{"type":"string","nullable":true}}},"qualificationStatus":{"type":"string"},"fitScore":{"type":"integer","nullable":true},"urgencyScore":{"type":"integer","nullable":true},"list":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"name":{"type":"string"}}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"ContactList":{"type":"object","properties":{"id":{"type":"string"},"organizationId":{"type":"string"},"name":{"type":"string"},"sourceType":{"type":"string"},"description":{"type":"string","nullable":true},"connectionPoint":{"type":"string","nullable":true},"goal":{"type":"string","nullable":true},"userId":{"type":"string"},"archivedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"Draft":{"type":"object","properties":{"id":{"type":"string"},"organizationId":{"type":"string"},"contactId":{"type":"string"},"contactName":{"type":"string"},"subject":{"type":"string"},"bodyPreview":{"type":"string"},"step":{"type":"integer"},"status":{"type":"string"},"sentAt":{"type":"string","format":"date-time","nullable":true},"openedAt":{"type":"string","format":"date-time","nullable":true},"repliedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"Deal":{"type":"object","description":"organizationId, name, ownerSalesPersonId, primaryContactId, companyId, stageId, createdAt, updatedAt. Returned identically by GET /deals (list items) and GET /deals/{id} (detail) — both serialize from the same shared shape, so a deal fetched by id sees exactly the fields it had in the list.","properties":{"id":{"type":"string"},"organizationId":{"type":"string"},"name":{"type":"string"},"valueCents":{"type":"integer","nullable":true},"probability":{"type":"integer","nullable":true,"description":"0-100, an integer-like percent. NEVER a 0-1 fraction — this has shipped as a bug twice in this repo."},"closeDate":{"type":"string","format":"date","nullable":true},"actualCloseDate":{"type":"string","format":"date","nullable":true},"ownerSalesPersonId":{"type":"string"},"primaryContactId":{"type":"string","nullable":true},"companyId":{"type":"string","nullable":true},"stageId":{"type":"string","nullable":true},"stageSlug":{"type":"string","nullable":true,"description":"Machine-readable stage id - exactly what POST /v1/deals/{id}/stage expects and what ?stageSlug= filters on. Null for a stage-less deal or a legacy stage row with no slug. Enumerate valid values via GET /v1/deal-stages."},"stageName":{"type":"string","nullable":true,"description":"Human-readable stage name."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"forecastCategory":{"type":"string","nullable":true,"enum":["lead","sql","qualified_pipeline","pipeline","best_case","strong_forecast","commit","closed_won","closed_lost","omitted"],"description":"Null when the deal has never been classified."},"readinessScore":{"type":"integer","nullable":true,"description":"0-100 MEDDPIC/qualification COMPLETENESS score, i.e. how much of the framework is filled in. A DIFFERENT metric from `probability` (win likelihood) — do not conflate the two. Null when qualification has never been recorded."},"lossReason":{"type":"string","nullable":true,"description":"Free text, set only when the deal closed lost."},"meddpic":{"type":"object","nullable":true,"additionalProperties":true,"description":"The org's qualification-framework blob, present ONLY when the caller passes `?include=meddpic` (same \"unknown/absent include tokens are ignored\" convention as `?include=evidence` on drafts and `?include=research` on contacts) on either GET /deals or GET /deals/{id}. Shape is framework-dependent (typically `{ fields, sources, confidence, frameworkKey }`) — treat as free-shape. `null` covers both \"no qualification recorded yet\" and \"the stored value was not an object\" (a malformed legacy JSONB row degrades to null rather than erroring). Key is entirely ABSENT from the response when `include` was not requested."}}},"Sequence":{"type":"object","description":"ContactSequence — the enqueued cadence linking a contact to a template.","properties":{"id":{"type":"string"},"organizationId":{"type":"string"},"contactId":{"type":"string"},"templateId":{"type":"string"},"status":{"type":"string","description":"e.g. NEW, ACTIVE, PAUSED, COMPLETED."},"anchorAtUtc":{"type":"string","format":"date-time","nullable":true},"currentStep":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"Message":{"type":"object","description":"An inbound conversation message (a reply from a contact). Bodies are truncated to a ~200-char preview.","properties":{"id":{"type":"string"},"contactId":{"type":"string","nullable":true},"direction":{"type":"string","enum":["INBOUND","OUTBOUND"]},"channel":{"type":"string"},"intent":{"type":"string","nullable":true},"intentConfidence":{"type":"number","nullable":true},"bodyPreview":{"type":"string"},"createdAt":{"type":"string","format":"date-time"}}},"SuppressionActionResult":{"type":"object","description":"Response envelope for POST /suppression and DELETE /suppression — a status echo, not the stored entry.","properties":{"email":{"type":"string"},"suppressed":{"type":"boolean"}}},"SuppressionEntry":{"type":"object","description":"A stored SuppressionEntry row, as returned by GET /suppression (lookup or list mode).","properties":{"id":{"type":"string"},"organizationId":{"type":"string"},"email":{"type":"string"},"reason":{"type":"string","description":"SuppressionReason enum, e.g. MANUAL_BLOCK, UNSUBSCRIBE, HARD_BOUNCE, COMPLAINT."},"source":{"type":"string","nullable":true,"description":"Free-text provenance label (e.g. the calling Zap name for an API-originated entry)."},"notes":{"type":"string","nullable":true},"expiresAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"WebhookEndpoint":{"type":"object","properties":{"id":{"type":"string"},"url":{"type":"string","format":"uri"},"eventTypes":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","paused","disabled"]},"description":{"type":"string","nullable":true},"lastDeliveryAt":{"type":"string","format":"date-time","nullable":true},"consecutiveFailures":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"},"signingSecret":{"type":"string","nullable":true,"description":"Only present in the POST (create) response — shown once, never returned again. Format `whs_<32hex>`."}}},"WebhookDelivery":{"type":"object","properties":{"id":{"type":"string"},"webhookEndpointId":{"type":"string"},"eventId":{"type":"string"},"attempt":{"type":"integer"},"httpStatus":{"type":"integer","nullable":true},"errorMessage":{"type":"string","nullable":true},"deliveredAt":{"type":"string","format":"date-time","nullable":true},"scheduledAt":{"type":"string","format":"date-time"},"createdAt":{"type":"string","format":"date-time"}}},"Event":{"type":"object","properties":{"apiVersion":{"type":"string","description":"The webhook/event envelope version (WEBHOOK_API_VERSION), e.g. \"2026-07-21\"."},"id":{"type":"string"},"type":{"type":"string","enum":["org.research_ttl_updated","rep.invited","rep.activated","list.uploaded","list.context_updated","draft.created","draft.approved","draft.sent","draft.failed","email.opened","email.clicked","email.bounced","email.complained","reply.received","reply.classified","lead.interested","contact.stage_changed","meeting.booked","inbound.lead_received","deal.won","deal.lost","deal.stage_changed","quota.warning","demo.run","demo.signup_clicked","grounding.would_fire","demo.abandoned","org.health_changed","team.member_added","team.member_removed","send.failed","sequence.started","sequence.completed","sequence.step_failed","research.completed","deal.created","email.unsubscribed","draft.pending_approval","sequence.paused","suppression.added","contact.tagged","contact.snoozed","handoff.created","action.resolved","suppression.removed","sequence.resumed","company.research_completed","contact.deleted","contact.erased","rule.triggered","rule.lead_qualified","rule.deal_at_risk","rule.objection","rule.hot_reply"]},"createdAt":{"type":"string","format":"date-time"},"organizationId":{"type":"string"},"data":{"type":"object","additionalProperties":true},"resource":{"type":"object","properties":{"type":{"type":"string"},"id":{"type":"string"}}}}},"Usage":{"type":"object","description":"Current billing-period SubscriptionUsage snapshot for the calling org.","properties":{"periodStart":{"type":"string","format":"date-time"},"periodEnd":{"type":"string","format":"date-time"},"plan":{"type":"object","properties":{"id":{"type":"string"},"draftsPerMonth":{"type":"integer"}}},"drafts":{"type":"object","properties":{"created":{"type":"integer"},"regenerated":{"type":"integer"},"limit":{"type":"integer"},"remaining":{"type":"integer"}}},"sends":{"type":"object","properties":{"attempted":{"type":"integer"}}},"llm":{"type":"object","properties":{"tokensUsed":{"type":"integer"},"costCents":{"type":"integer"}}}}},"ActionItemVerbResult":{"type":"object","description":"Response envelope for POST /action-items/{id}/{verb}.","properties":{"actionId":{"type":"string"},"status":{"type":"string","enum":["complete","reopen","dismiss","snooze"]}}},"ActionItem":{"type":"object","description":"A single ActionItem row, returned by GET /action-items (list) and GET /action-items/{id} (detail) — same projection, so a fetched-by-id item is identical to its list appearance.","properties":{"id":{"type":"string"},"actionType":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"priority":{"type":"string","enum":["urgent","high","medium","low"]},"status":{"type":"string","enum":["pending","completed","dismissed","expired"]},"entityType":{"type":"string","nullable":true},"entityId":{"type":"string","nullable":true},"salesPersonId":{"type":"string"},"aiGenerated":{"type":"boolean"},"expectedImpactCents":{"type":"integer","nullable":true},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","nullable":true},"completedAt":{"type":"string","format":"date-time","nullable":true},"dismissedAt":{"type":"string","format":"date-time","nullable":true},"dismissReason":{"type":"string","nullable":true},"snoozedUntilAt":{"type":"string","format":"date-time","nullable":true},"snoozedAt":{"type":"string","format":"date-time","nullable":true},"snoozeReason":{"type":"string","nullable":true}}},"Company":{"type":"object","description":"Single-company detail (GET /companies/{id}). Company is the account grain of the deal surface — `(organizationId, domainNormalized)` identity — which is why this reuses `deals.read` rather than a dedicated companies scope, matching the list route.","properties":{"id":{"type":"string"},"name":{"type":"string","description":"Falls back to the domain when the company has no name."},"domain":{"type":"string","nullable":true},"domainNormalized":{"type":"string","nullable":true},"industry":{"type":"string","nullable":true},"companySize":{"type":"string","nullable":true},"headquarters":{"type":"string","nullable":true},"tier":{"type":"string","nullable":true},"tags":{"type":"array","items":{"type":"string"}},"ownerSalesPersonId":{"type":"string","nullable":true},"researchUpdatedAt":{"type":"string","format":"date-time","nullable":true},"sourcesCount":{"type":"integer","description":"Type-gated count of researchSources (never a bare jsonb_array_length — see gotcha #14 below). 0, not an error, for a legacy row whose researchSources holds a malformed non-array JSONB value."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"CompanyResearch":{"type":"object","description":"`Company.researchSources`/`recentNews`/`painPoints` are array-shaped JSONB columns, but a legacy row can hold `{}` (an object) instead of an array — the exact column that 500'd /sales/companies for a whole org on 2026-07-17. Every array field below returns `[]` for a malformed row rather than erroring; never assume the array is well-formed on the wire even though the type below says so.","properties":{"companyId":{"type":"string"},"researchSources":{"type":"array","items":{"type":"object","additionalProperties":true}},"recentNews":{"type":"array","items":{"type":"object","additionalProperties":true}},"painPoints":{"type":"array","items":{"type":"object","additionalProperties":true}},"updatedAt":{"type":"string","format":"date-time"},"ttlDays":{"type":"integer"},"isFresh":{"type":"boolean"}}},"Citation":{"type":"object","description":"One per-sentence draft citation, resolved against the contact's research sources. See GET /drafts/{id}?include=evidence for the honesty rules around `resolved`.","properties":{"text":{"type":"string","description":"The factual sentence, verbatim from the draft body."},"sourceIndex":{"type":"integer","description":"1-based index into the contact's research source list, as cited."},"resolved":{"type":"boolean","description":"False when sourceIndex no longer points at a resolvable source. A real state, not an error — see the route description."},"sourceUrl":{"type":"string","nullable":true},"sourceTitle":{"type":"string","nullable":true},"sourceDomain":{"type":"string","nullable":true},"sourceSnippet":{"type":"string","nullable":true}}},"FunnelBucket":{"type":"object","description":"One funnel bucket (totals, or one row of byProduct/byList). Every rate names its own denominator and is null (never 0) when that denominator is 0 — see GET /analytics/funnel for the full contract notes.","properties":{"key":{"type":"string","nullable":true},"label":{"type":"string","nullable":true,"description":"Human name of the grouping key (Product/ContactList name); null for `totals` or a deleted referent."},"sequencesStarted_count":{"type":"integer"},"sequencesSent_count":{"type":"integer"},"sequencesReplied_count":{"type":"integer"},"sequencesWithMeeting_count":{"type":"integer"},"dealsAttributed_count":{"type":"integer"},"dealsWon_count":{"type":"integer"},"wonValue_cents":{"type":"integer"},"openValue_cents":{"type":"integer"},"sentPerSequenceStarted_pct":{"type":"number","nullable":true},"repliedPerSequenceSent_pct":{"type":"number","nullable":true},"meetingPerSequenceSent_pct":{"type":"number","nullable":true},"dealsPerSequenceStarted_pct":{"type":"number","nullable":true,"description":"Can legitimately exceed 100 — several deals may attribute to one sequence — and is deliberately not clamped."},"wonPerDealAttributed_pct":{"type":"number","nullable":true},"ratesTrusted":{"type":"boolean","description":"False when this bucket started fewer than the minimum sample size — rates are still reported but are directional only."}}},"LiftBucket":{"type":"object","properties":{"assigned_count":{"type":"integer"},"endpoints":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","description":"\"meeting\"/\"positive\" for the email arm; \"won\"/\"advanced\" for the actions arm."},"hits_count":{"type":"integer"},"perAssigned_pct":{"type":"number","nullable":true,"description":"Null when assigned_count is 0, never 0."}}}}}},"DealStageHistoryEntry":{"type":"object","properties":{"id":{"type":"string"},"dealId":{"type":"string"},"fromStageId":{"type":"string","nullable":true,"description":"Null (together with fromStageName) for the row written at deal creation — there is no \"from\" stage."},"fromStageName":{"type":"string","nullable":true},"toStageId":{"type":"string"},"toStageName":{"type":"string","nullable":true},"reason":{"type":"string","nullable":true},"transitionedBy":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"GenerationJobStatus":{"type":"object","description":"Response envelope for POST /lists/{id}/generate — enqueue is async; poll GET /drafts?status=DRAFT_GENERATED for results.","properties":{"listId":{"type":"string"},"generationQueued":{"type":"boolean"},"generationRunning":{"type":"boolean","description":"Present only on the idempotent no-op path (a run was already in flight)."},"chunkCount":{"type":"integer","description":"Present only when a new run was just enqueued."},"totalContacts":{"type":"integer","description":"Present only when a new run was just enqueued."}}}},"parameters":{"Cursor":{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque pagination cursor. Pass the `nextCursor` from the previous response."},"Limit":{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":200},"description":"Page size. Default 50, max 200."}}},"paths":{"/me":{"get":{"summary":"Get current API key + org info","description":"No scope required — any valid key works. Useful for integrations to sanity-check their auth wiring.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"apiKey":{"type":"object"},"organization":{"type":"object"}}}}}}}}},"/contacts":{"get":{"summary":"List contacts","security":[{"ApiKeyAuth":["contacts.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"listId","in":"query","schema":{"type":"string"}},{"name":"qualificationStatus","in":"query","schema":{"type":"string"}},{"name":"q","in":"query","schema":{"type":"string"},"description":"Search by name or email."},{"name":"email","in":"query","schema":{"type":"string"},"description":"Exact-match (normalized) email filter — used by the Zapier \"Find Contact\" search action."},{"name":"includeDeleted","in":"query","schema":{"type":"string","enum":["true"]},"description":"W6 — pass `true` to include soft-deleted contacts (default excludes them). This is the ONLY way to see a deleted contact over the API: GET /contacts/{id} has no override and always 404s for one, so an integration polling a known id stops seeing the row."}],"responses":{"200":{"description":"Paginated contact list. Each item gains two ALWAYS-PRESENT fields since W6, both null for every live contact so the default response is otherwise unchanged: `deletedAt` (soft-deleted, reversible) and `erasedAt` (GDPR-erased, unrecoverable). GET /contacts/{id} does NOT gain these — it 404s on a deleted contact, so they would always be null there.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/Contact"},{"type":"object","properties":{"deletedAt":{"type":"string","format":"date-time","nullable":true},"erasedAt":{"type":"string","format":"date-time","nullable":true}}}]}},"nextCursor":{"type":"string","nullable":true}}}}}}}},"post":{"summary":"Create a contact","security":[{"ApiKeyAuth":["contacts.write"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","listId"],"properties":{"email":{"type":"string"},"listId":{"type":"string"},"name":{"type":"string"},"title":{"type":"string"},"company":{"type":"object","properties":{"name":{"type":"string"},"domain":{"type":"string"}}},"qualificationStatus":{"type":"string"},"fitScore":{"type":"integer","minimum":0,"maximum":100},"urgencyScore":{"type":"integer","minimum":0,"maximum":100}}}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"listId not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/contacts/{id}":{"get":{"summary":"Get single contact","security":[{"ApiKeyAuth":["contacts.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"include","in":"query","schema":{"type":"string"},"description":"Comma-separated; only `research` is recognised today (unknown/absent tokens are ignored, not rejected). Adds a `research` key: `{ facts, sources, updatedAt, ttlDays, isFresh } | null` — null when no ContactResearch row has been cached yet (NOT a 404, the contact still exists). Same shape as GET /contacts/{id}/research minus the redundant top-level contactId. Absent means the key is omitted entirely, not null; the default response is otherwise byte-identical to before."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"summary":"Update a contact (whitelisted fields)","description":"Zapier \"Update Contact\" action. Allowed fields: name, title, phone, notes, leadSource, companyId, qualificationStatus, fitScore, urgencyScore.","security":[{"ApiKeyAuth":["contacts.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"title":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"leadSource":{"type":"string","nullable":true},"companyId":{"type":"string","nullable":true},"qualificationStatus":{"type":"string"},"fitScore":{"type":"integer","minimum":0,"maximum":100,"nullable":true},"urgencyScore":{"type":"integer","minimum":0,"maximum":100,"nullable":true}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"updated":{"type":"boolean"}}}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"summary":"Soft-delete a contact (reversible)","description":"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`.","security":[{"ApiKeyAuth":["contacts.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"},"deletedAt":{"type":"string","format":"date-time"},"undoAvailableUntil":{"type":"string","format":"date-time","description":"Advisory only — see the operation description."}}}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/contacts/{id}/erase":{"post":{"summary":"Permanently erase a contact (GDPR Art. 17, IRREVERSIBLE)","description":"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.\n\nBody (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).\n\nReturns 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.\n\nErasure 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.\n\nEmits `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.\n\nMost callers want DELETE /contacts/{id} instead (reversible soft delete) — use this endpoint only for an actual data-subject erasure request.","security":[{"ApiKeyAuth":["contacts.erase"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["requesterRef","reason"],"properties":{"requesterRef":{"type":"string","minLength":1,"maxLength":200,"description":"Data-subject request reference (ticket id, DSAR reference). Persisted as the erasure evidence trail."},"reason":{"type":"string","minLength":1,"maxLength":500}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"erased":{"type":"boolean"},"erasedAt":{"type":"string","format":"date-time"},"alreadyErased":{"type":"boolean"},"note":{"type":"string","description":"States the Art. 19 honest boundary — see the operation description."}}}}}},"400":{"description":"Missing/blank requesterRef or reason, or a non-JSON body","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"The key is attributed to a non-admin role (contacts.erase scope alone is not sufficient — see the operation description)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"ERASURE_EMAIL_HASH_SECRET is unset or shorter than 32 characters — erasure is refused rather than performed with a weak tombstone","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/contacts/{id}/research":{"get":{"summary":"Get cached research dossier (L4) for a contact","security":[{"ApiKeyAuth":["contacts.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"404":{"description":"Contact or research not found"}}},"post":{"summary":"Trigger a fresh research pipeline run for a contact (bypasses cache)","description":"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).","security":[{"ApiKeyAuth":["contacts.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string"},"contactId":{"type":"string"},"status":{"type":"string"},"contactAssessment":{"type":"object","nullable":true},"signalReasons":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"No linked company domain","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Daily research budget exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/contacts/{id}/pause-sequence":{"post":{"summary":"Pause a contact's active sequence(s)","description":"Zapier \"Pause Sequence\" action. Pauses every ACTIVE/QUEUED/DRAFTED/APPROVED ContactSequence for the contact.","security":[{"ApiKeyAuth":["sequences.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"contactId":{"type":"string"},"pausedCount":{"type":"integer"}}}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/lists":{"get":{"summary":"List contact lists","security":[{"ApiKeyAuth":["lists.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"includeArchived","in":"query","schema":{"type":"string","enum":["1","0"]}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ContactList"}},"nextCursor":{"type":"string","nullable":true}}}}}}}},"post":{"summary":"Create a contact list","security":[{"ApiKeyAuth":["lists.write"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"sourceType":{"type":"string","enum":["CONFERENCE","WARM_LEAD","COLD","REFERRAL","OTHER"],"default":"OTHER"},"description":{"type":"string","maxLength":400},"connectionPoint":{"type":"string","maxLength":250},"goal":{"type":"string","maxLength":150},"useCaseTemplateId":{"type":"string"},"productId":{"type":"string"}}}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactList"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/lists/{id}":{"get":{"summary":"Get single contact list","description":"W6 — 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.","security":[{"ApiKeyAuth":["lists.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactList"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Soft-delete a list and its memberships","description":"W6 — 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.","security":[{"ApiKeyAuth":["lists.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"force","in":"query","schema":{"type":"string","enum":["true"]},"description":"Pass `true` to pause in-flight sequences first instead of returning 409."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"},"deletedAt":{"type":"string","format":"date-time"},"alreadyDeleted":{"type":"boolean"},"membershipsRemoved":{"type":"integer"},"sequencesPaused":{"type":"integer"}}}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"List has in-flight sequences — retry with ?force=true","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/lists/{id}/generate":{"post":{"summary":"Trigger AI draft generation for every contact in a list","description":"Idempotent 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.","security":[{"ApiKeyAuth":["lists.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationJobStatus"}}}},"400":{"description":"List has no contacts","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Outbound not entitled on this plan","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/drafts":{"get":{"summary":"List drafts","security":[{"ApiKeyAuth":["drafts.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"status","in":"query","schema":{"type":"string"}},{"name":"contactId","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Draft"}},"nextCursor":{"type":"string","nullable":true}}}}}}}}},"/drafts/{id}":{"get":{"summary":"Get single draft (includes full body)","description":"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 }`:\n\n`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`.\n\n`unresolvedCitationCount` — integer, how many of `citations` have `resolved: false`.\n\n`guardrailReport` — `{ clean, attempts, repaired[], advisories[] }` or null for legacy/fallback rows that never got one. Null means \"no report\", NOT \"clean\".\n\n`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.\n\n**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.","security":[{"ApiKeyAuth":["drafts.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"include","in":"query","schema":{"type":"string"},"description":"Comma-separated; only `evidence` is recognised today."}],"responses":{"200":{"description":"OK. See description above for the additional `?include=evidence` fields.","content":{"application/json":{"schema":{"type":"object","properties":{"citations":{"type":"array","items":{"$ref":"#/components/schemas/Citation"}},"unresolvedCitationCount":{"type":"integer"},"guardrailReport":{"type":"object","nullable":true,"properties":{"clean":{"type":"boolean"},"attempts":{"type":"integer"},"repaired":{"type":"array","items":{"type":"string"}},"advisories":{"type":"array","items":{"type":"string"}}}},"provider":{"type":"string","nullable":true}}}}}},"404":{"description":"Not found"}}}},"/drafts/{id}/approve":{"post":{"summary":"Approve a generated draft","description":"Draft 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.","security":[{"ApiKeyAuth":["drafts.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"draftId":{"type":"string"},"status":{"type":"string","enum":["approved"]},"allApproved":{"type":"boolean"},"contactId":{"type":"string"}}}}}},"400":{"description":"Draft not in DRAFT_GENERATED status","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/drafts/{id}/reject":{"post":{"summary":"Reject a generated draft","security":[{"ApiKeyAuth":["drafts.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"draftId":{"type":"string"},"status":{"type":"string","enum":["rejected"]},"contactId":{"type":"string"}}}}}},"400":{"description":"Draft not in DRAFT_GENERATED status","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/drafts/{id}/send":{"post":{"summary":"Enqueue an APPROVED draft's sequence for real sending","description":"Real-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).","security":[{"ApiKeyAuth":["drafts.send"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"draftId":{"type":"string"},"queued":{"type":"boolean","description":"True ONLY when queuedJobCount > 0. A skip (contact removed, suppressed, do-not-contact, already in pipeline, every step already sent) returns 200 with queued=false and a skippedReason - the request was understood, and nothing was enqueued."},"queuedJobCount":{"type":"integer","description":"How many SendQueueJob rows were created. enqueueSequence enqueues ALL approved steps of the sequence, not just this draft."},"skippedReason":{"type":"string","nullable":true,"description":"Why nothing was enqueued, or null. Always present."}}}}}},"400":{"description":"Draft not APPROVED, or not part of a sequence","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Outbound not entitled on this plan","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found, or the org has not enabled the v1_2_api_send flag","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/companies":{"get":{"summary":"List companies","description":"Company 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.","security":[{"ApiKeyAuth":["deals.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"search","in":"query","schema":{"type":"string"},"description":"Case-insensitive name substring / domain prefix filter."}],"responses":{"200":{"description":"Paginated company list.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","description":"Falls back to the domain when the company has no name."},"domain":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"}}}},"nextCursor":{"type":"string","nullable":true}}}}}}}}},"/companies/{id}":{"get":{"summary":"Get a single company","description":"A foreign / non-existent company 404s, matching GET on contacts/deals.","security":[{"ApiKeyAuth":["deals.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Company"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/companies/{id}/research":{"get":{"summary":"Get a company's cached research dossier","description":"Mirrors GET /contacts/{id}/research applied to the account grain (Company) instead of the person grain (Contact).","security":[{"ApiKeyAuth":["deals.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanyResearch"}}}},"404":{"description":"Company not in org, or no research computed yet (researchUpdatedAt is null)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"summary":"Trigger a fresh company-seeded research pipeline run (bypasses cache)","description":"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.","security":[{"ApiKeyAuth":["deals.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string"},"companyId":{"type":"string"},"status":{"type":"string","enum":["completed"]},"researchSources":{"type":"array","items":{"type":"object","additionalProperties":true}},"recentNews":{"type":"array","items":{"type":"object","additionalProperties":true}},"painPoints":{"type":"array","items":{"type":"object","additionalProperties":true}},"updatedAt":{"type":"string","format":"date-time","nullable":true}}}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Daily research budget exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/deal-stages":{"get":{"summary":"List the org's pipeline stages","description":"Enumerates 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.","security":[{"ApiKeyAuth":["deals.read"]}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"slug":{"type":"string","nullable":true,"description":"Null on a legacy stage row predating slugs."},"name":{"type":"string"},"sortOrder":{"type":"integer"},"category":{"type":"string","enum":["active","closed_won","closed_lost"]},"isTerminal":{"type":"boolean"}}}}}}}}},"403":{"description":"Missing the deals.read scope"}}}},"/deals":{"get":{"summary":"List deals","security":[{"ApiKeyAuth":["deals.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"stageSlug","in":"query","schema":{"type":"string"}},{"name":"minValueCents","in":"query","schema":{"type":"integer"}},{"name":"maxValueCents","in":"query","schema":{"type":"integer"}},{"name":"include","in":"query","schema":{"type":"string"},"description":"Comma-separated; only `meddpic` is recognised today (unknown/absent tokens are ignored, not rejected — the same convention as `?include=evidence` on drafts and `?include=research` on contacts). Adds the `meddpic` key to every Deal in `data`. Absent means the key is omitted entirely, not null."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Deal"}},"nextCursor":{"type":"string","nullable":true}}}}}}}},"post":{"summary":"Create a deal","description":"Routes through the same createDeal() invariants as the in-app path (DealContact link, initial DealStageHistory row). If stageId is omitted, the org's default stages are seeded on first use and the first stage is assigned.","security":[{"ApiKeyAuth":["deals.write"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"valueCents":{"type":"integer","minimum":0},"probability":{"type":"integer","minimum":0,"maximum":100},"closeDate":{"type":"string","format":"date"},"primaryContactId":{"type":"string"},"companyId":{"type":"string"},"ownerSalesPersonId":{"type":"string"},"stageId":{"type":"string"}}}}}},"responses":{"200":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Deal"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/deals/{id}":{"get":{"summary":"Get a single deal (Zapier \"find_deal\" + generic detail lookup)","description":"Field shape is identical to a GET /deals list item — both serialize from the same shared shape.","security":[{"ApiKeyAuth":["deals.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"include","in":"query","schema":{"type":"string"},"description":"Comma-separated; only `meddpic` is recognised today. Same convention as GET /deals."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Deal"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"summary":"Update a deal (whitelisted fields)","description":"Zapier \"Update Deal\" action. Allowed fields: name, valueCents, probability, closeDate, primaryContactId. Stage changes are NOT accepted here — use POST /deals/{id}/stage.","security":[{"ApiKeyAuth":["deals.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"valueCents":{"type":"integer","minimum":0,"nullable":true},"probability":{"type":"integer","minimum":0,"maximum":100,"nullable":true},"closeDate":{"type":"string","format":"date","nullable":true},"primaryContactId":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"updated":{"type":"boolean"}}}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/deals/{id}/stage":{"post":{"summary":"Transition a deal to a new pipeline stage","description":"Runs 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.","security":[{"ApiKeyAuth":["deals.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["stage"],"properties":{"stage":{"type":"string","description":"Target stage slug."},"reason":{"type":"string"},"overrideReason":{"type":"string"},"reasonChips":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"dealId":{"type":"string"},"stage":{"type":"string"}}}}}},"400":{"description":"Unknown stage slug / disallowed transition / reason required","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Deal not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Stage gate not met — carries gate/gateMode/needsOverride.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/deals/{id}/history":{"get":{"summary":"Get a deal's pipeline-stage transition log","description":"Cursor 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.","security":[{"ApiKeyAuth":["deals.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DealStageHistoryEntry"}},"nextCursor":{"type":"string","nullable":true}}}}}},"404":{"description":"Deal not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/sequences":{"get":{"summary":"List contact sequences","security":[{"ApiKeyAuth":["sequences.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"contactId","in":"query","schema":{"type":"string"}},{"name":"status","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Sequence"}},"nextCursor":{"type":"string","nullable":true}}}}}}}},"post":{"summary":"Enqueue a sequence for a contact + template","description":"Async — 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).","security":[{"ApiKeyAuth":["sequences.write"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["contactId","templateId"],"properties":{"contactId":{"type":"string"},"templateId":{"type":"string"},"anchorAtUtc":{"type":"string","format":"date-time"},"productId":{"type":"string"}}}}}},"responses":{"200":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sequence"}}}},"400":{"description":"Bad request / product collision","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Contact, template, or product not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/suppression":{"post":{"summary":"Suppress an email address (stops all future outreach in this org)","description":"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).","security":[{"ApiKeyAuth":["suppression.write"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string"},"reason":{"type":"string","description":"Free-text label, e.g. the Zap name."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuppressionActionResult"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"get":{"summary":"Look up or list suppression entries","description":"`?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.","security":[{"ApiKeyAuth":["suppression.write"]}],"parameters":[{"name":"email","in":"query","schema":{"type":"string"},"description":"Direct lookup mode. Omit for list mode."},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"OK. Lookup mode: `{ email, suppressed, entry: SuppressionEntry|null }`. List mode: `{ data: SuppressionEntry[], nextCursor }`.","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"email":{"type":"string"},"suppressed":{"type":"boolean"},"entry":{"allOf":[{"$ref":"#/components/schemas/SuppressionEntry"}],"nullable":true}}},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SuppressionEntry"}},"nextCursor":{"type":"string","nullable":true}}}]}}}},"400":{"description":"Invalid cursor","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"summary":"Remove a suppression entry (lift a suppression)","description":"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.","security":[{"ApiKeyAuth":["suppression.write"]}],"parameters":[{"name":"email","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK. Always `{ email, suppressed: false }`, whether or not an entry existed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuppressionActionResult"}}}},"400":{"description":"Missing email query parameter","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/messages":{"get":{"summary":"List inbound conversation messages (replies from contacts)","security":[{"ApiKeyAuth":["drafts.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"contactId","in":"query","schema":{"type":"string"}},{"name":"intent","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"nextCursor":{"type":"string","nullable":true}}}}}}}}},"/events":{"get":{"summary":"Paginated platform event log","security":[{"ApiKeyAuth":["events.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"eventType","in":"query","schema":{"type":"string"},"description":"Single event type or comma-separated list — see components.schemas.Event.properties.type.enum for the full catalog (also GET /webhooks/events)."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}},"nextCursor":{"type":"string","nullable":true}}}}}}}}},"/usage":{"get":{"summary":"Get the org's current-period usage + plan limits","security":[{"ApiKeyAuth":["events.read"]}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Usage"}}}}}}},"/analytics/funnel":{"get":{"summary":"The outbound to pipeline funnel over a bounded window","description":"GET-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.\n\n`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).\n\nContract 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.","security":[{"ApiKeyAuth":["analytics.read"]}],"parameters":[{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date"},"description":"UTC calendar date YYYY-MM-DD, inclusive."},{"name":"to","in":"query","required":true,"schema":{"type":"string","format":"date"},"description":"UTC calendar date YYYY-MM-DD, inclusive. Maximum window from `from`: 366 days."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"window":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"fromInclusive":{"type":"string","format":"date-time"},"toExclusive":{"type":"string","format":"date-time"},"days_count":{"type":"integer"},"maxDays_count":{"type":"integer"}}},"attribution":{"type":"object","properties":{"model":{"type":"string","enum":["sequence-grain-single-attribution"]},"dealsCreatedAllSources_count":{"type":"integer"},"dealsAttributedToOutbound_count":{"type":"integer"},"minSampleForTrustedRates_count":{"type":"integer"}}},"totals":{"$ref":"#/components/schemas/FunnelBucket"},"byProduct":{"type":"array","items":{"$ref":"#/components/schemas/FunnelBucket"}},"byList":{"type":"array","items":{"$ref":"#/components/schemas/FunnelBucket"}},"truncation":{"type":"object","properties":{"bucketsPerDimension_max":{"type":"integer"},"byProductTruncated":{"type":"boolean"},"byListTruncated":{"type":"boolean"},"dealScan_max":{"type":"integer"},"dealScanTruncated":{"type":"boolean","description":"True means every attributed number is a FLOOR, not a total."}}}}}}}},"400":{"description":"`from`/`to` missing, not YYYY-MM-DD (timestamps are rejected on purpose), `to` earlier than `from`, or window longer than 366 days.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/analytics/lift":{"get":{"summary":"The two holdout experiments (\"is the AI working?\")","description":"`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.\n\nRates 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.","security":[{"ApiKeyAuth":["analytics.read"]}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"overallPhase":{"type":"string","enum":["not_started","running","concluded"]},"experiments":{"type":"array","items":{"type":"object","properties":{"arm":{"type":"string","enum":["email","actions"]},"experimentKey":{"type":"string"},"unitOfAnalysis":{"type":"string","enum":["account","deal"]},"phase":{"type":"string"},"primaryEndpointLabel":{"type":"string"},"treatment":{"$ref":"#/components/schemas/LiftBucket"},"holdout":{"$ref":"#/components/schemas/LiftBucket"},"minSamplePerArm_count":{"type":"integer"},"accruedPerArm_count":{"type":"integer"},"horizonDays_count":{"type":"integer"},"horizonAt":{"type":"string","format":"date-time","nullable":true},"verdict":{"type":"object","nullable":true,"properties":{"significant":{"type":"boolean"},"lift_pp":{"type":"number"},"ci95Low_pp":{"type":"number","nullable":true},"ci95High_pp":{"type":"number","nullable":true}}}}}},"accountArmContamination":{"type":"object","nullable":true,"properties":{"heldOutAccounts_count":{"type":"integer"},"manuallyTouchedAccounts_count":{"type":"integer"},"manualEvents_count":{"type":"integer"},"manuallyTouchedPerHeldOut_pct":{"type":"number","nullable":true}}}}}}}},"400":{"description":"from/to passed (unsupported on this endpoint)","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/public-templates":{"get":{"summary":"List public sequence templates (no auth required)","description":"Public, unauthenticated endpoint (CORS: any origin). Returns active PublicTemplate rows. Optional filters: useCase, industry, sequenceStep.","security":[],"parameters":[{"name":"useCase","in":"query","schema":{"type":"string"}},{"name":"industry","in":"query","schema":{"type":"string"}},{"name":"sequenceStep","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object"}}}}}}}}}},"/actions":{"post":{"summary":"Generic headless action endpoint (execute a capability-registry action against a contact or deal)","description":"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).","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action","entity"],"properties":{"action":{"type":"string","enum":["pause_sequence","resume_sequence","snooze_contact","tag_contact","add_to_suppression","handoff_to_ae","draft_reply"]},"entity":{"type":"object","required":["type","id"],"properties":{"type":{"type":"string","enum":["contact","deal"]},"id":{"type":"string"}}},"args":{"type":"object","additionalProperties":true,"description":"Action-specific args — see the per-action arg spec in the description above (e.g. snooze_contact needs {days, reason?})."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string"},"entity":{"type":"object","properties":{"type":{"type":"string"},"id":{"type":"string"}}},"status":{"type":"string","enum":["executed","gated","queued_for_review"]},"gatedReason":{"type":"string"}}}}}},"400":{"description":"Bad request / action targets a different entity kind","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Key is missing the scope this action requires","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Unknown action, or entity not found in the key's org","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/action-items":{"get":{"summary":"List action items (the NBA / work queue)","description":"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.","security":[{"ApiKeyAuth":["actions.read"]}],"parameters":[{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","completed","dismissed","expired"]}},{"name":"priority","in":"query","schema":{"type":"string","enum":["urgent","high","medium","low"]}},{"name":"entityType","in":"query","schema":{"type":"string"}},{"name":"entityId","in":"query","schema":{"type":"string"}},{"name":"salesPersonId","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ActionItem"}},"nextCursor":{"type":"string","nullable":true}}}}}},"400":{"description":"Invalid status / priority / cursor","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/action-items/{id}":{"get":{"summary":"Get a single action item","description":"404 when the id does not exist OR belongs to a different org — org-scoping makes cross-org lookup structurally impossible, not just policy.","security":[{"ApiKeyAuth":["actions.read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionItem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/action-items/{id}/{verb}":{"post":{"summary":"Resolve an NBA / action item","description":"verb ∈ complete | reopen | dismiss | snooze. Same explicit-auth engine the /sales/actions queue + the Slack route use. Own-item-unless-manager (403); org-scoped (404).","security":[{"ApiKeyAuth":["contacts.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"verb","in":"path","required":true,"schema":{"type":"string","enum":["complete","reopen","dismiss","snooze"]}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","description":"Used by dismiss (optional)."},"days":{"type":"integer","description":"Required by snooze."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActionItemVerbResult"}}}},"400":{"description":"Unknown verb / snooze missing days","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — not the owner and not a manager. Authorization only: a state conflict returns 409, never 403.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Action item not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"State conflict — the verb is not legal from the item current status (dismiss is terminal; snooze requires a pending item). Distinct from 403 on purpose: retry after refetching, do not escalate.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/leads":{"get":{"summary":"List inbound leads","description":"Discovery 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).","security":[{"ApiKeyAuth":["contacts.read"]}],"parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["NEW","ROUTED","CONVERTED","SPAM","DUPLICATE"]}},{"name":"source","in":"query","schema":{"type":"string","enum":["web_form","chat","api"]}},{"name":"assignedSalesPersonId","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer"}},{"name":"cursor","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"source":{"type":"string"},"email":{"type":"string"},"name":{"type":"string","nullable":true},"title":{"type":"string","nullable":true},"companyName":{"type":"string","nullable":true},"domain":{"type":"string","nullable":true},"message":{"type":"string","nullable":true,"description":"The text the lead submitted - what a convert or spam decision actually turns on."},"intent":{"type":"string","nullable":true},"intentConfidence":{"type":"number","nullable":true},"fitScore":{"type":"integer","nullable":true},"status":{"type":"string"},"assignedSalesPersonId":{"type":"string","nullable":true},"convertedContactId":{"type":"string","nullable":true},"convertedDealId":{"type":"string","nullable":true},"capturedAt":{"type":"string","format":"date-time"},"routedAt":{"type":"string","format":"date-time","nullable":true},"convertedAt":{"type":"string","format":"date-time","nullable":true}}}},"nextCursor":{"type":"string","nullable":true}}}}}},"400":{"description":"Unknown status or source, or an undecodable cursor","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Missing the contacts.read scope"}}}},"/leads/{id}/convert":{"post":{"summary":"Convert an inbound lead on demand","description":"Thin wrapper over the same engine fn the rep-facing \"Convert now\" button calls — skips waiting for the inbound-lead-sweep cron.","security":[{"ApiKeyAuth":["contacts.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"leadId":{"type":"string"},"contactId":{"type":"string"},"dealId":{"type":"string","nullable":true},"fitScore":{"type":"integer","nullable":true},"intent":{"type":"string","nullable":true},"outcome":{"type":"string"}}}}}},"400":{"description":"Conversion failed","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Lead not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/leads/{id}/spam":{"post":{"summary":"Mark an inbound lead as spam","description":"Refuses on an already-CONVERTED lead (would orphan a Contact/Deal); idempotent on an already-SPAM lead.","security":[{"ApiKeyAuth":["contacts.write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"leadId":{"type":"string"},"status":{"type":"string","enum":["SPAM"]}}}}}},"400":{"description":"Lead already CONVERTED","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Lead not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/webhooks":{"get":{"summary":"List registered webhook endpoints","security":[{"ApiKeyAuth":["webhooks.manage"]}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}},"nextCursor":{"type":"null"}}}}}}}},"post":{"summary":"Register a new webhook endpoint","description":"Returns the one-time signingSecret (`whs_*`) — store it immediately, it is never shown again.","security":[{"ApiKeyAuth":["webhooks.manage"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","eventTypes"],"properties":{"url":{"type":"string","format":"uri"},"eventTypes":{"type":"array","items":{"type":"string","enum":["org.research_ttl_updated","rep.invited","rep.activated","list.uploaded","list.context_updated","draft.created","draft.approved","draft.sent","draft.failed","email.opened","email.clicked","email.bounced","email.complained","reply.received","reply.classified","lead.interested","contact.stage_changed","meeting.booked","inbound.lead_received","deal.won","deal.lost","deal.stage_changed","quota.warning","demo.run","demo.signup_clicked","grounding.would_fire","demo.abandoned","org.health_changed","team.member_added","team.member_removed","send.failed","sequence.started","sequence.completed","sequence.step_failed","research.completed","deal.created","email.unsubscribed","draft.pending_approval","sequence.paused","suppression.added","contact.tagged","contact.snoozed","handoff.created","action.resolved","suppression.removed","sequence.resumed","company.research_completed","contact.deleted","contact.erased","rule.triggered","rule.lead_qualified","rule.deal_at_risk","rule.objection","rule.hot_reply","*"]},"minItems":1}}}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"Bad request / disallowed url","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/webhooks/{id}":{"get":{"summary":"Get a single webhook endpoint","security":[{"ApiKeyAuth":["webhooks.manage"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"summary":"Update a webhook endpoint (url, eventTypes, or status)","description":"Reactivating (status → active) resets consecutiveFailures to 0.","security":[{"ApiKeyAuth":["webhooks.manage"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"eventTypes":{"type":"array","items":{"type":"string","enum":["org.research_ttl_updated","rep.invited","rep.activated","list.uploaded","list.context_updated","draft.created","draft.approved","draft.sent","draft.failed","email.opened","email.clicked","email.bounced","email.complained","reply.received","reply.classified","lead.interested","contact.stage_changed","meeting.booked","inbound.lead_received","deal.won","deal.lost","deal.stage_changed","quota.warning","demo.run","demo.signup_clicked","grounding.would_fire","demo.abandoned","org.health_changed","team.member_added","team.member_removed","send.failed","sequence.started","sequence.completed","sequence.step_failed","research.completed","deal.created","email.unsubscribed","draft.pending_approval","sequence.paused","suppression.added","contact.tagged","contact.snoozed","handoff.created","action.resolved","suppression.removed","sequence.resumed","company.research_completed","contact.deleted","contact.erased","rule.triggered","rule.lead_qualified","rule.deal_at_risk","rule.objection","rule.hot_reply","*"]}},"status":{"type":"string","enum":["active","paused","disabled"]}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"summary":"Hard-delete a webhook endpoint","security":[{"ApiKeyAuth":["webhooks.manage"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted."},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/webhooks/{id}/test-deliver":{"post":{"summary":"Fire a synthetic test event at the endpoint","description":"A live-fire smoke test — NOT persisted as a WebhookDelivery row. 10s timeout.","security":[{"ApiKeyAuth":["webhooks.manage"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"integer","nullable":true},"ms":{"type":"integer"},"error":{"type":"string"}}}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/webhooks/{id}/deliveries":{"get":{"summary":"List recent deliveries for a webhook endpoint","security":[{"ApiKeyAuth":["webhooks.manage"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"nextCursor":{"type":"string","nullable":true}}}}}},"404":{"description":"Not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/webhooks/{id}/deliveries/{deliveryId}/replay":{"post":{"summary":"Re-queue a webhook delivery for redelivery","description":"Inserts a NEW WebhookDelivery row (attempt reset to 1, scheduledAt = now); the original delivery row is left untouched.","security":[{"ApiKeyAuth":["webhooks.manage"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"deliveryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"replayed":{"type":"boolean"},"deliveryId":{"type":"string"}}}}}},"404":{"description":"Endpoint or delivery not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/webhooks/{id}/deliveries/{deliveryId}":{"get":{"summary":"Fetch a single webhook delivery","description":"Returns one delivery (status, httpStatus, errorMessage, timestamps) by id — completes the deliveries resource for headless debugging.","security":[{"ApiKeyAuth":["webhooks.manage"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"deliveryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"404":{"description":"Endpoint or delivery not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/webhooks/{id}/rotate-secret":{"post":{"summary":"Rotate a webhook signing secret","description":"Generates a fresh signing secret for the endpoint (id/url/subscriptions/history preserved) and returns the new plaintext ONCE.","security":[{"ApiKeyAuth":["webhooks.manage"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"signingSecret":{"type":"string"}}}}}},"404":{"description":"Endpoint not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/webhooks/events":{"get":{"summary":"Discover the canonical subscribable event catalog","description":"Returns 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.","security":[{"ApiKeyAuth":["webhooks.manage"]}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"apiVersion":{"type":"string"},"events":{"type":"array","items":{"type":"string","enum":["org.research_ttl_updated","rep.invited","rep.activated","list.uploaded","list.context_updated","draft.created","draft.approved","draft.sent","draft.failed","email.opened","email.clicked","email.bounced","email.complained","reply.received","reply.classified","lead.interested","contact.stage_changed","meeting.booked","inbound.lead_received","deal.won","deal.lost","deal.stage_changed","quota.warning","demo.run","demo.signup_clicked","grounding.would_fire","demo.abandoned","org.health_changed","team.member_added","team.member_removed","send.failed","sequence.started","sequence.completed","sequence.step_failed","research.completed","deal.created","email.unsubscribed","draft.pending_approval","sequence.paused","suppression.added","contact.tagged","contact.snoozed","handoff.created","action.resolved","suppression.removed","sequence.resumed","company.research_completed","contact.deleted","contact.erased","rule.triggered","rule.lead_qualified","rule.deal_at_risk","rule.objection","rule.hot_reply"]}}}}}}}}}}}}