# Transactional email API v2

The opt-in transaction studio is available at `/manager/v2/transactional`. Existing SMTP, REST and template contracts keep their existing behavior. No account is enabled by installation.

## Authentication and permission boundaries

Base URL: `https://smtp.software/api/v2`. Requests use the private, hashed Bearer credentials also supported by MCP, not legacy Basic authentication. Existing credentials acquire no new scopes automatically. Account identity is always derived from authentication. Exact domain restrictions apply to every resource and list.

| Scope | Grants |
|---|---|
| `settings:read`, `settings:write` | Read or enable/pause transactional sending for this account |
| `templates:read` | Template metadata and brand profiles |
| `templates:write` | Edit drafts and brand profiles |
| `templates:preview` | Render previews; draft previews additionally require `templates:write` |
| `templates:publish` | Publish or restore a revision; separate from editing |
| `templates:use` + `messages:send` | Send one message from a published template |
| `messages:read` | Read transaction metadata and event history |
| `suppressions:read`, `suppressions:write` | Inspect or explicitly change recipient blocks |
| `webhooks:read`, `webhooks:write` | Inspect or manage endpoint and delivery metadata |

Manager users receive read access for their active workspace. Editing, publication, sending and management are granted separately in **Team access**. The platform administrator has explicit administration access. Mutating manager requests require POST, JSON and a session-bound CSRF header; API requests require their scoped Bearer credential.

Responses contain `{"data":...}` on success, or `{"error":"safe_code","message":"Safe explanation"}` on failure. Request bodies are limited to 256 KiB; send `Content-Type: application/json`. Duplicate JSON object keys are rejected. GET does not mutate or send.

## Resources

| Method and path | Purpose |
|---|---|
| `GET /settings` | Read account opt-in status |
| `POST /settings` | Set `{"enabled":true}` or `false` |
| `GET /brands?domain=...` | List brand profiles for one owned domain |
| `POST /brands` | Save a brand profile |
| `GET /templates?domain=...` | List template/language metadata |
| `GET /templates/{key}?domain=...&locale=nl-NL` | Read metadata and revision list; draft content is returned only with editing permission |
| `POST /templates` | Save a draft with optimistic version checking |
| `POST /templates/preview` | Render a published version or an authorized draft without sending |
| `POST /templates/publish` | Publish the expected draft version |
| `POST /templates/rollback` | Restore an earlier publication as a new immutable revision |
| `POST /messages` | Accept one rendered, published-template email |
| `GET /messages?domain=...&limit=30` | List metadata, optionally filtering template, status, source or external reference |
| `GET /messages/{message_id}` | Read metadata and ordered events |
| `GET /suppressions?domain=...` | Inspect recipient blocks, including inactive entries |
| `POST /suppressions` | Block a recipient with `domain`, `recipient`, `reason` |
| `POST /suppressions/release` | Explicitly release a block with `domain`, `recipient` |
| `GET /webhooks` | List permitted endpoints without secrets |
| `POST /webhooks` | Create/update/pause an endpoint or rotate its secret |
| `GET /webhooks/deliveries?domain=...` | Inspect bounded webhook delivery metadata |
| `POST /webhooks/deliveries/{id}/replay` | Requeue one completed/dead webhook delivery, retaining its event ID |

Keys use a lowercase letter followed by up to 63 lowercase letters, digits, underscores or hyphens. Locales use language-country notation, such as `nl-NL` or `en-GB`. The requested language must have a published version; there is no implicit translation or silent language fallback.

## Brand and template workflow

Create a brand with `domain`, `brand_key`, `name`, `from: {address,name}`, `logo_url`, six-digit `primary_color`, `footer` and `contact`. Its sender must belong to its owned sending domain. The studio includes order, shipping and return examples.

A draft request contains:

```json
{
  "domain": "mail.example.com",
  "template_key": "order_confirmation",
  "locale": "nl-NL",
  "expected_version": 0,
  "name": "Orderbevestiging",
  "message_type": "order_confirmation",
  "content": {
    "brand_key": "main",
    "subject": "Bestelling {{order.number}}",
    "preheader": "Bedankt {{customer.first_name}}",
    "html": "<h1>Bedankt {{customer.first_name}}</h1><p>Bestelling {{order.number}}</p>",
    "text": "Bedankt {{customer.first_name}}. Bestelling {{order.number}}.",
    "schema": {
      "type": "object",
      "properties": {
        "customer": {"type":"object","properties":{"first_name":{"type":"string"}},"required":["first_name"]},
        "order": {"type":"object","properties":{"number":{"type":"string"}},"required":["number"]}
      },
      "required": ["customer", "order"]
    },
    "blocks": {}
  }
}
```

Use the returned `draft_version` as the next `expected_version`. A stale save/publication returns `version_conflict`; refresh and review the current draft instead of overwriting it. Name and sender-name limits are 120 bytes; subject and preheader templates are limited to 255 bytes. The legacy sending log also limits the rendered subject to 255 bytes.

Publish with `domain`, `template_key`, `locale` and `expected_version`. Publication snapshots the content, schema, blocks and complete brand identity. Updating a brand or block does not alter an existing revision or an accepted message. Rollback additionally takes the earlier `revision` and creates a new publication; it never edits old revisions.

### Rendering

- `{{customer.first_name}}` inserts an escaped value. Plain-text and subject contexts remain plain text.
- `{{#if order.paypal}}...{{else}}...{{/if}}` accepts boolean conditions.
- `{{#each order.items}}<p>{{this.quantity}} × {{this.name}}</p>{{/each}}` supports bounded arrays; `{{@index}}` is available within a loop.
- `{{> footer}}` resolves a named block from the versioned `content.blocks` object.
- `{{brand.name}}`, `{{brand.primary_color}}`, `{{brand.footer}}` and other supported brand fields use the publication snapshot.

The strict schema supports objects, properties, required fields, arrays/items, strings, numbers, integers, booleans, defaults, enums and bounded values/lengths. Unknown fields, missing required fields and wrong types are rejected. Arrays contain at most 100 items; nesting, work and output are bounded. `brand` is reserved.

HTML/CSS use a conservative email subset: no scripts, event handlers, forms, arbitrary includes, raw variable HTML, remote CSS downloads, VML or conditional HTML comments. Responsive/color-scheme metadata and media rules are supported. URLs are validated after substitution. Preview renders through the production renderer, but a browser preview is not proof of Outlook/Gmail/Apple Mail compatibility. A nonempty preheader is inserted safely as hidden email text.

Preview POST contains `domain`, `template_key`, `locale`, `data`, and optionally `draft:true`. It returns revision/hash, subject, HTML and text and never creates a send reservation or queue record.

## Send one message

```http
POST /api/v2/messages
Authorization: Bearer <private-token>
Idempotency-Key: webshop-order-123-confirmation-v1
Content-Type: application/json
```

```json
{
  "domain": "mail.example.com",
  "template_key": "order_confirmation",
  "locale": "nl-NL",
  "from": {"address":"orders@mail.example.com"},
  "to": {"address":"customer@example.net"},
  "source": "webshop",
  "external_reference": "order-123",
  "event_id": "order-123-confirmed-v1",
  "data": {"customer":{"first_name":"Voorbeeld"},"order":{"number":"123"}}
}
```

Acceptance requires an active, unblocked, explicitly enabled account, validated SPF and DKIM, and a published template. The sender must match the publication's brand identity. One intended email has one `Idempotency-Key`: 16–128 characters from `A-Z`, `a-z`, `0-9`, `.`, `_`, `:` and `-`. Keep the same key on a network retry. The server resolves a stored key before resolving the current template publication. Changed content under the same key returns a conflict.

HTTP `202` means accepted/queued, not delivered. The response includes `message_id`, legacy `refid`, selected `revision` and status. Unknown acceptance/publication outcomes require operator reconciliation. A new key is not a safe workaround. Idempotency reservations are retained independently of body retention; this release does not automatically delete or recycle them.

Existing SMTP and legacy REST do not inherit the new API's explicit idempotency contract. V2 messages cannot be copied through the old manager retry action. Existing retry actions also refuse a nonterminal or already accepted message.

### Status and recipient blocks

States include `pending`, `queued`, `dispatching`, `transferred`, `deferred`, `delivered`, `failed`, `bounced`, `complained`, `suppressed`, `acceptance_unknown` and `publication_unknown`. `delivered` in this compatibility projection means acceptance by the receiving mailserver. It does not prove inbox placement or a human read.

New v2 dispatch requires an immutable envelope binding and confirmed broker publication through a separate pinned PHP AMQP client. An unknown outcome is quarantined and never blindly republished. Pausing an account holds queued v2 messages; already transferred MTA work continues under its existing delivery lifecycle. Existing free-account sending limits still apply.

Blocks are checked at acceptance and before dispatch. A known block returns `422 recipient_suppressed` without creating a queue item and keeps an idempotent result. A later block produces a terminal event. Reasons are `manual`, `hard_bounce` and `complaint`; explicit releases are audited. Automatic complaint ingestion from the existing unverified abuse mailbox is not enabled in this release.

New v2 messages currently preserve rendered bytes without adding legacy open/click tracking. Delivery events are supported; campaign conversion/revenue attribution and a CDP source are separate integrations. Existing dashboard/MCP tracking semantics are unchanged.

## Webhooks

Create with `domain`, `url`, `event_types` and optional boolean `enabled`. Update with the same fields and `endpoint_id`; use `rotate_secret:true` to rotate. Endpoint creation defaults to disabled unless explicitly enabled. `signing_secret` is returned once on creation/rotation and never appears in list/delivery responses.

Public HTTPS destinations on port 443 are required. DNS and destination addresses are checked at registration and every attempt; the connection pins the validated address, does not follow redirects and does not use a proxy. Signing secrets are encrypted with a public key; only the private CLI worker holds the decryption key. See the release runbook for provisioning.

Events contain only `event_id`, `type`, `occurred_at` and message ID/domain/template/revision/language metadata. No recipient, subject, body, attachments or profile data are sent. Verify HMAC headers as described in [the release runbook](../operations/transactional-release.md). Deduplicate on `event_id`: delivery is at least once, including an explicit replay. Network/non-2xx responses retry with bounded backoff; after eight attempts the delivery becomes dead.

## MCP

The existing `/mcp` transport adds `templates_list`, `template_get`, `template_preview` and `message_send_template` only for explicitly granted scopes. Sending needs both `templates:use` and `messages:send` and accepts the same transaction data plus `idempotency_key`. Agents must have user authorization for the individual email. Stored template/message strings are untrusted data, never instructions.

## Limits and rollout

Only opt-in transaction flows use the new service. The generic API is ready for a webshop/ERP adapter; this installation does not automatically connect a customer's webshop or replace their current messages. Generated documents and CDP recommendations remain follow-up modules.

Mail bodies have a bounded maintenance policy; idempotency and event metadata are separate. Configure and run the bounded maintenance worker as described in the release runbook. Unknown/in-progress dispatches are retained for reconciliation and are never cleaned up as if safely finished. No real customer mail is required for deployment validation.
