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. Onlyreply_tomay be set.platform_managed_domain: KeenAuth sends from your own domain, signed with DKIM for that domain. Add the domain with email sender domains and publish its DNS records first; the domain must beverified. Setsender_domain_idand afrom_addressin exactly that domain (not a subdomain of it). Production environments only. If the domain later stops being verified, mail is sent asplatform_defaultuntil the domain is verified again; the configuration itself does not change.customer_smtp: your SMTP server on port465(implicit TLS) or587(STARTTLS is required). The certificate must be valid for the host name.customer_api: your account atses,sendgrid,postmark,mailgunorbrevo.customer_webhook: KeenAuth renders the message and hands it to one of your webhook endpoints; your application sends it (see Webhook delivery). Setwebhook_endpoint_idto an enabled endpoint of this environment.
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:
- The request is
POSTwithContent-Type: application/jsonand the Standard Webhooks v1 headerswebhook-id,webhook-timestampandwebhook-signature.webhook-idis 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
2xxresponse. 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):
{
"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.