Skip to content

Email channel ​

Spanish version: Canal de email.

Email is available in core v0.97.0 (openapi/SOURCE.json). It is a governed outbound channel: an integration records addresses and their provenance, uses approved templates, and starts an email_blast campaign or relies on a preconfigured disposition effect. There is no direct POST /v1/email/messages send endpoint. Contract: POST|GET /v1/email/addresses, GET /v1/email/messages, POST /v1/email-templates, and CampaignWrite.campaign_type.

The channel starts off

GET /v1/me returns capabilities.email_channel. It is true only when the tenant's email_channel_enabled flag is on; false is the default and means the sender and the email_blast runner write no ledger row and call no provider. A missing key means that the deployed core predates the capability, not that the channel is available. Contract: GET /v1/me → capabilities.email_channel.

Record an address with provenance ​

Create an address with POST /v1/email/addresses. The required fields are contact_id, address, provenance, and obtained_at. All provenance types except imported_unverified also require non-blank provenance_evidence; creditor_notice additionally requires notice_sent_at. An imported_unverified address is stored but is never sendable. Contract: POST /v1/email/addresses request fields provenance, provenance_evidence, and notice_sent_at.

Clients cannot mark an address as sendable. Core computes sendable and ineligible_reason when the address is read. The closed reasons include missing evidence, an open notice period, a non-public domain, suppression, an unreadable suppression state, and an erased address (address_erased). Contract: EmailAddress.sendable and EmailAddress.ineligible_reason.

After a privacy erasure of the person, the address is no longer readable: address comes back as null and ineligible_reason as address_erased, so the address is never sendable. Do not assume address is a string when you list or read addresses. The console shows these rows as "Address erased" ("Dirección borrada" in Spanish). Contract: EmailAddress.address and EmailAddress.ineligible_reason. See erase one person.

json
{
  "contact_id": "00000000-0000-4000-8000-000000000001",
  "address": "[email protected]",
  "provenance": "consumer_consent",
  "provenance_evidence": "Written consent recorded in the account file",
  "obtained_at": "2026-10-01T12:00:00Z"
}

Use GET /v1/email/addresses?contact_id=... to list a contact's addresses and GET /v1/email/addresses/{id} to read the current eligibility of one address. Contract: GET /v1/email/addresses and GET /v1/email/addresses/{id}.

Suppression and cease-and-desist ​

GET /v1/email/suppressions?address=... returns the append-only suppression history of one normalized address and the resulting suppressed or clear status. The latest ledger action wins. There is deliberately no /v1 write route: unsubscribe, bounce, and complaint processing write suppressions, while a lift requires the consumer's own provenance. Contract: GET /v1/email/suppressions response fields status and email_suppressions.

A written cease-and-desist on file for the consumer is a consumer-level record, not a suppression row: the send gate blocks email to every address of that consumer, and the address itself can still read sendable: true. Contract: GET /v1/consumers/{id} → ConsumerCard.cease_and_desist.

Separately, an address can carry a suppression row whose reason is cease_communication; the other reasons are unsubscribe, hard_bounce, complaint, and third_party. A suppressed address reads EmailAddress.ineligible_reason: suppressed. Contract: EmailSuppression.reason and EmailAddress.ineligible_reason.

Gates fail closed ​

Before any send, treat all of the following as independent requirements:

  • The capability must be present and true; otherwise the channel does nothing. Contract: GET /v1/me → capabilities.email_channel.
  • The address must read sendable: true; an unreadable suppression state is itself an ineligibility reason. Contract: EmailAddress.sendable and EmailAddress.ineligible_reason.
  • No written cease-and-desist may be on file for the consumer; it blocks every address of that consumer whatever the address reads. Contract: GET /v1/consumers/{id} → ConsumerCard.cease_and_desist.
  • The template version must be approved. An email_blast that names a foreign or non-approved version is rejected. Contract: CampaignWrite.email_template_id.
  • An active provider row must resolve. There is no fallback provider; without one, nothing is sent. Contract: POST /v1/email/providers and EmailProvider.status.

capabilities.email_channel: true is therefore permission to try, not a readiness signal. Contract: GET /v1/me → capabilities.email_channel.

Templates and approval ​

POST /v1/email-templates creates a draft. The subject is static; the body uses declared markers. The static body must already contain the unsubscribe notice and the collector declaration. Creating another draft with the same name starts the next version instead of editing an approved version. Contract: POST /v1/email-templates and EmailTemplateInput.

PATCH /v1/email-templates/{id} edits only a draft. Approve it with POST /v1/email-templates/{id}/approve; only an account_admin login may do so, and approval records approved_by and approved_at. Retiring through POST /v1/email-templates/{id}/retire is terminal and removes that version from use. Contract: PATCH /v1/email-templates/{id}, fields EmailTemplate.approved_by and EmailTemplate.approved_at, and the approve/retire routes.

Send after a disposition ​

Recording POST /v1/call_attempts/{id}/disposition commits the outcome first and then hands it to the tenant's disposition policy. A stored rule is visible through GET /v1/disposition-rules; when its effect is send_email, the runtime uses its template and variables through the same gated sender as every other email. Contract: POST /v1/call_attempts/{id}/disposition and DispositionRule.effect/DispositionRule.params.

The public PUT /v1/disposition-rules can write only reschedule and suppress. It cannot provision send_email; integrations must not manufacture that effect or present it as a self-service setting. Contract: DispositionRulesWrite.rules[].effect.

Run an email_blast campaign ​

Create the campaign with campaign_type: "email_blast", an approved email_template_id, and values in email_template_variables. Starting it with POST /v1/campaigns/{id}/start selects the email runner while keeping the same campaign lifecycle as the other channel types. Contract: POST /v1/campaigns, CampaignWrite.email_template_id, CampaignWrite.email_template_variables, and POST /v1/campaigns/{id}/start.

Read the resulting ledger through GET /v1/email/messages or the collapsed view at GET /v1/email/messages/{id}. For one debt, use GET /v1/debts/{id}/messages?channel=email. Contract: EmailMessage.status, EmailMessageView.transitions, and GET /v1/debts/{id}/messages.

Signed provider events ​

Provider callbacks use a per-provider HMAC key. Supply it as the write-only webhook_secret when registering a row; reads expose only has_webhook_secret. Rotating the value replaces the key, and clearing it stops that row from accepting events. Contract: POST /v1/email/providers → webhook_secret, PATCH /v1/email/providers/{id} → webhook_secret, and EmailProvider.has_webhook_secret.

Accepted events appear as ledger transitions such as delivered, deferred, bounced, and complained; bounce and complaint processing can also append the corresponding suppression reason. Contract: EmailMessage.status and EmailSuppression.reason.

The contract names generic smtp and log adapters. It does not promise a specific cloud email vendor. Contract: EmailProvider.adapter.

RFC 8058 one-click unsubscribe ​

The one-click action is platform-owned, not a /v1 suppression write for an integration to recreate. Every template must already contain the unsubscribe notice, and the contract-visible result of an unsubscribe is a suppression with reason: unsubscribe. The /v1 contract explicitly offers only the read route for that ledger. Contract: POST /v1/email-templates, EmailSuppression.reason, and GET /v1/email/suppressions.

Do not clear that suppression merely because a campaign or template changes. A lift is another append-only ledger action and requires the consumer's own provenance. Contract: EmailSuppression.action, EmailSuppression.provenance, and GET /v1/email/suppressions.

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.