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:

If-Match is required and carries the version you read (the ETag of get). 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.

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 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) 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:

Body (schema_version 1):

{
  "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:

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:

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

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:

{
  "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:

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

There is no connection to test in this mode; test the endpoint with Test a webhook endpoint.

To send from your verified sender domain:

{
  "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 in this mode; check the domain with verify 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.