{"openapi":"3.1.0","info":{"title":"Volanea API","version":"0.1.0","description":"The open email platform: transactional sending, marketing campaigns, and lifecycle automation over one contact graph.\n\n**Authentication** — pass an API key as `Authorization: Bearer <key>`. Three key types exist per project:\n`sk_…` (secret, full access — server-side only), `sk_test_…` (test — full access, renders and logs but never delivers),\nand `pk_…` (public — only accepted by `POST /v1/events`, safe to ship in client code).\n\n**Response envelope** — every 2xx body is `{ \"success\": true, \"data\": … }`. The schema shown for each operation below\ndescribes what goes in `data`, not the whole body. Cursor-paginated lists put the array in `data` and `nextCursor`\nbeside it: `{ \"success\": true, \"data\": [...], \"nextCursor\": \"1730000000000.6a6dd07e6afeb87b46b08bca\" }`.\n`nextCursor` is an opaque string — send it back as `cursor` unchanged, and never parse or synthesise one.\n\n**Errors** — every error is `{ \"success\": false, \"error\": \"<human-readable>\", \"code\": \"<machine-readable>\" }` with an\nappropriate HTTP status. Branch on `code`, never on the wording of `error`. Validation failures (422) add a `details`\narray naming each failing field by its dotted path.\n\n**Unknown request-body fields are rejected** — a body carrying a field this spec does not declare is a 422\n`validation_error`, with one `details` entry per unknown key (`code: \"unrecognized_keys\"`, `path` naming the key).\nUntil 2026-08 they were silently dropped, which is how `reply_to` for `replyTo`, a `cc` carried over from another\nprovider, and a misspelled segment `operator` all shipped to production looking like they had worked: the request\nanswered 200 and the behaviour the field was meant to buy simply never happened. Sending an unknown field now costs\nyou a failed request instead of a silent one. Query strings are the exception and stay permissive — a URL routinely\ncarries analytics and cache-busting params that have nothing to do with the endpoint.\n\n**Idempotency** — `POST /v1/send` and `POST /v1/send/batch` accept an `Idempotency-Key` header. A repeated key with the\nsame body replays the stored response, marked BOTH by the `X-Idempotent-Replay: true` response header and by\n`\"replay\": true` inside `data`. Read either; they are set together. (The header was promised here for years and never\nactually sent, so callers wrote against the body field — both are now part of the contract and neither will be\nremoved.) A repeated key with a *different* body is a 409 `idempotency_key_reused` — the request is neither sent nor\nreplayed, because silently returning the first response would discard the new message.\n\n**Template variables** — `{{name}}` placeholders are substituted in subjects, bodies, and preheaders wherever this\nspec says so. A placeholder may carry a fallback: `{{firstName | \"there\"}}` renders `there` when `firstName` is\nmissing, null, or the empty string. The quotes are required and must be double quotes. Without a fallback a missing\nvariable renders as nothing at all, which is how \"Hi ,\" reaches inboxes. Dotted paths (`{{data.plan}}`) resolve\ninto the contact's custom `data`; per-call `variables` win over contact fields of the same name.\n\n**Pagination** — list endpoints return `{ data, nextCursor }`; pass `nextCursor` back as `cursor` until it is `null`."},"servers":[{"url":"https://api.volanea.com","description":"Production"},{"url":"http://localhost:4100","description":"Local development"}],"tags":[{"name":"Send","description":"Transactional sending, with batching, scheduling, and idempotency."},{"name":"Events","description":"Product event ingestion (public key) and the event log."},{"name":"Contacts","description":"The contact graph: CRUD, timeline, bulk operations, CSV import/export."},{"name":"Emails","description":"The send log — every message across all sources."},{"name":"Templates","description":"Reusable content with versioning, rollback, and test sends."},{"name":"Domains","description":"Sending domains, DNS records, and verification."},{"name":"Segments","description":"Dynamic (condition-driven) and static audiences."},{"name":"Campaigns","description":"One-off broadcasts with scheduling, A/B subjects, and engagement stats."},{"name":"Workflows","description":"Event-triggered automation graphs and their executions."},{"name":"Webhooks","description":"Signed event deliveries to your endpoints, with retries and replay."},{"name":"Suppressions","description":"The do-not-send list: bounces, complaints, unsubscribes, and manual blocks."},{"name":"Stats","description":"Engagement aggregates: the project rollup and the per-workflow funnel."},{"name":"Verify","description":"Email address validation."},{"name":"Billing","description":"Usage, monthly caps, reputation, and invoices."},{"name":"Inbound","description":"Inbound mail ingestion (used by the SMTP relay)."},{"name":"Meta","description":"API metadata."}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Project API key. Use `sk_…` (secret) or `sk_test_…` (test) for server-side endpoints; `pk_…` (public) is accepted only by `POST /v1/events`."}},"schemas":{"Error":{"type":"object","description":"The error envelope returned by every non-2xx response. `error` is a human-readable string — branch on `code`, not on the wording of `error`, which may be reworded at any time.","properties":{"success":{"type":"boolean","enum":[false]},"error":{"type":"string","description":"Human-readable explanation. For `validation_error` it names the failing field."},"code":{"type":"string","description":"Machine-readable code, e.g. `unauthorized`, `not_found`, `validation_error`, `invalid_state`, `idempotency_key_reused`, `ambiguous_api_key`, `send_failed`."},"details":{"type":"array","description":"Present on `validation_error` (one entry per failing field) and on `send_failed`, where the single entry names the specific cause. `code` is the part to branch on — see the `details` note on `POST /v1/send`.","items":{"type":"object","properties":{"path":{"type":"string","description":"Dotted path into the request body, e.g. \"messages.2.to\". Empty string when the failure is not attributable to one field — which is the case for every `send_failed` entry."},"message":{"type":"string"},"code":{"type":"string","description":"The underlying validator's code (`invalid_string`, `unrecognized_keys`, `invalid_cursor`, …), or for `send_failed` the specific cause (`template_not_found`, …)."}},"required":["path","message","code"]}}},"required":["success","error","code"]},"SendMessage":{"type":"object","description":"A single message. Provide inline `html`/`text` content or a `templateId`; `subject` is required unless the template supplies one.","properties":{"to":{"description":"Recipient address (optionally with a display name), or an array of 1–50 recipients (each gets its own message).","oneOf":[{"type":"string","format":"email"},{"type":"object","properties":{"email":{"type":"string","format":"email"},"name":{"type":"string"}},"required":["email"]},{"type":"array","items":{"oneOf":[{"type":"string","format":"email"},{"type":"object","properties":{"email":{"type":"string","format":"email"},"name":{"type":"string"}},"required":["email"]}]},"minItems":1,"maxItems":50}]},"subject":{"type":"string","description":"Subject line. Supports `{{variable}}` placeholders, with fallbacks: `{{firstName | \"there\"}}`."},"html":{"type":"string","description":"HTML body. Supports `{{variable}}` placeholders, with fallbacks: `{{firstName | \"there\"}}`."},"text":{"type":"string","description":"Plain-text body (wrapped in `<pre>` when no HTML is given)."},"templateId":{"type":"string","description":"Send using a stored template instead of inline content."},"variables":{"type":"object","additionalProperties":{"oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]},"description":"Values for `{{variable}}` placeholders in the subject and body. A name you omit falls back to the same-named contact field, then to the placeholder's own `| \"default\"` if it has one, then to nothing."},"from":{"type":"string","format":"email","description":"From address. Must belong to a verified domain (unless in test mode). Defaults to the template's from address, or hello@ your first verified domain."},"fromName":{"type":"string"},"replyTo":{"type":"string","format":"email","description":"Sets a Reply-To header."},"headers":{"type":"object","additionalProperties":{"type":"string"},"description":"Custom email headers to include on the message."},"attachments":{"type":"array","maxItems":10,"items":{"type":"object","properties":{"filename":{"type":"string"},"content":{"type":"string","description":"Base64-encoded file content."},"contentType":{"type":"string","default":"application/octet-stream"}},"required":["filename","content"]},"description":"Up to 10 attachments per message. Total request size is capped at 10 MiB."},"sendAt":{"type":"string","format":"date-time","description":"Schedule delivery for a future time (ISO 8601 with offset). The message is stored `queued` with a `scheduledFor` — list the pending ones with `GET /v1/emails?status=scheduled`, stop one with `DELETE /v1/emails/{id}`."},"createContact":{"type":"boolean","description":"Set `false` to skip auto-creating a contact for unknown recipients (default `true`)."},"type":{"type":"string","enum":["transactional","marketing"],"description":"Marketing messages get the unsubscribe footer and skip contacts who have unsubscribed. Defaults to the template's type, or `transactional` for inline content — so promotional copy sent as inline `html` must set this explicitly or it bypasses the unsubscribe check."}},"required":["to"]},"SendResult":{"description":"Per-recipient outcome: either an accepted message or a skip reason.","oneOf":[{"type":"object","properties":{"id":{"type":"string","description":"Email id — use with `GET /v1/emails/{id}`."},"to":{"type":"string","format":"email"},"status":{"type":"string","enum":["sent","scheduled","queued"]}},"required":["id","to","status"]},{"type":"object","properties":{"to":{"type":"string","format":"email"},"skipped":{"type":"string","enum":["suppressed","unsubscribed","limit_reached","reputation_paused","disabled","free_tier_exceeded","plan_quota_exceeded"]}},"required":["to","skipped"]}]},"Contact":{"type":"object","properties":{"contactId":{"type":"string"},"projectId":{"type":"string"},"email":{"type":"string","format":"email"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"subscribed":{"type":"boolean"},"data":{"type":["object","null"],"additionalProperties":true,"description":"Arbitrary custom fields."},"locale":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["contactId","email","subscribed","createdAt","updatedAt"]},"Event":{"type":"object","properties":{"eventId":{"type":"string"},"projectId":{"type":"string"},"contactId":{"type":["string","null"]},"emailId":{"type":["string","null"]},"name":{"type":"string","description":"System events use reserved prefixes (\"email.\", \"contact.\", \"segment.\", \"workflow.\"); everything else is yours."},"data":{"type":["object","null"],"additionalProperties":true},"createdAt":{"type":"string","format":"date-time"}},"required":["eventId","name","createdAt"]},"TriggeredWorkflow":{"type":"object","description":"A workflow this event handed to the automation engine. `status` is `dispatched`, never `enrolled`: enrolment runs fire-and-forget after the response and can still be refused by the workflow's `reentry` policy. Confirm an actual enrolment with `GET /v1/workflows/{id}/executions`.","properties":{"workflowId":{"type":"string"},"name":{"type":"string"},"trigger":{"type":"string","description":"The workflow's trigger string that this event matched."},"status":{"type":"string","enum":["dispatched"]}},"required":["workflowId","name","trigger","status"]},"SkippedWorkflow":{"type":"object","description":"A workflow whose trigger matched but which cannot run in its current state. Reported separately so the two ways an event-driven integration silently does nothing — nothing matched, or the only match is not live — are distinguishable from the response alone.","properties":{"workflowId":{"type":"string"},"name":{"type":"string"},"trigger":{"type":"string"},"reason":{"type":"string","enum":["workflow_paused","workflow_draft"]}},"required":["workflowId","name","trigger","reason"]},"InboundMessage":{"type":"object","description":"A received message, as listed by `GET /v1/inbound`. The body is omitted — fetch `GET /v1/emails/{emailId}` for it.","properties":{"emailId":{"type":"string","description":"Use with `GET /v1/emails/{id}` to read the stored body."},"contactId":{"type":["string","null"],"description":"The sender, upserted as a contact at ingest."},"to":{"type":"string","description":"The recipient exactly as stored. Only the domain is lowercased — the local part keeps its case, because a Reply-To routing address (`ticket+AbC123XyZ@…`) carries a case-sensitive token."},"subject":{"type":"string"},"fromEmail":{"type":"string","format":"email"},"fromName":{"type":["string","null"]},"status":{"type":"string","enum":["received"]},"createdAt":{"type":"string","format":"date-time"}},"required":["emailId","to","subject","fromEmail","status","createdAt"]},"EmailSummary":{"type":"object","description":"A send-log row (the body is omitted from lists for size).","properties":{"emailId":{"type":"string"},"to":{"type":"string","format":"email"},"subject":{"type":"string"},"fromEmail":{"type":"string"},"fromName":{"type":["string","null"]},"source":{"type":"string","enum":["transactional","campaign","workflow","inbound"]},"status":{"type":"string","enum":["queued","sending","sent","delivered","opened","clicked","bounced","complained","failed","received","cancelled"]},"campaignId":{"type":["string","null"]},"workflowId":{"type":["string","null"]},"testMode":{"type":"boolean"},"opens":{"type":"integer"},"clicks":{"type":"integer"},"error":{"type":["string","null"]},"sentAt":{"type":["string","null"],"format":"date-time"},"scheduledFor":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"}},"required":["emailId","to","subject","source","status","createdAt"]},"Email":{"allOf":[{"$ref":"#/components/schemas/EmailSummary"},{"type":"object","properties":{"projectId":{"type":"string"},"contactId":{"type":["string","null"]},"body":{"type":["string","null"],"description":"The rendered HTML body. Null only if archival storage was unreachable on read; retry."},"providerMessageId":{"type":["string","null"]},"deliveredAt":{"type":["string","null"],"format":"date-time"},"openedAt":{"type":["string","null"],"format":"date-time"},"clickedAt":{"type":["string","null"],"format":"date-time"},"latencyMs":{"type":["number","null"]}}}]},"Template":{"type":"object","properties":{"templateId":{"type":"string"},"projectId":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["transactional","marketing"]},"subject":{"type":"string"},"body":{"type":"string"},"fromName":{"type":["string","null"]},"fromEmail":{"type":"string","format":"email"},"version":{"type":"integer","description":"Bumped on every content change; old versions are kept for rollback."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["templateId","name","type","subject","body","fromEmail","version","createdAt","updatedAt"]},"TemplateVersion":{"type":"object","properties":{"templateId":{"type":"string"},"version":{"type":"integer"},"subject":{"type":"string"},"body":{"type":"string"},"fromName":{"type":["string","null"]},"fromEmail":{"type":"string","format":"email"},"createdAt":{"type":"string","format":"date-time"}},"required":["templateId","version","subject","body","fromEmail","createdAt"]},"DnsRecord":{"type":"object","properties":{"purpose":{"type":"string","enum":["ownership","dkim","spf","dmarc","tracking","mailfrom"]},"type":{"type":"string","enum":["TXT","CNAME","MX"]},"host":{"type":"string"},"value":{"type":"string"},"status":{"type":"string","enum":["pending","verified","failed","not_applicable"]}},"required":["purpose","type","host","value","status"]},"Domain":{"type":"object","description":"A sending domain. On Amazon SES, `status` becomes `verified` once SES accepts the DKIM TXT record — publish all four records and poll this endpoint (or `POST /v1/domains/{id}/verify`) until then; SES is re-checked every 5 minutes for up to 72 hours, after which the domain is marked `failed`. On other providers `status` becomes `verified` once the ownership and SPF records pass.","properties":{"domainId":{"type":"string"},"projectId":{"type":"string"},"domain":{"type":"string"},"status":{"type":"string","enum":["pending","verified","failed"]},"dkimStatus":{"type":"string","enum":["pending","verified","failed","not_applicable"]},"spfStatus":{"type":"string","enum":["pending","verified","failed"]},"dmarcStatus":{"type":"string","enum":["pending","verified","failed"]},"trackingStatus":{"type":"string","enum":["pending","verified","failed"]},"mailFromStatus":{"type":"string","enum":["pending","verified","failed","not_applicable"],"description":"Custom MAIL FROM subdomain (`bounce.<domain>`) — carries the bounce path and lets DMARC align SPF."},"mailFromDomain":{"type":["string","null"]},"selector":{"type":["string","null"],"description":"DKIM selector published at `<selector>._domainkey.<domain>`."},"verifiedForSending":{"type":"boolean","description":"Whether the provider will currently accept mail from this domain."},"dkimTokens":{"type":"array","items":{"type":"string"},"description":"Legacy SES Easy DKIM CNAME tokens. Empty for domains onboarded with BYODKIM, which publish a single TXT record instead."},"verificationToken":{"type":["string","null"]},"lastCheckedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"dnsRecords":{"type":"array","items":{"$ref":"#/components/schemas/DnsRecord"},"description":"The DNS records to install."}},"required":["domainId","domain","status","createdAt","dnsRecords"]},"SegmentConditions":{"type":"object","description":"Nested condition tree evaluated against contacts, their custom `data`, and their activity.","properties":{"logic":{"type":"string","enum":["and","or"]},"groups":{"type":"array","minItems":1,"maxItems":20,"items":{"type":"object","properties":{"filters":{"type":"array","minItems":1,"maxItems":20,"items":{"type":"object","properties":{"field":{"type":"string","description":"`email`, `subscribed`, `locale`, `createdAt`, `data.<key>`, `event.<name>`, `email.opened`, …"},"operator":{"type":"string","description":"`equals`, `notEquals`, `contains`, `notContains`, `greaterThan`, `lessThan`, `exists`, `notExists`, `within`, `notWithin`, `triggered`, `notTriggered`, …"},"value":{"oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]},"days":{"type":"integer","minimum":1,"maximum":3650,"description":"Time window (days) for activity operators."}},"required":["field","operator"]}}},"required":["filters"]}}},"required":["logic","groups"]},"Segment":{"type":"object","properties":{"segmentId":{"type":"string"},"projectId":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"type":{"type":"string","enum":["dynamic","static"],"description":"Dynamic segments are computed from `conditions`; static segments are managed via the members endpoints."},"conditions":{"oneOf":[{"$ref":"#/components/schemas/SegmentConditions"},{"type":"null"}]},"memberCount":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["segmentId","name","type","memberCount","createdAt","updatedAt"]},"SegmentMember":{"type":"object","properties":{"contactId":{"type":"string"},"email":{"type":"string","format":"email"},"firstName":{"type":["string","null"]},"lastName":{"type":["string","null"]},"enteredAt":{"type":"string","format":"date-time"}},"required":["contactId","email","enteredAt"]},"Campaign":{"type":"object","properties":{"campaignId":{"type":"string"},"projectId":{"type":"string"},"name":{"type":"string"},"subject":{"type":"string"},"previewText":{"type":["string","null"],"maxLength":200,"description":"Preheader — the preview line inboxes show after the subject. Injected as a hidden block at the top of the body at send time."},"body":{"type":"string"},"bodyBlocks":{"type":["string","array","object","null"],"additionalProperties":true,"description":"The visual editor's design `body` was compiled from — a JSON string of the GrapesJS project (or a legacy block array/object), stored so the editor can reopen it. Ignored when sending — only `body` is delivered."},"fromName":{"type":["string","null"]},"fromEmail":{"type":"string","format":"email"},"replyTo":{"type":["string","null"],"format":"email","description":"Becomes the Reply-To header when set."},"status":{"type":"string","enum":["draft","scheduled","sending","sent","cancelled"]},"audience":{"type":"string","enum":["all","segment","filter"]},"segmentId":{"type":["string","null"]},"filter":{"oneOf":[{"$ref":"#/components/schemas/SegmentConditions"},{"type":"null"}]},"abSubjectB":{"type":["string","null"]},"abSamplePct":{"type":["integer","null"]},"abDecideAt":{"type":["string","null"],"format":"date-time"},"abWinner":{"type":["string","null"],"enum":["a","b",null]},"scheduledFor":{"type":["string","null"],"format":"date-time"},"sentAt":{"type":["string","null"],"format":"date-time"},"totalRecipients":{"type":"integer"},"delivered":{"type":"integer"},"opened":{"type":"integer"},"clicked":{"type":"integer"},"bounced":{"type":"integer"},"complained":{"type":"integer"},"unsubscribed":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"}},"required":["campaignId","name","subject","body","fromEmail","status","audience","createdAt"]},"CampaignRecipient":{"type":"object","properties":{"campaignRecipientId":{"type":"string"},"campaignId":{"type":"string"},"contactId":{"type":["string","null"]},"email":{"type":"string","format":"email"},"emailId":{"type":["string","null"],"description":"The send-log id once the message went out."},"status":{"type":"string","enum":["pending","sent","skipped"]},"variant":{"type":["string","null"],"enum":["a","b","hold",null],"description":"A/B variant, when an experiment is running."},"createdAt":{"type":"string","format":"date-time"}},"required":["campaignRecipientId","campaignId","email","status","createdAt"]},"WorkflowGraph":{"type":"object","description":"The automation graph: nodes (with canvas positions) plus directed edges.","properties":{"nodes":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","enum":["trigger","send_email","delay","wait_event","condition","update_contact","webhook","exit"]},"label":{"type":"string"},"config":{"type":"object","additionalProperties":true},"x":{"type":"number"},"y":{"type":"number"}},"required":["id","type","label","x","y"]}},"edges":{"type":"array","maxItems":200,"items":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"label":{"type":"string"}},"required":["from","to"]}}},"required":["nodes","edges"]},"Workflow":{"type":"object","properties":{"workflowId":{"type":"string"},"projectId":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","enum":["draft","active","paused"]},"trigger":{"type":"string","description":"`event:<name>`, `contact.created`, `segment:enter:<ref>`, or `segment:exit:<ref>`."},"graph":{"oneOf":[{"$ref":"#/components/schemas/WorkflowGraph"},{"type":"null"}]},"reentry":{"type":"string","enum":["never","after_exit","always"]},"entered":{"type":"integer"},"completed":{"type":"integer"},"active":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"}},"required":["workflowId","name","status","trigger","reentry","createdAt"]},"WorkflowExecution":{"type":"object","properties":{"executionId":{"type":"string"},"workflowId":{"type":"string"},"projectId":{"type":"string"},"contactId":{"type":"string"},"status":{"type":"string","enum":["active","waiting","completed","exited","failed","cancelled"]},"currentNodeId":{"type":["string","null"]},"wakeAt":{"type":["string","null"],"format":"date-time"},"waitEventName":{"type":["string","null"]},"waitTimeoutAt":{"type":["string","null"],"format":"date-time"},"context":{"type":["object","null"],"additionalProperties":true},"enteredAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"completedAt":{"type":["string","null"],"format":"date-time"}},"required":["executionId","workflowId","contactId","status","enteredAt"]},"WorkflowStepRun":{"type":"object","properties":{"executionId":{"type":"string"},"workflowId":{"type":"string"},"nodeId":{"type":"string"},"nodeType":{"type":"string"},"outcome":{"type":"string","description":"`ok`, `branch:<label>`, `skipped`, `timeout`, or `error`."},"detail":{"type":["object","null"],"additionalProperties":true},"createdAt":{"type":"string","format":"date-time"}},"required":["executionId","nodeId","nodeType","outcome","createdAt"]},"WebhookEndpoint":{"type":"object","properties":{"webhookEndpointId":{"type":"string"},"projectId":{"type":"string"},"url":{"type":"string","format":"uri"},"description":{"type":["string","null"]},"secret":{"type":"string","description":"`whsec_…` — used to HMAC-SHA256-sign every delivery (`X-Volanea-Signature: t=<ms>,v1=<hex>` over `<t>.<body>`)."},"events":{"type":["array","null"],"items":{"type":"string"},"description":"Event name patterns, e.g. `[\"email.*\", \"contact.created\"]`. Empty or null = all events."},"enabled":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}},"required":["webhookEndpointId","url","secret","enabled","createdAt"]},"WebhookDelivery":{"type":"object","properties":{"webhookDeliveryId":{"type":"string"},"endpointId":{"type":"string"},"projectId":{"type":"string"},"eventName":{"type":"string"},"payload":{"type":"object","additionalProperties":true},"status":{"type":"string","enum":["pending","success","failed"]},"attempts":{"type":"integer"},"responseStatus":{"type":["integer","null"]},"responseBody":{"type":["string","null"],"description":"Truncated to 1 KB."},"nextRetryAt":{"type":["string","null"],"format":"date-time"},"deliveredAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"}},"required":["webhookDeliveryId","endpointId","eventName","payload","status","attempts","createdAt"]},"Suppression":{"type":"object","properties":{"suppressionId":{"type":"string"},"projectId":{"type":"string"},"email":{"type":"string","format":"email"},"reason":{"type":"string","enum":["hard_bounce","complaint","manual","unsubscribe"]},"source":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"}},"required":["suppressionId","email","reason","createdAt"]},"BillingCategoryUsage":{"type":"object","properties":{"limit":{"type":["integer","null"],"description":"`null` = unlimited."},"used":{"type":"integer","description":"Non-test sends this month in this category."}},"required":["limit","used"]},"Billing":{"type":"object","properties":{"month":{"type":"string","description":"Current period, \"YYYY-MM\"."},"used":{"type":"integer","description":"Non-test emails sent this month."},"limit":{"type":["integer","null"],"description":"Overall monthly send cap; `null` = unlimited."},"categories":{"type":"object","description":"Per-category usage and cap — transactional/campaign/workflow/inbound. Checked alongside (not instead of) the overall `limit`.","properties":{"transactional":{"$ref":"#/components/schemas/BillingCategoryUsage"},"campaign":{"$ref":"#/components/schemas/BillingCategoryUsage"},"workflow":{"$ref":"#/components/schemas/BillingCategoryUsage"},"inbound":{"$ref":"#/components/schemas/BillingCategoryUsage"}}},"plan":{"type":"string","enum":["free","starter","growth"]},"emailQuota":{"type":"integer","description":"Email credits per monthly cycle for the current plan. Hard cap — sending stops when it's used up."},"freeTierCredits":{"type":"integer"},"creditsUsed":{"type":"integer","description":"Credits used this cycle (1/email, 2 with attachments)."},"planQuotas":{"type":"object","description":"Per-cycle quota of every self-serve plan — what an upgrade would buy.","properties":{"free":{"type":"integer"},"starter":{"type":"integer"},"growth":{"type":"integer"}}},"reputation":{"type":"object","additionalProperties":true,"description":"Computed deliverability metrics. The worst of three windows (24h/7d/all-time) wins; `windows` breaks each one down. New projects (<30 days old) enforce at a lower volume floor."},"reputationState":{"type":"string","enum":["ok","warn","throttled","paused"]},"reputationReason":{"type":["string","null"]},"dodo":{"type":"object","description":"Merchant-of-record provider (Dodo Payments) backing paid subscriptions. Disabled on self-host installs.","properties":{"enabled":{"type":"boolean"},"env":{"type":"string","enum":["test_mode","live_mode"]}}},"dodoSubscription":{"type":"object","properties":{"id":{"type":["string","null"]},"status":{"type":["string","null"],"enum":["active","ended",null]},"plan":{"type":["string","null"],"enum":["starter","growth",null]},"cancelAtNextBillingDate":{"type":"boolean"},"nextBillingAt":{"type":["string","null"],"format":"date-time"}}}},"required":["month","used","plan","emailQuota","creditsUsed","reputationState"]}}},"paths":{"/v1/openapi.json":{"get":{"tags":["Meta"],"operationId":"getOpenApiSpec","summary":"This document","description":"The OpenAPI 3.1 description of the API. No authentication required.","security":[],"responses":{"200":{"description":"The OpenAPI document.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}},"/v1/docs":{"get":{"tags":["Meta"],"operationId":"getDocs","summary":"Rendered API reference","description":"This document, drawn for humans — the browsable reference at <https://api.volanea.com/v1/docs>. Returns HTML, not JSON. No authentication required.","security":[],"responses":{"200":{"description":"The HTML reference page.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/v1/send":{"post":{"tags":["Send"],"operationId":"send","summary":"Send an email","description":"Send one message (to one address or up to 50). Runs the full pipeline: suppression check, contact upsert, template render, tracking instrumentation, and dispatch. Supports scheduling via `sendAt` and safe retries via the `Idempotency-Key` header.\n\n**Reading a failure** — every failure of the send pipeline keeps the top-level `code` `send_failed`, at whatever HTTP status fits (404 for a missing template, 403 for an unverified from domain). The specific cause is in `details[0].code` — e.g. `template_not_found`. Branch on `code === \"send_failed\"` for \"the send did not happen\", then read `details[0].code` when you need to tell a wrong template id in your config from a transient provider problem worth retrying. `details` is absent when there is no cause more specific than the message.\n\n**A 200 does not mean delivered.** Each entry in `messages` is either an accepted message (with an `id` and a `status`) or a skip (`{ to, skipped }` — suppressed, unsubscribed, over quota). Skips are part of a 200 body. Accepted messages are queued: the provider round trip happens after this response returns, so track final delivery with `GET /v1/emails/{id}` or the `email.*` webhooks, not from this call.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"A unique key for this logical send; a repeat replays the stored response instead of sending again.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessage"}}}},"responses":{"200":{"description":"Per-recipient results.","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/SendResult"}},"testMode":{"type":"boolean"},"replay":{"type":"boolean","enum":[true],"description":"Present and `true` ONLY on an idempotent replay — absent on a first send, never `false`. Nothing was dispatched for this request; `messages` is the stored response from the original call, so the ids in it are the original ids. Test `data.replay === true`, or read the `X-Idempotent-Replay` header."}},"required":["messages","testMode"]}}},"headers":{"X-Idempotent-Replay":{"description":"Present and `true` when this response was replayed from a previous request with the same `Idempotency-Key` — nothing was sent for this call. Omitted entirely otherwise; it is never `false`. `data.replay` says the same thing in the body for clients that cannot read response headers.","schema":{"type":"string","enum":["true"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The from domain is not verified, or the project has no verified domain at all. `code` is `send_failed` — the domain is named in `error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Template not found. `code` is `send_failed`; `details[0].code` is `template_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`Idempotency-Key` reused with a different body (`idempotency_key_reused`) — nothing was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-project send rate limit (`rate_limited`). Retry after the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/send/batch":{"post":{"tags":["Send"],"operationId":"sendBatch","summary":"Send a batch","description":"Up to 1,000 personalized messages in one call. Each message succeeds or fails independently; a failed message becomes a `{ error: … }` entry in `results` rather than failing the whole batch, so a bad template id in message #7 does not stop #8.\n\n**The batch answers 200 even when every message failed.** `results` is positional — entry *i* is the outcome of `messages[i]` — and you must inspect each entry. A caller that only checks the HTTP status reports a completely failed batch as a success. Supports `Idempotency-Key` over the whole batch.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Replay protection for the whole batch.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/SendMessage"},"minItems":1,"maxItems":1000}},"required":["messages"]}}}},"responses":{"200":{"description":"Per-message results, in request order.","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"oneOf":[{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/SendResult"}}},"required":["messages"]},{"type":"object","description":"A failed message. NOT the top-level error envelope — there is no `success` here, and the human-readable text is `error.message`, not `error`. Read `error.code` to branch: `validation_error` (with `error.details` naming the fields) or `send_failed`.","properties":{"error":{"type":"object","properties":{"message":{"type":"string"},"code":{"type":"string","description":"`validation_error` or `send_failed`."},"details":{"type":"array","description":"Failing fields, present on `validation_error` only. Paths are relative to this message, not to the batch body.","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"},"code":{"type":"string"}},"required":["path","message","code"]}}},"required":["message","code"]}},"required":["error"]}]}},"testMode":{"type":"boolean"},"replay":{"type":"boolean","enum":[true],"description":"Present and `true` ONLY on an idempotent replay — absent on a first send, never `false`. Nothing was dispatched; `results` is the stored response from the original batch."}},"required":["results","testMode"]}}},"headers":{"X-Idempotent-Replay":{"description":"Present and `true` when this response was replayed from a previous request with the same `Idempotency-Key` — nothing was sent for this call. Omitted entirely otherwise; it is never `false`. `data.replay` says the same thing in the body for clients that cannot read response headers.","schema":{"type":"string","enum":["true"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`Idempotency-Key` reused with a different body (`idempotency_key_reused`) — nothing was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-project send rate limit (`rate_limited`). A batch consumes one unit per message, so it cannot dodge the cap.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/events":{"post":{"tags":["Events"],"operationId":"trackEvent","summary":"Track an event","description":"Ingest a product event for a contact (auto-created if unknown). Accepts the public key (`pk_…`), so it is safe to call from client-side code. Names with the `email.`/`contact.`/`segment.`/`workflow.` prefixes are reserved for system events. Events feed segments, workflow triggers, and webhooks.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":128,"description":"Your event name, e.g. `user.signup` (reserved prefixes excluded)."},"email":{"type":"string","format":"email","description":"The contact the event belongs to."},"data":{"type":"object","additionalProperties":true,"description":"Properties of the event — what happened. Point-in-time; never applied to the contact."},"contact":{"type":"object","description":"Who the contact is. Applied to the contact record, so a workflow triggered by this event can personalise its email with `{{firstName}}`. Omit a field to leave the existing value untouched; omit the whole object and the contact is created with only an address.","properties":{"firstName":{"type":"string","maxLength":120,"nullable":true},"lastName":{"type":"string","maxLength":120,"nullable":true},"locale":{"type":"string","maxLength":16,"nullable":true},"data":{"type":"object","additionalProperties":true,"nullable":true,"description":"Custom contact fields, merged into the contact's existing `data`."}}}},"required":["name","email"]}}}},"responses":{"200":{"description":"Event recorded, with what it set off.","content":{"application/json":{"schema":{"type":"object","properties":{"eventId":{"type":"string"},"contactId":{"type":"string","description":"The contact the event was attributed to, created if it did not exist."},"triggered":{"type":"array","items":{"$ref":"#/components/schemas/TriggeredWorkflow"},"description":"The active workflows this event just handed to the automation engine. **An empty array means nothing matched** — that is the point of the field. Firing automations is what this endpoint is for, and before this existed the only way to find out whether anything happened was to list every workflow and compare trigger strings by hand; a typo in an event name looked exactly like a working integration."},"skipped":{"type":"array","items":{"$ref":"#/components/schemas/SkippedWorkflow"},"description":"Workflows whose trigger matched but that are `draft` or `paused`, so nothing ran. Check this before concluding your trigger string is wrong: an empty `triggered` with a non-empty `skipped` means the wiring is correct and the workflow simply is not live yet."}},"required":["eventId","contactId","triggered","skipped"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Events"],"operationId":"listEvents","summary":"List events","description":"The event log (system and custom events), newest first, cursor-paginated. Requires a secret key.","parameters":[{"name":"cursor","in":"query","required":false,"description":"Pagination cursor — an OPAQUE token. Take the `nextCursor` from the previous page and send it back unchanged. Do not parse it, do not coerce it to a number, and do not construct one: the format is `<unixMs>.<objectId>` today and may change. A cursor that cannot be parsed is rejected with 422 `validation_error` rather than silently restarting at page one.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (1–200, default 50).","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"name","in":"query","required":false,"description":"Filter by exact event name.","schema":{"type":"string"}},{"name":"contactId","in":"query","required":false,"description":"Filter to one contact.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of events.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}},"nextCursor":{"type":["string","null"],"description":"Opaque token. Pass back as `cursor` verbatim to fetch the next page; `null` when there are no more rows."}},"required":["data","nextCursor"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/events/names":{"get":{"tags":["Events"],"operationId":"listEventNames","summary":"List distinct event names","description":"Every event name logged for the project, alphabetical — for populating a segment/workflow event picker.","responses":{"200":{"description":"Distinct event names.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string"}}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/events/stats":{"get":{"tags":["Events"],"operationId":"eventStats","summary":"Per-name event counts","description":"Count and first/last-seen timestamp for every event name, most frequent first.\n\n**Windowing is opt-in.** With no range parameters this covers the project's whole history, which is what it did before ranges existed — narrowing the default would have changed the meaning of a number callers are already charting. Pass `days` for a rolling window or `from`/`to` for an explicit one; the two forms are mutually exclusive and sending both is a 422 rather than a silent choice between them.\n\nWhen a range is given the response carries a `window` object echoing it back, and `firstSeen`/`lastSeen` become the extremes *inside* that window rather than the event's all-time first sighting. A request with no range omits `window` entirely.","parameters":[{"name":"days","in":"query","required":false,"description":"Rolling window, in days (1–365) counting back from now. Mutually exclusive with `from`/`to`. Omit for all time.","schema":{"type":"integer","minimum":1,"maximum":365}},{"name":"from","in":"query","required":false,"description":"Start of an explicit window (ISO 8601). May be sent without `to` for an open-ended range.","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"description":"End of an explicit window (ISO 8601), inclusive — `to` on a date includes that whole day rather than stopping at its start.","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Per-name stats.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"count":{"type":"integer"},"firstSeen":{"type":"integer","description":"Unix ms."},"lastSeen":{"type":"integer","description":"Unix ms."}}}}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/events/{name}/usage":{"get":{"tags":["Events"],"operationId":"eventUsage","summary":"Usage detail for one event name","description":"One event name's totals: how many times it fired, how many distinct contacts triggered it, and when it was first and last seen.\n\n`contactCount` counts only events that were attributed to a contact — events tracked without a resolvable address raise `count` without raising it, so the two legitimately disagree.\n\nAn unknown name answers 200 with zeros rather than 404, so this endpoint cannot tell you \"never fired\" from \"you misspelled it\". When a trigger looks dead, check `GET /v1/events/names` for what you are actually sending.\n\nTakes the same optional window as `GET /v1/events/stats`, so drilling from a ranged list into one name keeps the range and the two agree. With no range this covers all time, as it always has.","parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string"}},{"name":"days","in":"query","required":false,"description":"Rolling window, in days (1–365) counting back from now. Mutually exclusive with `from`/`to`. Omit for all time.","schema":{"type":"integer","minimum":1,"maximum":365}},{"name":"from","in":"query","required":false,"description":"Start of an explicit window (ISO 8601). May be sent without `to` for an open-ended range.","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"description":"End of an explicit window (ISO 8601), inclusive — `to` on a date includes that whole day rather than stopping at its start.","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Usage detail.","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"count":{"type":"integer"},"contactCount":{"type":"integer","description":"Distinct contacts that triggered this event."},"firstSeen":{"type":["integer","null"]},"lastSeen":{"type":["integer","null"]}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/events/{name}":{"delete":{"tags":["Events"],"operationId":"deleteEventName","summary":"Delete all events with this name","description":"Irreversible. System event names (`email.`/`contact.`/`segment.`/`workflow.` prefixes) are reserved and can't be deleted.","parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"How many events were removed.","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"removed":{"type":"integer"}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts":{"get":{"tags":["Contacts"],"operationId":"listContacts","summary":"List contacts","description":"Newest first, cursor-paginated.","parameters":[{"name":"cursor","in":"query","required":false,"description":"Pagination cursor — an OPAQUE token. Take the `nextCursor` from the previous page and send it back unchanged. Do not parse it, do not coerce it to a number, and do not construct one: the format is `<unixMs>.<objectId>` today and may change. A cursor that cannot be parsed is rejected with 422 `validation_error` rather than silently restarting at page one.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (1–200, default 50).","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"A page of contacts.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}},"nextCursor":{"type":["string","null"],"description":"Opaque token. Pass back as `cursor` verbatim to fetch the next page; `null` when there are no more rows."}},"required":["data","nextCursor"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Contacts"],"operationId":"upsertContact","summary":"Create or update a contact","description":"Upsert by email (addresses are lowercased). Only the provided fields are changed on update.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"firstName":{"type":"string"},"lastName":{"type":"string"},"subscribed":{"type":"boolean"},"data":{"type":"object","additionalProperties":true,"description":"Arbitrary custom fields (replaces the stored object)."}},"required":["email"]}}}},"responses":{"200":{"description":"Existing contact updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"201":{"description":"Contact created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/export":{"get":{"tags":["Contacts"],"operationId":"exportContacts","summary":"Export contacts as CSV","description":"The full audience as a CSV download (`email,firstName,lastName,subscribed,locale,source,data,createdAt`).","responses":{"200":{"description":"CSV file.","content":{"text/csv":{"schema":{"type":"string"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/import":{"post":{"tags":["Contacts"],"operationId":"importContacts","summary":"Import contacts from CSV","description":"CSV with a header row; an `email` column is required. `firstName`, `lastName`, `subscribed`, and `locale` columns map to contact fields; any other column becomes a custom `data` field.\n\n**Asynchronous.** This answers 202 with a job id as soon as the header row validates; the rows are imported by a background worker. It does NOT return final counts — poll `GET /v1/contacts/import/{id}` until `status` is `done` or `failed`. Only the malformed-CSV cases (no header, no `email` column) are decided synchronously, as 422.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"csv":{"type":"string","description":"The CSV content. Max 8,000,000 characters — the whole file is stored on the job document, and Mongo caps a document at 16 MB."},"dedupe":{"type":"string","enum":["update","skip"],"default":"update","description":"What to do when an email already exists."}},"required":["csv"]}}}},"responses":{"202":{"description":"Import accepted and queued. No contact has been written yet.","content":{"application/json":{"schema":{"type":"object","properties":{"importJobId":{"type":"string","description":"Poll `GET /v1/contacts/import/{id}` with this."},"status":{"type":"string","enum":["queued"]},"total":{"type":"integer","description":"Data rows detected (header excluded). Nothing has been imported yet."}},"required":["importJobId","status","total"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/import/{id}":{"get":{"tags":["Contacts"],"operationId":"getContactImport","summary":"Import job progress","description":"Progress and final counts for a queued CSV import. Poll until `status` is `done` (counts are final) or `failed` (`failureReason` says why). Counts are recomputed from scratch on every worker attempt, never incremented, so a retried job never double-counts.","parameters":[{"name":"id","in":"path","required":true,"description":"Import job id (`importJobId` from the 202).","schema":{"type":"string"}}],"responses":{"200":{"description":"The job's current state.","content":{"application/json":{"schema":{"type":"object","properties":{"importJobId":{"type":"string"},"status":{"type":"string","enum":["queued","processing","done","failed"]},"total":{"type":"integer","description":"Data rows in the file."},"processed":{"type":"integer","description":"Rows handled so far."},"imported":{"type":"integer","description":"New contacts created."},"updated":{"type":"integer"},"skipped":{"type":"integer"},"errors":{"type":"array","items":{"type":"object","properties":{"line":{"type":"integer"},"error":{"type":"string"}}},"description":"Up to the first 10 row failures. `skipped` counts them all — do not read this array's length as the failure count."},"failureReason":{"type":["string","null"],"description":"Set only when `status` is `failed`: the job itself died, not one row."}},"required":["importJobId","status","total"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/bulk":{"post":{"tags":["Contacts"],"operationId":"bulkContacts","summary":"Bulk subscribe / unsubscribe / delete / suppress","description":"Apply one action to up to 10,000 addresses. `suppress` adds addresses to the do-not-send list even if no contact exists.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string","enum":["subscribe","unsubscribe","delete","suppress"]},"emails":{"type":"array","items":{"type":"string","format":"email"},"minItems":1,"maxItems":10000}},"required":["action","emails"]}}}},"responses":{"200":{"description":"How many contacts were affected.","content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string"},"affected":{"type":"integer"}},"required":["action","affected"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/fields":{"get":{"tags":["Contacts"],"operationId":"listContactFields","summary":"Discover custom contact fields","description":"Every key seen across contacts' `data` objects, with how many contacts have it set — for building segment filter pickers dynamically.","responses":{"200":{"description":"Custom fields with counts.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"count":{"type":"integer"}}}}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/fields/{field}/values":{"get":{"tags":["Contacts"],"operationId":"listContactFieldValues","summary":"Distinct values for one custom field","description":"The 100 most common values stored under one custom `data` key, most frequent first, each with a count.\n\nBuilt for populating a segment-filter dropdown from real data instead of asking someone to remember what they wrote. Contacts that lack the key, or store null under it, are excluded — so the counts here total less than your contact count.\n\nThe cap is a fixed 100 and there is no pagination: a field with a longer tail than that is a free-text field, not a segment filter.","parameters":[{"name":"field","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Distinct values with counts.","content":{"application/json":{"schema":{"type":"object","properties":{"field":{"type":"string"},"data":{"type":"array","items":{"type":"object","properties":{"value":{},"count":{"type":"integer"}}},"description":"Up to 100 distinct values, most common first."}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/fields/{field}/usage":{"get":{"tags":["Contacts"],"operationId":"contactFieldUsage","summary":"How many contacts have this field set","description":"How many contacts have this custom field set to a non-null value.\n\nWorth calling before `DELETE /v1/contacts/fields/{field}` — that endpoint is irreversible and project-wide, and this is the number it would affect.","parameters":[{"name":"field","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Usage count.","content":{"application/json":{"schema":{"type":"object","properties":{"field":{"type":"string"},"count":{"type":"integer"}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/fields/{field}":{"delete":{"tags":["Contacts"],"operationId":"deleteContactField","summary":"Remove a custom field from every contact","description":"Irreversible. Deletes this key from the `data` object of every contact in the project.","parameters":[{"name":"field","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"How many contacts were changed.","content":{"application/json":{"schema":{"type":"object","properties":{"field":{"type":"string"},"removed":{"type":"integer"}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/{id}":{"get":{"tags":["Contacts"],"operationId":"getContact","summary":"Get a contact","description":"One contact by id: identity, subscription state, locale, and the custom `data` object.\n\nThis is the record, not the history. For what the contact was sent and what they did with it, use `GET /v1/contacts/{id}/timeline`.","parameters":[{"name":"id","in":"path","required":true,"description":"Contact id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The contact.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Contacts"],"operationId":"deleteContact","summary":"Delete a contact","description":"Deletes the contact record. Two things deliberately survive it.\n\n**The send log.** Rows in `GET /v1/emails` keep the address and their now-dangling `contactId`, because discarding delivery history along with the contact would erase the bounce and complaint record you are required to be able to produce.\n\n**Suppressions.** The do-not-send list is keyed by address, not by contact, so deleting a contact does not make a suppressed address mailable again — and re-creating the contact does not either. Use `DELETE /v1/suppressions/{id}` if that is what you meant.","parameters":[{"name":"id","in":"path","required":true,"description":"Contact id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/contacts/{id}/timeline":{"get":{"tags":["Contacts"],"operationId":"getContactTimeline","summary":"Contact activity timeline","description":"The contact plus their most recent 100 activities — events and emails merged, newest first.","parameters":[{"name":"id","in":"path","required":true,"description":"Contact id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The merged timeline.","content":{"application/json":{"schema":{"type":"object","properties":{"contact":{"$ref":"#/components/schemas/Contact"},"timeline":{"type":"array","items":{"oneOf":[{"type":"object","description":"An event entry.","properties":{"kind":{"type":"string","enum":["event"]},"at":{"type":"integer","description":"Unix ms."},"name":{"type":"string"},"data":{"type":["object","null"],"additionalProperties":true},"emailId":{"type":["string","null"]}},"required":["kind","at","name"]},{"type":"object","description":"An email entry.","properties":{"kind":{"type":"string","enum":["email"]},"at":{"type":"integer","description":"Unix ms."},"emailId":{"type":"string"},"subject":{"type":"string"},"source":{"type":"string"},"status":{"type":"string"}},"required":["kind","at","emailId","subject"]}]}}},"required":["contact","timeline"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/emails":{"get":{"tags":["Emails"],"operationId":"listEmails","summary":"List sent emails","description":"The send log across all sources, newest first, cursor-paginated. Bodies are omitted — fetch one email for the full content.","parameters":[{"name":"cursor","in":"query","required":false,"description":"Pagination cursor — an OPAQUE token. Take the `nextCursor` from the previous page and send it back unchanged. Do not parse it, do not coerce it to a number, and do not construct one: the format is `<unixMs>.<objectId>` today and may change. A cursor that cannot be parsed is rejected with 422 `validation_error` rather than silently restarting at page one.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (1–200, default 50).","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"status","in":"query","required":false,"description":"`scheduled` is a VIEW, not a stored status: it selects rows stored `queued` whose `scheduledFor` is still in the future. This is the only way to list pending scheduled sends — filtering on `queued` also returns messages already handed to the provider and awaiting a callback, which is the opposite of \"still pending\". `POST /v1/send` has always *returned* `status: \"scheduled\"`, so until this was accepted here the API handed callers a value it then refused to take back. The rows that come back still read `\"status\": \"queued\"` — the stored value — with the schedule in `scheduledFor`.","schema":{"type":"string","enum":["queued","sending","sent","delivered","opened","clicked","bounced","complained","failed","received","cancelled","scheduled"]}},{"name":"source","in":"query","required":false,"schema":{"type":"string","enum":["transactional","campaign","workflow","inbound"]}},{"name":"to","in":"query","required":false,"description":"Filter by recipient address.","schema":{"type":"string","format":"email"}}],"responses":{"200":{"description":"A page of send-log rows.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EmailSummary"}},"nextCursor":{"type":["string","null"],"description":"Opaque token. Pass back as `cursor` verbatim to fetch the next page; `null` when there are no more rows."}},"required":["data","nextCursor"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/emails/{id}":{"delete":{"tags":["Emails"],"operationId":"cancelEmail","summary":"Cancel a scheduled message","description":"Stops a message that was queued with `sendAt` before it is dispatched. Only a `queued` message that actually has a `scheduledFor` can be cancelled: one sent immediately answers 409 `not_scheduled`, and one already dispatched answers 409 `already_dispatched`. Cancelling emits an `email.cancelled` event.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The cancelled message.","content":{"application/json":{"schema":{"type":"object","properties":{"emailId":{"type":"string"},"status":{"type":"string","enum":["cancelled"]}},"required":["emailId","status"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Not scheduled, or already dispatched.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Emails"],"operationId":"getEmail","summary":"Get an email","description":"Full message detail including the rendered HTML body and engagement counters.","parameters":[{"name":"id","in":"path","required":true,"description":"Email id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Email"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/templates":{"get":{"tags":["Templates"],"operationId":"listTemplates","summary":"List templates","description":"Every template in the project, newest first, each with its current content and version number.\n\nUnpaginated — unlike contacts, emails, and events, this returns the whole set in one response, bodies included. A project with many large templates will find this a heavy call; there is no cursor to page it with.","responses":{"200":{"description":"All templates, most recently updated first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Template"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Templates"],"operationId":"createTemplate","summary":"Create a template","description":"Stores reusable content addressed by `templateId`, so a send can name the template instead of carrying the markup.\n\n`subject`, `body`, and the preheader support `{{variable}}` placeholders, resolved at send time against the per-call `variables` first and the contact's own fields second. `fromEmail` must sit on a verified domain or every send from this template fails.\n\nVersion 1 is written immediately. Every later content change bumps the version and keeps the previous one, which is what `POST /v1/templates/{id}/rollback` restores.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":120},"type":{"type":"string","enum":["transactional","marketing"],"description":"Marketing templates get the unsubscribe footer and respect the subscribed flag."},"subject":{"type":"string","maxLength":500},"body":{"type":"string","description":"HTML with `{{variable}}` placeholders."},"fromName":{"type":"string","maxLength":120},"fromEmail":{"type":"string","format":"email"}},"required":["name","subject","body","fromEmail"]}}}},"responses":{"201":{"description":"Created (version 1 is snapshotted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/templates/{id}":{"get":{"tags":["Templates"],"operationId":"getTemplate","summary":"Get a template","description":"One template: its current subject, body, from address, and version number.\n\nOnly the current content — earlier versions are at `GET /v1/templates/{id}/versions`.","parameters":[{"name":"id","in":"path","required":true,"description":"Template id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The template.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Templates"],"operationId":"updateTemplate","summary":"Update a template","description":"Content changes (subject, body, from) bump the version and snapshot the new content for rollback.","parameters":[{"name":"id","in":"path","required":true,"description":"Template id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":120},"type":{"type":"string","enum":["transactional","marketing"],"description":"Marketing templates get the unsubscribe footer and respect the subscribed flag."},"subject":{"type":"string","maxLength":500},"body":{"type":"string","description":"HTML with `{{variable}}` placeholders."},"fromName":{"type":"string","maxLength":120},"fromEmail":{"type":"string","format":"email"}}}}}},"responses":{"200":{"description":"The updated template.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Templates"],"operationId":"deleteTemplate","summary":"Delete a template","description":"Deletes the template and its entire version history.\n\nMessages already sent from it are unaffected: the rendered body is stored on each email row, so the send log stays readable after the template is gone.\n\n**Nothing checks for references first.** A campaign or workflow step still naming this `templateId` is not updated and not warned about — it fails at send time with `send_failed` and `details[0].code` of `template_not_found`. Check what points at a template before removing it.","parameters":[{"name":"id","in":"path","required":true,"description":"Template id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/templates/{id}/versions":{"get":{"tags":["Templates"],"operationId":"listTemplateVersions","summary":"List template versions","description":"Every stored version of one template, newest version first.\n\nEach row is the full content as it stood — subject, body, from address — not a diff. This is the list to read a `version` number out of before calling `POST /v1/templates/{id}/rollback`.","parameters":[{"name":"id","in":"path","required":true,"description":"Template id.","schema":{"type":"string"}}],"responses":{"200":{"description":"All snapshots, newest version first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TemplateVersion"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/templates/{id}/duplicate":{"post":{"tags":["Templates"],"operationId":"duplicateTemplate","summary":"Duplicate a template","description":"Clones the current content as a new template with its own fresh version-1 history (the source's version history is not copied).","parameters":[{"name":"id","in":"path","required":true,"description":"Template id.","schema":{"type":"string"}}],"responses":{"201":{"description":"The new template.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/templates/{id}/rollback":{"post":{"tags":["Templates"],"operationId":"rollbackTemplate","summary":"Roll back to a version","description":"Restores an old version's content as a new version (history is never rewritten).","parameters":[{"name":"id","in":"path","required":true,"description":"Template id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"version":{"type":"integer","minimum":1}},"required":["version"]}}}},"responses":{"200":{"description":"The template at its new version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/templates/{id}/test-send":{"post":{"tags":["Templates"],"operationId":"testSendTemplate","summary":"Test-send a template","description":"Sends the template to one address with sample variable data. The subject is prefixed with \"[Test]\".","parameters":[{"name":"id","in":"path","required":true,"description":"Template id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"to":{"type":"string","format":"email"},"variables":{"type":"object","additionalProperties":{"oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}}},"required":["to"]}}}},"responses":{"200":{"description":"The send outcome.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/domains":{"get":{"tags":["Domains"],"operationId":"listDomains","summary":"List domains","description":"Every sending domain in the project, each with its verification state and the DNS records to publish.\n\nUnpaginated. Only domains reaching `verified` can send; until then a send from an address on them is refused with 403.","responses":{"200":{"description":"All domains with their DNS records.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Domain"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Domains"],"operationId":"addDomain","summary":"Add a domain","description":"Registers a sending domain and returns the DNS records to install. On Amazon SES that is four records — a DKIM TXT at `<selector>._domainkey.<domain>`, an MX and an SPF TXT on the `bounce.<domain>` MAIL FROM subdomain, and a DMARC TXT. Idempotent: re-adding a domain returns the records already in play rather than issuing a new DKIM key, which would stop a live domain from signing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"domain":{"type":"string","description":"e.g. `mail.example.com`."}},"required":["domain"]}}}},"responses":{"200":{"description":"The domain was already added; its existing DNS records are returned unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"201":{"description":"The domain with its DNS records.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"The mail provider rejected the registration.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/domains/{id}":{"get":{"tags":["Domains"],"operationId":"getDomain","summary":"Get a domain","description":"One domain: its verification state and the full DNS record set.\n\nThis is the endpoint to poll while a new domain propagates. On Amazon SES the status flips to `verified` once SES accepts the DKIM records; SES re-checks every five minutes for up to 72 hours and then marks the domain `failed`, so a domain stuck in `pending` past that window needs its records re-published, not more waiting.","parameters":[{"name":"id","in":"path","required":true,"description":"Domain id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The domain with its DNS records.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Domains"],"operationId":"deleteDomain","summary":"Remove a domain","description":"Removes the domain from this project.\n\nThe provider identity is torn down **only if no other project still uses the same domain** — SES identities are account-global, so an unconditional delete would revoke another project's DKIM signing along with yours. Domains that were adopted rather than provisioned by Volanea are never torn down at the provider.\n\nSends from addresses on this domain start failing as soon as the record is gone. Mail already queued is not recalled.","parameters":[{"name":"id","in":"path","required":true,"description":"Domain id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/domains/{id}/verify":{"post":{"tags":["Domains"],"operationId":"verifyDomain","summary":"Check DNS and verify","description":"Re-checks the domain now instead of waiting for the 5-minute poller, and updates each record's status. On Amazon SES the domain becomes `verified` once SES itself accepts the DKIM record; elsewhere once ownership + SPF pass.","parameters":[{"name":"id","in":"path","required":true,"description":"Domain id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Updated statuses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments":{"get":{"tags":["Segments"],"operationId":"listSegments","summary":"List segments","description":"Every segment in the project — dynamic and static — with its conditions and cached `memberCount`.\n\nUnpaginated. The count is as of the last refresh, not live: a dynamic segment whose contacts changed since then reports a stale number until `POST /v1/segments/{id}/refresh` recomputes it.","responses":{"200":{"description":"All segments.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Segment"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Segments"],"operationId":"createSegment","summary":"Create a segment","description":"Dynamic segments require `conditions` and are computed immediately; static segments start empty and are managed via the members endpoints.\n\nPass `dryRun: true` to preview instead of create — identical to `POST /v1/segments/preview`, and `name` is not required. The alias exists because the MCP `create_segment` tool takes `dryRun` and tells agents to run one first: until this was honoured, carrying that parameter into the REST call created a live segment on every \"preview\", and deleting it afterwards did not undo the `segment.entered` activity it had already written onto contacts.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":120,"description":"Required unless `dryRun` is true."},"description":{"type":"string","maxLength":500},"type":{"type":"string","enum":["dynamic","static"],"default":"dynamic"},"conditions":{"$ref":"#/components/schemas/SegmentConditions"},"dryRun":{"type":"boolean","description":"Preview only — nothing is created. Returns the same shape as `POST /v1/segments/preview`."}}}}}},"responses":{"200":{"description":"Preview, when `dryRun` is true.","content":{"application/json":{"schema":{"type":"object","properties":{"dryRun":{"type":"boolean","enum":[true]},"segmentId":{"type":"null","description":"Always null — a dry run creates nothing."},"count":{"type":"integer"},"sample":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}}},"required":["dryRun","count"]}}}},"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Segment"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments/preview":{"post":{"tags":["Segments"],"operationId":"previewSegment","summary":"Preview conditions","description":"Live member-count preview for a condition tree (used while building a segment). Returns the count and a small sample of matching contacts.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"conditions":{"$ref":"#/components/schemas/SegmentConditions"}},"required":["conditions"]}}}},"responses":{"200":{"description":"Match count and sample.","content":{"application/json":{"schema":{"type":"object","properties":{"count":{"type":"integer"},"sample":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}}},"required":["count","sample"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments/{id}":{"get":{"tags":["Segments"],"operationId":"getSegment","summary":"Get a segment","description":"One segment: its type, its condition tree if dynamic, and its cached member count.\n\nThe count is from the last refresh rather than computed on read. For the actual contacts, use `GET /v1/segments/{id}/members`.","parameters":[{"name":"id","in":"path","required":true,"description":"Segment id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The segment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Segment"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Segments"],"operationId":"updateSegment","summary":"Update a segment","description":"Changing `conditions` recomputes membership immediately. Static segments reject `conditions`.","parameters":[{"name":"id","in":"path","required":true,"description":"Segment id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":120},"description":{"type":["string","null"],"maxLength":500},"conditions":{"$ref":"#/components/schemas/SegmentConditions"}}}}}},"responses":{"200":{"description":"The updated segment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Segment"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Segments"],"operationId":"deleteSegment","summary":"Delete a segment","description":"Deletes the segment and every membership row belonging to it. The contacts themselves are untouched — membership is a join, and only the join is removed.\n\nNothing checks whether a campaign or workflow still names this `segmentId`; those references are left dangling and resolve to no audience.","parameters":[{"name":"id","in":"path","required":true,"description":"Segment id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments/{id}/refresh":{"post":{"tags":["Segments"],"operationId":"refreshSegment","summary":"Recompute membership","description":"Recomputes a dynamic segment against the current contact graph and returns how many contacts `entered`, how many `exited`, and the resulting `memberCount`.\n\n**This has side effects beyond the count.** Entering and exiting emit `segment.entered` and `segment.exited`, so any workflow triggered on those events is set off by this call and any webhook subscribed to them fires. Refreshing a large segment after a bulk import is therefore an automation event, not a read.\n\nStatic segments are not recomputed — their membership is whatever was added by hand — so for those the call only refreshes the stored count and reports `entered: 0, exited: 0`.","parameters":[{"name":"id","in":"path","required":true,"description":"Segment id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Membership delta.","content":{"application/json":{"schema":{"type":"object","properties":{"entered":{"type":"integer"},"exited":{"type":"integer"},"memberCount":{"type":"integer"}},"required":["entered","exited","memberCount"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments/{id}/members":{"get":{"tags":["Segments"],"operationId":"listSegmentMembers","summary":"List members","description":"Current members joined to their contacts, newest entries first.","parameters":[{"name":"id","in":"path","required":true,"description":"Segment id.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"Current members.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SegmentMember"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Segments"],"operationId":"addSegmentMembers","summary":"Add members (static segments)","description":"Add up to 1,000 contacts by email. Only valid for static segments — dynamic membership is computed from conditions.","parameters":[{"name":"id","in":"path","required":true,"description":"Segment id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"emails":{"type":"array","items":{"type":"string","format":"email"},"minItems":1,"maxItems":1000}},"required":["emails"]}}}},"responses":{"200":{"description":"Add summary.","content":{"application/json":{"schema":{"type":"object","properties":{"added":{"type":"integer"},"missing":{"type":"array","items":{"type":"string"},"description":"Addresses with no matching contact."}},"required":["added","missing"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/segments/{id}/members/{contactId}":{"delete":{"tags":["Segments"],"operationId":"removeSegmentMember","summary":"Remove a member (static segments)","description":"Marks a contact as exited from a **static** segment and emits `segment.exited`.\n\nStatic only: membership of a dynamic segment is derived from its conditions, so removing someone by hand would be undone by the next refresh. Attempting it answers 422 — edit the conditions instead.\n\nThe contact record is untouched and the same contact can be added back later with `POST /v1/segments/{id}/members`.","parameters":[{"name":"id","in":"path","required":true,"description":"Segment id.","schema":{"type":"string"}},{"name":"contactId","in":"path","required":true,"description":"Contact id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Removed.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns":{"get":{"tags":["Campaigns"],"operationId":"listCampaigns","summary":"List campaigns","description":"Every campaign in the project — drafts, scheduled, sending, and sent — with its audience and engagement counters.\n\nUnpaginated. Counters are aggregates maintained as the campaign sends; per-recipient detail is at `GET /v1/campaigns/{id}/recipients`.","responses":{"200":{"description":"All campaigns.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Campaign"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Campaigns"],"operationId":"createCampaign","summary":"Create a campaign","description":"Created in `draft`. Audience is everyone (`all`), a segment (`segment` + `segmentId`), or ad-hoc conditions (`filter`).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":160},"subject":{"type":"string","maxLength":500},"previewText":{"type":["string","null"],"maxLength":200,"description":"Preheader — the preview line after the subject in the inbox. Supports `{{variable}}` placeholders."},"body":{"type":"string","description":"HTML with `{{variable}}` placeholders."},"bodyBlocks":{"type":["string","array","object","null"],"additionalProperties":true,"description":"Optional design the visual editor compiled `body` from — a JSON string of the GrapesJS project (or a legacy block array/object). Stored verbatim and never sent."},"fromEmail":{"type":"string","format":"email"},"fromName":{"type":"string","maxLength":120},"replyTo":{"type":["string","null"],"format":"email","description":"Sets the Reply-To header on every send."},"audience":{"type":"string","enum":["all","segment","filter"],"default":"all"},"segmentId":{"type":"string","description":"Required when `audience` is `\"segment\"`."},"filter":{"$ref":"#/components/schemas/SegmentConditions","description":"Ad-hoc audience conditions; required when `audience` is `\"filter\"`."},"abSubjectB":{"type":"string","maxLength":500,"description":"Subject-line B for an A/B experiment; the winner is auto-sent to the remainder."},"abSamplePct":{"type":"integer","minimum":5,"maximum":50,"description":"Percent of the audience used for the A/B sample (default 20)."}},"required":["name","subject","body","fromEmail"]}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Campaign"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/audience-count":{"post":{"tags":["Campaigns"],"operationId":"countCampaignAudience","summary":"Count an audience before creating a campaign","description":"How many contacts the given targeting would reach right now — subscribed, non-suppressed, resolved exactly the way a real send resolves it. Powers the live recipient count in the campaign builder.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"audience":{"type":"string","enum":["all","segment","filter"],"default":"all"},"segmentId":{"type":"string","description":"Required when `audience` is `\"segment\"`."},"filter":{"$ref":"#/components/schemas/SegmentConditions","description":"Required when `audience` is `\"filter\"`."}}}}}},"responses":{"200":{"description":"The live recipient count.","content":{"application/json":{"schema":{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/{id}":{"get":{"tags":["Campaigns"],"operationId":"getCampaign","summary":"Get a campaign","description":"Includes `estimatedAudience` (live count) for drafts and scheduled campaigns; frozen totals once sending.","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The campaign.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Campaign"},{"type":"object","properties":{"estimatedAudience":{"type":["integer","null"]}}}]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Campaigns"],"operationId":"updateCampaign","summary":"Update a campaign","description":"Only draft campaigns can be edited.","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":160},"subject":{"type":"string","maxLength":500},"previewText":{"type":["string","null"],"maxLength":200,"description":"Preheader — the preview line after the subject in the inbox. Supports `{{variable}}` placeholders."},"body":{"type":"string","description":"HTML with `{{variable}}` placeholders."},"bodyBlocks":{"type":["string","array","object","null"],"additionalProperties":true,"description":"Optional design the visual editor compiled `body` from — a JSON string of the GrapesJS project (or a legacy block array/object). Stored verbatim and never sent."},"fromEmail":{"type":"string","format":"email"},"fromName":{"type":"string","maxLength":120},"replyTo":{"type":["string","null"],"format":"email","description":"Sets the Reply-To header on every send."},"audience":{"type":"string","enum":["all","segment","filter"],"default":"all"},"segmentId":{"type":"string","description":"Required when `audience` is `\"segment\"`."},"filter":{"$ref":"#/components/schemas/SegmentConditions","description":"Ad-hoc audience conditions; required when `audience` is `\"filter\"`."},"abSubjectB":{"type":"string","maxLength":500,"description":"Subject-line B for an A/B experiment; the winner is auto-sent to the remainder."},"abSamplePct":{"type":"integer","minimum":5,"maximum":50,"description":"Percent of the audience used for the A/B sample (default 20)."}}}}}},"responses":{"200":{"description":"The updated campaign.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Campaign"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Not a draft (`invalid_state`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Campaigns"],"operationId":"deleteCampaign","summary":"Delete a campaign","description":"Deletes the campaign and its recipient rows.\n\nA campaign that is currently `sending` answers 409 — cancel it with `POST /v1/campaigns/{id}/cancel` first, so the in-flight batch stops rather than being orphaned mid-send.\n\nMessages already dispatched are not recalled. They stay in the send log and keep recording opens and clicks; what goes away is the campaign's own aggregate view of them.","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Cancel a sending campaign before deleting it (`invalid_state`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/{id}/send":{"post":{"tags":["Campaigns"],"operationId":"sendCampaign","summary":"Send or schedule","description":"Send now (omit the body) or schedule for later with a future `scheduledFor`. The from domain must be verified.","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"scheduledFor":{"type":"string","format":"date-time"}}}}}},"responses":{"200":{"description":"The campaign, now `sending` (or `scheduled`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Campaign"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"From domain not verified (`domain_not_verified`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Already sending/sent (`invalid_state`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/{id}/cancel":{"post":{"tags":["Campaigns"],"operationId":"cancelCampaign","summary":"Cancel a campaign","description":"Stops a scheduled or in-flight campaign; remaining recipients are skipped.","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The cancelled campaign.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Campaign"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Not cancellable in its current state (`invalid_state`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/{id}/recipients":{"get":{"tags":["Campaigns"],"operationId":"listCampaignRecipients","summary":"List recipients","description":"The recipient snapshot with per-recipient outcome (and A/B variant when an experiment ran).","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":500,"default":100}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["pending","sent","skipped"]}}],"responses":{"200":{"description":"Recipients.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CampaignRecipient"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/{id}/links":{"get":{"tags":["Campaigns"],"operationId":"listCampaignLinks","summary":"Per-link click counts","description":"Click counts per URL, from `email.clicked` events, top 50.","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Links by clicks, descending.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string"},"clicks":{"type":"integer"}},"required":["url","clicks"]}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/{id}/duplicate":{"post":{"tags":["Campaigns"],"operationId":"duplicateCampaign","summary":"Duplicate a campaign","description":"Clones content and targeting as a new draft. Stats, schedule, send state, and A/B decision are reset.","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"responses":{"201":{"description":"The new draft campaign.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Campaign"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/{id}/resend-non-openers":{"post":{"tags":["Campaigns"],"operationId":"resendNonOpeners","summary":"Re-send to non-openers","description":"Creates a follow-up draft campaign targeting recipients who never opened, with an optional new subject.","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"subject":{"type":"string","maxLength":500}}}}}},"responses":{"201":{"description":"The follow-up campaign (draft).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Campaign"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/campaigns/{id}/test-send":{"post":{"tags":["Campaigns"],"operationId":"testSendCampaign","summary":"Test-send a campaign","description":"Sends the campaign content to one address, subject prefixed with \"[Test]\".","parameters":[{"name":"id","in":"path","required":true,"description":"Campaign id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"to":{"type":"string","format":"email"}},"required":["to"]}}}},"responses":{"200":{"description":"The send outcome.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendResult"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows":{"get":{"tags":["Workflows"],"operationId":"listWorkflows","summary":"List workflows","description":"Every workflow in the project — draft, active, and paused — with its trigger and its full node graph.\n\nUnpaginated. Only `active` workflows enrol anyone: a `draft` or `paused` workflow whose trigger matches is reported in the `skipped` array of `POST /v1/events` rather than running.","responses":{"200":{"description":"All workflows.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Workflow"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Workflows"],"operationId":"createWorkflow","summary":"Create a workflow","description":"Created in `draft`. The graph must have unique node ids, edges to existing nodes, and at least one trigger node.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":160},"trigger":{"type":"string","description":"`event:<name>`, `contact.created`, `segment:enter:<ref>`, or `segment:exit:<ref>`."},"graph":{"$ref":"#/components/schemas/WorkflowGraph"},"reentry":{"type":"string","enum":["never","after_exit","always"],"default":"after_exit"}},"required":["name","trigger","graph"]}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Workflow"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows/{id}":{"get":{"tags":["Workflows"],"operationId":"getWorkflow","summary":"Get a workflow","description":"Includes `stepStats`: per-node counts grouped by outcome.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The workflow with aggregates.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Workflow"},{"type":"object","properties":{"stepStats":{"type":"array","items":{"type":"object","properties":{"nodeId":{"type":"string"},"nodeType":{"type":"string"},"outcome":{"type":"string"},"count":{"type":"integer"}}}}}}]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Workflows"],"operationId":"updateWorkflow","summary":"Update a workflow","description":"Also used to activate or pause via `status`.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":160},"trigger":{"type":"string"},"graph":{"$ref":"#/components/schemas/WorkflowGraph"},"reentry":{"type":"string","enum":["never","after_exit","always"]},"status":{"type":"string","enum":["draft","active","paused"]}}}}}},"responses":{"200":{"description":"The updated workflow.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Workflow"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Workflows"],"operationId":"deleteWorkflow","summary":"Delete a workflow","description":"Deletes the workflow definition.\n\n**Executions already in flight are not deleted and not cancelled by this call.** An enrolment sitting on a delay keeps its wake timer, so removing a workflow is not the same as stopping the automation it is running. Call `POST /v1/workflows/{id}/executions/cancel-all` first if you want the contacts currently inside it to stop where they are.\n\nPausing with `PATCH /v1/workflows/{id}` is the reversible way to stop new enrolments.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows/{id}/duplicate":{"post":{"tags":["Workflows"],"operationId":"duplicateWorkflow","summary":"Duplicate a workflow","description":"Clones the graph, trigger, and re-entry policy as a new `paused` workflow — never auto-activated. Stats are reset.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}}],"responses":{"201":{"description":"The new paused workflow.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Workflow"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows/{id}/enroll":{"post":{"tags":["Workflows"],"operationId":"enrollInWorkflow","summary":"Enroll a contact","description":"Manually start an execution for a contact (the workflow must be `active`). Re-entry is subject to the workflow's `reentry` policy.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"}},"required":["email"]}}}},"responses":{"201":{"description":"The new execution.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowExecution"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Workflow not active (`invalid_state`) or contact not enrolled (`not_enrolled`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows/{id}/executions":{"get":{"tags":["Workflows"],"operationId":"listWorkflowExecutions","summary":"List executions","description":"Enrolments in one workflow, newest first — each with the contact's email, where it currently sits in the graph, and what it is waiting on. Filter with `status` and cap with `limit`.\n\nThis is the log to read when a workflow \"did nothing\". An empty list means nothing ever enrolled, which is a trigger problem — check `POST /v1/events`'s `triggered` and `skipped` arrays. A list of `waiting` rows means enrolment worked and the contacts are sitting on a delay or an event that has not arrived, which is a graph problem. The two look identical from the outside and have nothing in common as fixes.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["active","waiting","completed","exited","failed","cancelled"]}}],"responses":{"200":{"description":"Executions, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"contactId":{"type":"string"},"contactEmail":{"type":["string","null"]},"status":{"type":"string","enum":["active","waiting","completed","exited","failed","cancelled"]},"currentNodeId":{"type":["string","null"]},"wakeAt":{"type":["string","null"],"format":"date-time"},"waitEventName":{"type":["string","null"]},"enteredAt":{"type":"string","format":"date-time"},"completedAt":{"type":["string","null"],"format":"date-time"}},"required":["id","contactId","status","enteredAt"]}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows/{id}/executions/cancel-all":{"post":{"tags":["Workflows"],"operationId":"cancelAllWorkflowExecutions","summary":"Cancel every in-flight execution","description":"Cancels every `active` or `waiting` execution for this workflow. Already-finished executions are untouched.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}}],"responses":{"200":{"description":"How many executions were cancelled.","content":{"application/json":{"schema":{"type":"object","properties":{"cancelled":{"type":"integer"}},"required":["cancelled"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows/{id}/executions/{executionId}/cancel":{"post":{"tags":["Workflows"],"operationId":"cancelWorkflowExecution","summary":"Cancel one execution","description":"Cancels an `active` or `waiting` execution. No-op error if it has already finished.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}},{"name":"executionId","in":"path","required":true,"description":"Execution id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Cancelled.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Already finished and can't be cancelled (`invalid_state`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows/executions/{executionId}":{"get":{"tags":["Workflows"],"operationId":"getWorkflowExecution","summary":"Get an execution","description":"One contact's walk through the graph: the execution plus its ordered step runs.","parameters":[{"name":"executionId","in":"path","required":true,"description":"Execution id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The execution with step log.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WorkflowExecution"},{"type":"object","properties":{"steps":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowStepRun"}}}}]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhooks":{"get":{"tags":["Webhooks"],"operationId":"listWebhooks","summary":"List endpoints","description":"Every endpoint registered for the project, with the event patterns each subscribes to and whether it is enabled.\n\n**This response includes each endpoint's `whsec_…` signing secret**, not just the create call. That makes rotation-free recovery easy — you can always read the secret back — but it also makes this response a credential, so treat it like one and never proxy it to a browser.","responses":{"200":{"description":"All endpoints.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Webhooks"],"operationId":"createWebhook","summary":"Create an endpoint","description":"Registers a URL to receive event deliveries. The response includes the signing `secret` (`whsec_…`). Deliveries retry with backoff for ~24h and carry `X-Volanea-Signature: t=<ms>,v1=<hmac-sha256 hex of \"<t>.<body>\">`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"description":{"type":"string","maxLength":500},"events":{"type":"array","items":{"type":"string"},"maxItems":50,"description":"Patterns like `\"email.*\"` or exact names. Omit for all events."}},"required":["url"]}}}},"responses":{"201":{"description":"Created, with the signing secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhooks/{id}":{"patch":{"tags":["Webhooks"],"operationId":"updateWebhook","summary":"Update an endpoint","description":"Changes an endpoint's URL, description, event patterns, or enabled flag. Omitted fields are left as they are.\n\nThe signing secret is **not** rotated and cannot be set here, so an endpoint you edit keeps verifying against the secret you already deployed.\n\n`enabled: false` is the reversible way to stop deliveries — the endpoint and its delivery history survive, and flipping it back resumes without re-registering.","parameters":[{"name":"id","in":"path","required":true,"description":"Endpoint id.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"description":{"type":"string","maxLength":500},"events":{"type":"array","items":{"type":"string"},"maxItems":50},"enabled":{"type":"boolean"}}}}}},"responses":{"200":{"description":"The updated endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Webhooks"],"operationId":"deleteWebhook","summary":"Delete an endpoint","description":"Removes the endpoint and its entire delivery history.\n\nDeliveries queued for retry are dropped with it, so nothing further reaches that URL. If you want to stop deliveries but keep the log — and the option of turning it back on — PATCH `enabled: false` instead.","parameters":[{"name":"id","in":"path","required":true,"description":"Endpoint id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhooks/{id}/deliveries":{"get":{"tags":["Webhooks"],"operationId":"listWebhookDeliveries","summary":"Delivery log","description":"Deliveries for one endpoint, newest first, cursor-paginated.","parameters":[{"name":"id","in":"path","required":true,"description":"Endpoint id.","schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"description":"Pagination cursor — an OPAQUE token. Take the `nextCursor` from the previous page and send it back unchanged. Do not parse it, do not coerce it to a number, and do not construct one: the format is `<unixMs>.<objectId>` today and may change. A cursor that cannot be parsed is rejected with 422 `validation_error` rather than silently restarting at page one.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (1–100, default 30).","schema":{"type":"integer","minimum":1,"maximum":100,"default":30}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["pending","success","failed"]}}],"responses":{"200":{"description":"A page of deliveries.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"nextCursor":{"type":["string","null"],"description":"Opaque token. Pass back as `cursor` verbatim to fetch the next page; `null` when there are no more rows."}},"required":["data","nextCursor"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhooks/deliveries/{id}/replay":{"post":{"tags":["Webhooks"],"operationId":"replayWebhookDelivery","summary":"Replay a delivery","description":"Resets a delivery (any status) and re-attempts it immediately.","parameters":[{"name":"id","in":"path","required":true,"description":"Delivery id.","schema":{"type":"string"}}],"responses":{"200":{"description":"The delivery after the replay attempt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/stats":{"get":{"tags":["Stats"],"operationId":"projectStats","summary":"Project engagement rollup","description":"Sends, delivery, opens, clicks, bounces and unsubscribes for the whole project over a window, with the previous window alongside for comparison and a gap-free daily series for charting.\n\n**Test and live are separate populations**, and which one you get is decided by the key you authenticate with rather than by a parameter — a `sk_test_…` key can never report production figures. This matters more than it sounds: in a project that has been integrated against, more than half the rows in the send log can be test sends, and totalling `GET /v1/emails` without filtering them produces a delivery rate that is wrong by tens of points.\n\n**Rates compare in points, sends compare in percent.** An open rate moving 35% → 38% is reported as `openPoints: 3`, because \"open rate up 8%\" is ambiguous and \"up 3 points\" is not. `sentPercent` is a true percentage change, and is `null` rather than 0 when the previous window had no sends at all — those are different facts.\n\n`delivered` counts every message that reached the mailbox, so it includes the ones that went on to be opened and clicked. Statuses only move forward, so each stage necessarily contains the ones past it.","parameters":[{"name":"days","in":"query","required":false,"description":"Window length in days (1–365, default 30). The comparison window is the same length immediately before it.","schema":{"type":"integer","minimum":1,"maximum":365,"default":30}}],"responses":{"200":{"description":"The rollup.","content":{"application/json":{"schema":{"type":"object","properties":{"window":{"type":"object","properties":{"days":{"type":"integer"},"from":{"type":"string","format":"date-time"},"to":{"type":"string","format":"date-time"}}},"testMode":{"type":"boolean","description":"Which population these numbers describe, derived from the key."},"totals":{"type":"object","properties":{"sent":{"type":"integer"},"delivered":{"type":"integer"},"opened":{"type":"integer"},"clicked":{"type":"integer"},"bounced":{"type":"integer"},"complained":{"type":"integer"},"failed":{"type":"integer"},"unsubscribed":{"type":"integer"}}},"rates":{"type":"object","description":"Percentages. `delivery` and `bounce` are of `sent`; `open`, `click` and `unsubscribe` are of `delivered`.","properties":{"delivery":{"type":"number"},"open":{"type":"number"},"click":{"type":"number"},"bounce":{"type":"number"},"unsubscribe":{"type":"number"}}},"previous":{"type":"object","description":"The same measures for the preceding window of equal length."},"change":{"type":"object","properties":{"sentPercent":{"type":["number","null"],"description":"Percentage change in volume; null when the previous window had no sends."},"deliveryPoints":{"type":"number"},"openPoints":{"type":"number"},"clickPoints":{"type":"number"}}},"daily":{"type":"array","description":"One entry per day, oldest first, split by source. Days with no sends are present with zeros so a chart has no holes.","items":{"type":"object","properties":{"day":{"type":"string","description":"YYYY-MM-DD."},"transactional":{"type":"integer"},"campaign":{"type":"integer"},"workflow":{"type":"integer"},"inbound":{"type":"integer"},"total":{"type":"integer"}}}}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/workflows/{id}/stats":{"get":{"tags":["Stats"],"operationId":"workflowStats","summary":"Workflow funnel","description":"The enrolment funnel for one workflow: entered → delivered → opened → clicked → converted, each with the drop from the stage before it.\n\n**The funnel counts distinct contacts; `totals` counts messages.** A three-email drip sends three messages to one person, so a funnel counted in messages would report more delivered than entered and stop being a funnel at all. The two numbers differ for that reason, not by mistake.\n\n**Conversion is whatever you say it is.** Volanea stores no conversion of its own — what counts as one is a property of your product. Name any event you already send to `POST /v1/events` as `conversionEvent` and it becomes the last stage. Only conversions that happened at or after the contact entered are counted; attributing a purchase that predates the enrolment would flatter every funnel aimed at people who already buy. Omit the parameter and the stage is absent rather than zero, because \"no goal set\" and \"nobody converted\" are different answers.\n\nEngagement is aggregated from the send log on read rather than kept as counters on the workflow, so these numbers are real for workflows that ran long before this endpoint existed.","parameters":[{"name":"id","in":"path","required":true,"description":"Workflow id.","schema":{"type":"string"}},{"name":"days","in":"query","required":false,"description":"Window length in days (1–365, default 30).","schema":{"type":"integer","minimum":1,"maximum":365,"default":30}},{"name":"conversionEvent","in":"query","required":false,"description":"One of your own event names, e.g. `order.placed`. Adds the `converted` stage.","schema":{"type":"string","maxLength":128}}],"responses":{"200":{"description":"The funnel.","content":{"application/json":{"schema":{"type":"object","properties":{"workflowId":{"type":"string"},"name":{"type":"string"},"status":{"type":"string"},"window":{"type":"object","properties":{"days":{"type":"integer"},"from":{"type":"string","format":"date-time"},"to":{"type":"string","format":"date-time"}}},"testMode":{"type":"boolean"},"funnel":{"type":"array","description":"Stages in order, in distinct contacts.","items":{"type":"object","properties":{"stage":{"type":"string","enum":["entered","delivered","opened","clicked","converted"]},"count":{"type":"integer"},"rateOfEntered":{"type":"number","description":"Percentage of the contacts that entered."},"dropFromPrevious":{"type":["integer","null"],"description":"Contacts lost since the previous stage; null on the first."}}}},"totals":{"type":"object","description":"Messages, not people.","properties":{"messages":{"type":"integer"},"unsubscribed":{"type":"integer"},"byStatus":{"type":"object","additionalProperties":{"type":"integer"}}}},"executions":{"type":"object","additionalProperties":{"type":"integer"},"description":"Enrolment counts by execution status."},"conversionEvent":{"type":"string","description":"Echoed back when one was named."}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/suppressions":{"get":{"tags":["Suppressions"],"operationId":"listSuppressions","summary":"List suppressions","description":"The do-not-send list, newest first, cursor-paginated. Filter by `reason` to separate hard bounces from complaints, unsubscribes, and manual blocks.\n\nEvery send checks this list first, and a suppressed recipient comes back inside a 200 as `{ to, skipped: \"suppressed\" }` rather than as an error — so a caller reading `messages` as \"delivered\" counts suppressions as successes.\n\nTo ask about a single address rather than page the whole list, use `GET /v1/suppressions/check`.","parameters":[{"name":"cursor","in":"query","required":false,"description":"Pagination cursor — an OPAQUE token. Take the `nextCursor` from the previous page and send it back unchanged. Do not parse it, do not coerce it to a number, and do not construct one: the format is `<unixMs>.<objectId>` today and may change. A cursor that cannot be parsed is rejected with 422 `validation_error` rather than silently restarting at page one.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (1–200, default 50).","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"reason","in":"query","required":false,"schema":{"type":"string","enum":["hard_bounce","complaint","manual","unsubscribe"]}}],"responses":{"200":{"description":"A page of suppressions.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Suppression"}},"nextCursor":{"type":["string","null"],"description":"Opaque token. Pass back as `cursor` verbatim to fetch the next page; `null` when there are no more rows."}},"required":["data","nextCursor"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["Suppressions"],"operationId":"addSuppression","summary":"Suppress an address","description":"Adds an address to the do-not-send list (reason `manual`). All future sends to it are skipped.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"}},"required":["email"]}}}},"responses":{"201":{"description":"The suppression.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Suppression"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/suppressions/check":{"get":{"tags":["Suppressions"],"operationId":"checkSuppression","summary":"Check an address","description":"Is this address suppressed, and why?","parameters":[{"name":"email","in":"query","required":true,"schema":{"type":"string","format":"email"}}],"responses":{"200":{"description":"The suppression state.","content":{"application/json":{"schema":{"type":"object","properties":{"suppressed":{"type":"boolean"},"suppression":{"oneOf":[{"$ref":"#/components/schemas/Suppression"},{"type":"null"}]}},"required":["suppressed","suppression"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/suppressions/{id}":{"get":{"tags":["Suppressions"],"operationId":"lookupSuppression","summary":"Look up one address or suppression","description":"Path form of `GET /v1/suppressions/check`, accepting either a suppression id or a URL-encoded email address.\n\nUse it as a pre-send check. Note that a clean result here does not by itself mean a send will go out: `POST /v1/send` also skips a recipient whose *contact* is unsubscribed (`skipped: \"unsubscribed\"`), which is a different record from a suppression.","parameters":[{"name":"id","in":"path","required":true,"description":"Suppression id, or a URL-encoded email address.","schema":{"type":"string"}}],"responses":{"200":{"description":"Whether the address is suppressed. Not being suppressed is a 200, not a 404 — a 404 would be ambiguous with a missing route.","content":{"application/json":{"schema":{"type":"object","properties":{"suppressed":{"type":"boolean"},"suppression":{"oneOf":[{"$ref":"#/components/schemas/Suppression"},{"type":"null"}],"description":"Null when the address is not suppressed."}},"required":["suppressed"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Suppressions"],"operationId":"removeSuppression","summary":"Remove a suppression","description":"Removes an address from the do-not-send list, making it mailable again.\n\n**Read the `reason` before calling this.** Clearing an `unsubscribe` you know was a mistake is routine. Clearing a `bounce` or `complaint` means deliberately re-sending to an address that already hard-bounced or pressed \"report spam\", which is the behaviour mailbox providers measure you on — the entry is protecting your sending reputation, not obstructing you.\n\nProvider-generated suppressions come back on their own: the next bounce or complaint re-creates the entry.","parameters":[{"name":"id","in":"path","required":true,"description":"Suppression id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Removed.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/verify":{"post":{"tags":["Verify"],"operationId":"verifyEmail","summary":"Validate an email address","description":"Checks syntax, MX records, disposable domains, and role accounts. `checks.mx` is `\"unknown\"` when the resolver is unavailable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string"}},"required":["email"]}}}},"responses":{"200":{"description":"The verdict.","content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","description":"The normalized (trimmed, lowercased) address."},"valid":{"type":"boolean"},"checks":{"type":"object","properties":{"syntax":{"type":"boolean"},"mx":{"oneOf":[{"type":"boolean"},{"type":"string","enum":["unknown"]}]},"disposable":{"type":"boolean"},"roleAccount":{"type":"boolean"}},"required":["syntax","mx","disposable","roleAccount"]}},"required":["email","valid","checks"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/billing":{"get":{"tags":["Billing"],"operationId":"getBilling","summary":"Usage, limits, and plan","description":"Current-month usage, the monthly caps, the plan and its per-cycle email quota, computed reputation, and subscription state in one call.","responses":{"200":{"description":"The billing overview.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Billing"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Billing"],"operationId":"setBillingLimit","summary":"Set or clear the overall and/or per-category monthly caps","description":"Provide any subset of the fields — only the ones present are changed. At least one is required. `plan: \"free\"` is the downgrade path (schedules the subscription cancellation; the plan drops when the period ends). Paid plans cannot be set here — use `POST /api/dodo/checkout`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"monthlyLimit":{"type":["integer","null"],"minimum":1,"maximum":100000000,"description":"`null` removes the overall cap."},"monthlyLimitTransactional":{"type":["integer","null"],"minimum":1,"maximum":100000000},"monthlyLimitCampaign":{"type":["integer","null"],"minimum":1,"maximum":100000000},"monthlyLimitWorkflow":{"type":["integer","null"],"minimum":1,"maximum":100000000},"monthlyLimitInbound":{"type":["integer","null"],"minimum":1,"maximum":100000000},"plan":{"type":"string","enum":["free"],"description":"Downgrade only."}}}}}},"responses":{"200":{"description":"Echoes back whichever fields were changed.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/dodo/checkout":{"post":{"tags":["Internal"],"operationId":"createDodoCheckout","summary":"Start a checkout for a paid plan","description":"Creates a Dodo-hosted checkout session for the given plan and returns its URL. The plan resolves to a Dodo product server-side. The plan flips only when the subscription webhook confirms payment. Hosted offering only — 409 when Dodo isn't configured.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"plan":{"type":"string","enum":["starter","growth"],"description":"Enterprise is contact-us only."},"email":{"type":"string","format":"email","description":"Optional — pre-fills the hosted checkout's customer field."},"name":{"type":"string"}},"required":["plan"]}}}},"responses":{"200":{"description":"The checkout session to open.","content":{"application/json":{"schema":{"type":"object","properties":{"checkout_url":{"type":"string","description":"Send the browser here."},"plan":{"type":"string","enum":["starter","growth"]}},"required":["checkout_url","plan"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Billing disabled, or the project already has an active subscription (manage it from the portal).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/dodo/portal":{"get":{"tags":["Internal"],"operationId":"createDodoPortalSession","summary":"Mint a customer-portal session","description":"Returns a short-lived URL to Dodo's self-service portal (update payment method, change plan, cancel, invoices). 404 until the project has subscribed at least once. `POST /v1/billing/portal` is the same operation at its historic path.","responses":{"200":{"description":"The portal session.","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/inbound":{"post":{"tags":["Inbound"],"operationId":"ingestInbound","summary":"Ingest an inbound email","description":"Store an inbound message, upsert the sender as a contact, and emit `email.received` (which fans out to webhooks and workflow triggers). Normally called by the SMTP relay's unauthenticated listener, but you can post parsed mail yourself.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"from":{"type":"string","format":"email"},"fromName":{"type":"string","maxLength":200},"to":{"type":"string","format":"email"},"subject":{"type":"string","maxLength":1000,"default":"(no subject)"},"html":{"type":"string"},"text":{"type":"string"}},"required":["from","to"]}}}},"responses":{"201":{"description":"Stored.","content":{"application/json":{"schema":{"type":"object","properties":{"emailId":{"type":"string","description":"The stored email id."}},"required":["emailId"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Inbound"],"operationId":"listInbound","summary":"List received mail","description":"Received messages, newest first, cursor-paginated. Before this existed, inbound mail was reachable only as `GET /v1/emails?source=inbound`. Note that the recipient's local part preserves its case (RFC 5321 §2.4), so a `ticket+AbC123@…` routing token survives the round trip and can be matched against what `POST /v1/send` put in `replyTo`; only the domain is lowercased.","parameters":[{"name":"cursor","in":"query","required":false,"description":"Pagination cursor — an OPAQUE token. Take the `nextCursor` from the previous page and send it back unchanged. Do not parse it, do not coerce it to a number, and do not construct one: the format is `<unixMs>.<objectId>` today and may change. A cursor that cannot be parsed is rejected with 422 `validation_error` rather than silently restarting at page one.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (1–200, default 50).","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"A page of received messages.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InboundMessage"}},"nextCursor":{"type":["string","null"],"description":"Opaque token. Pass back as `cursor` verbatim to fetch the next page; `null` when there are no more rows."}},"required":["data","nextCursor"]}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Request validation failed (code `validation_error`, 422).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}