
# Replace the email provider

`PUT /v1/email-provider` · permission `email-provider:write` · all plans

Replaces how the environment sends email. You can write five modes:

- `platform_default`: KeenAuth sends from its own address. Only `reply_to` may be set.
- `platform_managed_domain`: KeenAuth sends from your own domain, signed with DKIM for that domain. Add the domain with [email sender domains](../email-sender-domains/create.md) and publish its DNS records first; the domain must be `verified`. Set `sender_domain_id` and a `from_address` in exactly that domain (not a subdomain of it). Production environments only. If the domain later stops being verified, mail is sent as `platform_default` until the domain is verified again; the configuration itself does not change.
- `customer_smtp`: your SMTP server on port `465` (implicit TLS) or `587` (STARTTLS is required). The certificate must be valid for the host name.
- `customer_api`: your account at `ses`, `sendgrid`, `postmark`, `mailgun` or `brevo`.
- `customer_webhook`: KeenAuth renders the message and hands it to one of your [webhook endpoints](../webhook-endpoints/create.md); your application sends it (see [Webhook delivery](#webhook-delivery)). Set `webhook_endpoint_id` to an enabled endpoint of this environment.

`If-Match` is required and carries the version you read (the `ETag` of [get](get.md)). For an environment that has never been configured, send `If-Match: "0"`; `*` does not match it. A mismatch returns `412`.

The credential is write-once. Send it in `credentials`; no response returns it. If you leave it out, the stored credential is kept only when the mode and the destination (SMTP host, port and user name, or provider) are unchanged. Otherwise the request fails with `/credentials` `required`.

A change clears the last connection test result and writes an audit record (`email_provider.updated`) with the mode, the provider and the sender domain id only, never an address or the credential. After a change, run a [connection test](test.md).

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `mode` | string | yes | `platform_default`, `platform_managed_domain`, `customer_smtp`, `customer_api` or `customer_webhook`. |
| `sender_domain_id` | string | `platform_managed_domain` | A `verified` sender domain of this environment (`sdom_…`). Not allowed in other modes. |
| `webhook_endpoint_id` | string | `customer_webhook` | An enabled [webhook endpoint](../webhook-endpoints/get.md) of this environment (`whe_…`). Not allowed in other modes. |
| `from_address` | string | all but `platform_default` | Bare sender address, such as `noreply@example.com`. In `platform_managed_domain` its domain must be exactly the sender domain. Not allowed in `platform_default`. |
| `reply_to` | string | no | Bare reply-to address. |
| `provider` | string | `customer_api` | `ses`, `sendgrid`, `postmark`, `mailgun` or `brevo`. |
| `smtp_host` | string | `customer_smtp` | DNS name with at least two labels (no IP address). |
| `smtp_port` | integer | `customer_smtp` | `465` or `587`. |
| `smtp_username` | string | `customer_smtp` | SMTP AUTH user name, 1 to 256 characters. |
| `credentials` | string | see above | Write-only, 1 to 1024 bytes. `customer_smtp`: the SMTP password. `customer_api`: `<region>:<access key id>:<secret access key>` for `ses`; the API key for `sendgrid` and `mailgun` (prefix `eu:` for the provider's EU endpoint); the server token for `postmark`; the API key for `brevo`. |

## Response

`200 OK` with the stored configuration (same shape as [get](get.md)) and `ETag` set to the new version.

## Errors

| HTTP | code | When |
|---|---|---|
| 400 | `unknown_field` | The body has an unknown field or is not a JSON object. |
| 412 | `precondition_failed` | `If-Match` is not the stored version (someone saved in between), or is `*` before the first write. |
| 413 | `payload_too_large` | The body is larger than 16 KiB. |
| 428 | `precondition_required` | `If-Match` is missing. |
| 422 | `validation_failed` | A field fails validation; `errors[]` names the field and the rule (see below). |
| 401 | `unauthenticated` | The credential is missing, invalid or revoked. |
| 403 | `insufficient_permissions` | The credential lacks the required permission. |
| 429 | `rate_limited` | The rate limit was exceeded; retry after the `Retry-After` period. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable; retry after the `Retry-After` period. |

Rules in `errors[]`:

| Pointer | Rule | Meaning |
|---|---|---|
| `/mode` | `required`, `invalid` | The mode is missing or unknown. |
| `/mode` | `delivery_mode_unavailable` | The mode cannot send in this deployment: `customer_webhook` needs the webhook delivery connection to be enabled, `platform_managed_domain` needs DKIM signing to be enabled, and the customer modes need outbound delivery to be enabled. |
| `/mode` | `production_only` | `platform_managed_domain` is allowed only in production environments. |
| `/mode` | `fields_not_allowed` | A field the mode does not take was sent. `customer_webhook` takes no `provider`, SMTP fields, `credentials` or `sender_domain_id`. |
| `/sender_domain_id` | `required`, `invalid` | `platform_managed_domain` was sent without a sender domain id, or the id is not an `sdom_…` id. |
| `/sender_domain_id` | `not_found` | This environment has no sender domain with that id. A domain of another environment gets the same answer. |
| `/sender_domain_id` | `not_verified` | The sender domain exists but is not `verified` (DNS records pending, degraded or disabled). |
| `/from_address` | `not_in_sender_domain` | The sender address is not in exactly the sender domain. |
| `/webhook_endpoint_id` | `required`, `invalid` | `customer_webhook` was sent without an endpoint id, or the id is not a `whe_…` id. |
| `/webhook_endpoint_id` | `not_found` | This environment has no webhook endpoint with that id. An endpoint of another environment gets the same answer. |
| `/webhook_endpoint_id` | `not_enabled` | The endpoint exists but is not enabled. |
| `/credentials` | `required` | The mode or destination changed and no new credential was sent. |
| `/credentials` | `invalid` | The credential is empty, too long, has control characters or does not fit the provider's format. |
| `/from_address`, `/reply_to`, `/provider`, `/smtp_host`, `/smtp_port`, `/smtp_username` | `required`, `invalid` | The field is missing or invalid for the mode. |

## Webhook delivery

In `customer_webhook` mode KeenAuth does not send the message and never falls back to its own sending. For every message it renders (subject, HTML and text), it posts one signed event to the endpoint over HTTPS:

- The request is `POST` with `Content-Type: application/json` and the Standard Webhooks v1 headers `webhook-id`, `webhook-timestamp` and `webhook-signature`. `webhook-id` is the message id (`msg_…`) and is the same in every attempt of the same message, so deduplicate on it. The event type does not have to be in the endpoint's event subscription: the endpoint receives it because it is the delivery endpoint.
- Success is any `2xx` response. Redirects are not followed and count as failures. One attempt times out after 10 seconds.
- A failed attempt is retried 15 seconds later, at most 3 attempts in total. After the third failure the message is marked failed and no email is sent. An endpoint that is deleted or disabled after you saved the configuration fails the message at once, without an attempt.
- The message content is not stored by the webhook service. Send only to endpoints you trust: the body carries the recipient address and any sign-in code or link.

Body (`schema_version` 1):

```json
{
  "id": "msg_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "type": "email.send_requested",
  "schema_version": 1,
  "environment_id": "env_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "occurred_at": "2026-10-27T09:00:00Z",
  "data": {
    "message_id": "msg_01k7xq3m5r8t9v0w1x2y3z4a5b",
    "family": "email_otp",
    "locale": "en",
    "to": "ada@example.com",
    "from": "auth@example.com",
    "from_name": "Example",
    "reply_to": "support@example.com",
    "subject": "Your sign-in code",
    "html": "<p>Your code is 123456</p>",
    "text": "Your code is 123456",
    "link": "https://example.keenauth.app/...",
    "code": "123456"
  }
}
```

| `data` field | Description |
|---|---|
| `message_id`, `family`, `locale` | The message id (same as the envelope `id`), the template family and the language the message was rendered in. |
| `to` | Recipient address. |
| `from`, `from_name`, `reply_to` | Present only when set: `from` is the `from_address` of this configuration. |
| `subject` | Rendered subject. |
| `html`, `text` | Rendered content; at least one is present. |
| `link`, `code` | Present only when the message carries a sign-in link or a one-time code. |

The signature is `v1,` plus the base64 of `HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")`. The key is the 32-byte value encoded by the 43 base62 characters that follow the `idp_whsec_` prefix of the endpoint's signing secret. Verify the raw request body, before you parse it, and reject a timestamp more than 300 seconds away from your clock. During a secret rotation `webhook-signature` holds two space-separated signatures.

Python:

```python
import base64, hashlib, hmac, time

ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
PREFIX = "idp_whsec_"


def signing_key(secret: str) -> bytes:
    """The HMAC key is the 32-byte value behind the 43 base62 characters after the prefix."""
    body = secret[len(PREFIX):len(PREFIX) + 43]
    n = 0
    for ch in body:
        n = n * 62 + ALPHABET.index(ch)
    return n.to_bytes(32, "big")


def verify(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
    msg_id = headers["webhook-id"]
    ts = headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > tolerance:
        return False
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    mac = hmac.new(signing_key(secret), signed, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(mac).decode()
    # webhook-signature holds one or more space-separated signatures.
    return any(hmac.compare_digest(expected, s) for s in headers["webhook-signature"].split(" "))
```

Node.js:

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const PREFIX = "idp_whsec_";

// The HMAC key is the 32-byte value behind the 43 base62 characters after the prefix.
function signingKey(secret) {
  let n = 0n;
  for (const ch of secret.slice(PREFIX.length, PREFIX.length + 43)) n = n * 62n + BigInt(ALPHABET.indexOf(ch));
  return Buffer.from(n.toString(16).padStart(64, "0"), "hex");
}

export function verify(secret, headers, rawBody, tolerance = 300) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;
  const mac = createHmac("sha256", signingKey(secret)).update(`${id}.${ts}.`).update(rawBody).digest();
  const expected = Buffer.from(`v1,${mac.toString("base64")}`);
  // webhook-signature holds one or more space-separated signatures.
  return headers["webhook-signature"].split(" ").some((s) => {
    const got = Buffer.from(s);
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

## Example

```bash
curl -X PUT https://api.eu.keenauth.com/v1/email-provider \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "0"' \
  -d @email-provider.json
```

`email-provider.json`:

```json
{
  "mode": "customer_smtp",
  "from_address": "no-reply@example.com",
  "smtp_host": "smtp.example.com",
  "smtp_port": 587,
  "smtp_username": "mailer",
  "credentials": "<SMTP password>"
}
```

To hand messages to a webhook endpoint:

```json
{
  "mode": "customer_webhook",
  "webhook_endpoint_id": "whe_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "from_address": "auth@example.com"
}
```

There is no connection to [test](test.md) in this mode; test the endpoint with [Test a webhook endpoint](../webhook-endpoints/test.md).

To send from your verified sender domain:

```json
{
  "mode": "platform_managed_domain",
  "sender_domain_id": "sdom_01k7xq3m5r8t9v0w1x2y3z4a5b",
  "from_address": "auth@mail.example.com",
  "reply_to": "support@example.com"
}
```

There is no connection to [test](test.md) in this mode; check the domain with [verify](../email-sender-domains/verify.md) instead.

CLI: `keenauth email-provider put --if-match '"0"' --data @email-provider.json`. Keep the credential in a file or pass it on standard input (`--data -`) so it does not end up in your shell history.
