{"openapi":"3.1.0","info":{"title":"RelyKit API","version":"0.1.0","summary":"Transactional email over Amazon SES.","description":"RelyKit sends transactional email through Amazon SES. `POST /emails` validates the request, checks the account's suppression list and daily limit, stores the message, and returns; a worker composes the MIME and hands it to SES, so nothing slow happens on the request path.\n\nBefore an account can send, the domain in the `from` address must be added with `POST /domains` and verified by publishing the DNS records that call returns. Delivery outcomes arrive as a per-message timeline at `GET /emails/{id}/events` and, if you register an endpoint, as signed webhooks.\n\nAuthenticate every request with an API key as `Authorization: Bearer rlk_live_...`. Errors are always `{ \"name\", \"message\", \"status_code\" }`, and `name` is the stable identifier to branch on.\n\nThe administrative routes under `/admin`, the SNS ingest route `POST /ingest/ses`, and the recipient-facing one-click unsubscribe pages under `/u` are deliberately not part of this document: the first two authenticate differently, and the last is reached by mail clients, not by API clients.","contact":{"name":"RelyKit support","email":"support@example.com"},"license":{"name":"MIT"}},"servers":[{"url":"https://api.example.com","description":"Production"},{"url":"http://localhost:3000","description":"Local development (npm run dev:api)"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Emails","description":"Sending messages and reading what happened to them."},{"name":"Templates","description":"Versioned content a send can name instead of carrying its own subject and bodies."},{"name":"Domains","description":"Sending identities and their DNS verification."},{"name":"Webhooks","description":"Signed delivery of timeline events to your endpoint."},{"name":"Suppressions","description":"The per-account list of addresses that are never sent to."},{"name":"Account","description":"Account settings and sending reputation."},{"name":"Service","description":"Liveness."}],"paths":{"/health":{"get":{"tags":["Service"],"operationId":"getHealth","summary":"Liveness check","description":"Returns 200 as long as the HTTP server is up. It does not check the database, and it needs no API key.","security":[],"responses":{"200":{"description":"The server is up.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","const":true}}},"example":{"ok":true}}}}}}},"/emails":{"post":{"tags":["Emails"],"operationId":"sendEmail","summary":"Send an email","description":"Validates and queues one message. The response is the stored message with status `queued`; poll `GET /emails/{id}` or take webhooks for the outcome.\n\nThe host in `from` must be a verified domain on this account, or a subdomain of one, otherwise the request is refused with `domain_not_verified`. Recipients on the account's suppression list are dropped before the message is stored and listed in `suppressed`; if every recipient is dropped, the message is stored with status `cancelled` and nothing is sent.\n\nSend `scheduled_at` to hold the message until then, and `unsubscribe: true` to add the `List-Unsubscribe` headers that Gmail and Yahoo require of bulk senders — that one needs exactly one recipient, because the link is signed for a single address.\n\nIn place of `subject`, `html` and `text`, a message may name a `template` and pass `variables`; the template provides all three, and setting both is a 422. The stored message records which template and which version rendered it. A `template` that matches no template on this account is a 404, as is a `template_version` that does not exist.","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendEmailRequest"},"examples":{"simple":{"summary":"A plain text and HTML message","value":{"from":"Acme <hello@acme.com>","to":"you@example.com","subject":"Welcome to Acme","html":"<p>Thanks for signing up.</p>","text":"Thanks for signing up."}},"full":{"summary":"Several recipients, an attachment, tags and a schedule","value":{"from":"Acme <billing@acme.com>","to":["ops@example.com","Finance <finance@example.com>"],"cc":"records@example.com","reply_to":"billing@acme.com","subject":"Invoice 2026-04","html":"<p>Your invoice is attached.</p>","text":"Your invoice is attached.","headers":{"X-Entity-Ref-ID":"inv_2026_04"},"attachments":[{"filename":"invoice.pdf","content":"JVBERi0xLjQKJeLjz9MK","content_type":"application/pdf"}],"tags":{"campaign":"invoices","period":"2026-04"},"scheduled_at":"2026-04-01T09:00:00Z"}},"unsubscribe":{"summary":"One recipient, with one-click unsubscribe headers","value":{"from":"Acme <news@acme.com>","to":"you@example.com","subject":"This month at Acme","html":"<p>News.</p>","unsubscribe":true}}}}}},"responses":{"200":{"description":"An earlier request used the same `Idempotency-Key` on this account. The original message is returned and nothing was sent again.","headers":{"Idempotent-Replayed":{"description":"Present, with the value `true`, only on a replayed request.","schema":{"type":"string","const":"true"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Email"}}}},"201":{"description":"The message was stored. `status` is `queued`, or `cancelled` when every recipient was suppressed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Email"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Sending is refused. `domain_not_verified` means the host in `from` is not a verified domain on this account; `forbidden` means the account itself is paused or suspended.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"domain_not_verified":{"value":{"name":"domain_not_verified","message":"acme.com is not a verified domain on this account. Add it with POST /domains and publish its DNS records.","status_code":403}},"paused":{"value":{"name":"forbidden","message":"Sending is paused for this account: complaint rate 0.40% over 500 messages exceeds the 0.25% limit.","status_code":403}}}}}},"404":{"description":"The `template` names no template on this account, or `template_version` names no such version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template":{"value":{"name":"not_found","message":"Template not found.","status_code":404}},"version":{"value":{"name":"not_found","message":"Version 7 of template \"welcome\" not found.","status_code":404}}}}}},"413":{"$ref":"#/components/responses/MessageTooLarge"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"description":"`unsubscribe: true` was sent but one-click unsubscribe is not configured on this deployment (`PUBLIC_BASE_URL` and `UNSUBSCRIBE_SECRET`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"name":"unsubscribe_unavailable","message":"One-click unsubscribe is not configured on this deployment.","status_code":503}}}}}}},"/emails/{id}":{"parameters":[{"$ref":"#/components/parameters/EmailId"}],"get":{"tags":["Emails"],"operationId":"getEmail","summary":"Retrieve an email","description":"The stored message, including its current status, the recipients that were dropped as suppressed, the number of send attempts, and `last_error` if a send failed.","responses":{"200":{"description":"The message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Email"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/emails/{id}/events":{"parameters":[{"$ref":"#/components/parameters/EmailId"}],"get":{"tags":["Emails"],"operationId":"listEmailEvents","summary":"List an email's timeline","description":"Every event recorded for this message, oldest first. There is one row per recipient per SES event, so a message to three addresses that all bounce has three `email.bounced` events.","responses":{"200":{"description":"The timeline.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EmailEvent"}}}},"example":{"data":[{"id":"6c1e0f2a-3b44-4d10-9f21-6b2c8a0d5e77","type":"email.sent","email_id":"3f8a1c2e-9f6d-4c71-8c3a-6a5f2b1d0e44","recipient":"you@example.com","data":{},"occurred_at":"2026-04-01T09:00:01.120Z"},{"id":"8a2d5b31-7c09-4e88-b0f2-1d4e6a7c9b30","type":"email.delivered","email_id":"3f8a1c2e-9f6d-4c71-8c3a-6a5f2b1d0e44","recipient":"you@example.com","data":{"smtp_response":"250 2.0.0 OK 1743498002 x12si"},"occurred_at":"2026-04-01T09:00:02.480Z"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/domains":{"post":{"tags":["Domains"],"operationId":"createDomain","summary":"Add a sending domain","description":"Creates the domain as an SES identity with Easy DKIM and a custom MAIL FROM subdomain, and returns the DNS records to publish. The domain stays `pending` until every required record resolves and SES reports DKIM and MAIL FROM as SUCCESS; the worker re-checks pending domains every few minutes, and `POST /domains/{id}/verify` checks immediately.\n\nA domain name exists once across the whole service, because it is one SES identity.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"The bare domain, like `acme.com` or `mail.acme.com`. Lower-cased; a trailing dot is trimmed. At least two labels, at most 253 characters, and the last label must be alphabetic.","maxLength":253,"examples":["acme.com","mail.acme.com"]}}},"example":{"name":"acme.com"}}}},"responses":{"201":{"description":"The domain was created. Publish `records`, then verify.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"The name is already registered, either to this account or to another one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"name":"conflict","message":"acme.com is already added to this account.","status_code":409}}}},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"}}},"get":{"tags":["Domains"],"operationId":"listDomains","summary":"List domains","description":"Every domain on the account, oldest first.","responses":{"200":{"description":"The account's domains.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Domain"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/domains/{id}":{"parameters":[{"$ref":"#/components/parameters/DomainId"}],"get":{"tags":["Domains"],"operationId":"getDomain","summary":"Retrieve a domain","description":"The domain as of the last check. Each entry in `records` carries the result of that check and the values DNS actually returned, so you can show which record is wrong.","responses":{"200":{"description":"The domain.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"delete":{"tags":["Domains"],"operationId":"deleteDomain","summary":"Remove a domain","description":"Deletes the domain and its SES identity. Messages already sent from it keep their history; new sends from that host are refused with `domain_not_verified`.","responses":{"204":{"description":"Deleted. No body."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/domains/{id}/verify":{"parameters":[{"$ref":"#/components/parameters/DomainId"}],"post":{"tags":["Domains"],"operationId":"verifyDomain","summary":"Check a domain's DNS now","description":"Resolves every expected record against the public resolvers, asks SES for its own view, stores the outcome, and returns the updated domain. Use it after publishing the records rather than waiting for the poller. A domain that has gone `failed` is only re-checked this way.","responses":{"200":{"description":"The domain, freshly checked. `status` is `verified` once every required record passes and SES reports success.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/webhooks":{"post":{"tags":["Webhooks"],"operationId":"createWebhook","summary":"Register a webhook endpoint","description":"Creates an endpoint and returns its signing `secret`. **The secret is returned only by this call** and never again, so store it now.\n\nAn empty or omitted `event_types` means every event type.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookRequest"},"examples":{"all_events":{"summary":"Every event","value":{"url":"https://hooks.acme.com/relay","description":"production"}},"filtered":{"summary":"Only the outcomes that matter","value":{"url":"https://hooks.acme.com/relay","event_types":["email.delivered","email.bounced","email.complained"],"description":"delivery outcomes"}}}}}},"responses":{"201":{"description":"Created. This is the only response that carries `secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookWithSecret"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"}}},"get":{"tags":["Webhooks"],"operationId":"listWebhooks","summary":"List webhook endpoints","description":"Every endpoint on the account, oldest first. Secrets are not included.","responses":{"200":{"description":"The account's endpoints.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/webhooks/{id}":{"parameters":[{"$ref":"#/components/parameters/WebhookId"}],"get":{"tags":["Webhooks"],"operationId":"getWebhook","summary":"Retrieve a webhook endpoint","responses":{"200":{"description":"The endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"patch":{"tags":["Webhooks"],"operationId":"updateWebhook","summary":"Update a webhook endpoint","description":"Only the fields you send are changed. Setting `enabled` to `true` on an endpoint that was disabled by repeated failures also clears its failure count, so it starts fresh. The signing secret cannot be changed or read here.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookRequest"},"examples":{"reenable":{"summary":"Resume an endpoint disabled by failures","value":{"enabled":true}},"retarget":{"summary":"Point it somewhere else and narrow the filter","value":{"url":"https://hooks.acme.com/relay/v2","event_types":["email.bounced","email.complained"]}}}}}},"responses":{"200":{"description":"The updated endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"}}},"delete":{"tags":["Webhooks"],"operationId":"deleteWebhook","summary":"Delete a webhook endpoint","description":"Removes the endpoint and its pending deliveries.","responses":{"204":{"description":"Deleted. No body."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/webhooks/{id}/test":{"parameters":[{"$ref":"#/components/parameters/WebhookId"}],"post":{"tags":["Webhooks"],"operationId":"testWebhook","summary":"Send a test event","description":"Posts a synthetic `webhook.test` event to the endpoint right now, signed exactly like a real one, and returns the attempt. The call waits for the delivery, so a slow endpoint makes it slow; the request has no body.\n\nA `200` here does not mean the endpoint accepted it — read `status` and `status_code` on the returned delivery.","responses":{"200":{"description":"The attempt, whether or not the endpoint accepted it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDelivery"},"example":{"id":"b71c4d90-2f8e-4a63-9c15-7e0a3d6b8f21","webhook_id":"9d2f4a11-5c8b-4e37-b6a0-2f1e9c4d7a55","event_id":null,"status":"delivered","attempts":1,"status_code":204,"response_excerpt":"","last_error":null,"next_attempt_at":"2026-04-01T09:00:00.000Z","delivered_at":"2026-04-01T09:00:00.412Z","created_at":"2026-04-01T09:00:00.000Z"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/webhooks/{id}/deliveries":{"parameters":[{"$ref":"#/components/parameters/WebhookId"}],"get":{"tags":["Webhooks"],"operationId":"listWebhookDeliveries","summary":"List recent deliveries","description":"The 50 most recent delivery attempts for this endpoint, newest first, with the status code and the first 500 characters of the response body.","responses":{"200":{"description":"Recent deliveries.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/suppressions":{"get":{"tags":["Suppressions"],"operationId":"listSuppressions","summary":"List suppressed addresses","description":"The account's suppression list, newest first. Pass `email` to look one address up exactly, which returns zero or one entry and ignores `limit`.","parameters":[{"name":"email","in":"query","required":false,"description":"Look up one address. Matched case-insensitively on the whole address.","schema":{"type":"string","format":"email"},"example":"bounced@example.com"},{"name":"limit","in":"query","required":false,"description":"How many entries to return. Clamped to 1..1000. Ignored when `email` is given.","schema":{"type":"integer","minimum":1,"maximum":1000,"default":100}}],"responses":{"200":{"description":"The suppression list.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Suppression"}}}},"example":{"data":[{"email":"bounced@example.com","reason":"hard_bounce","source_email_id":"3f8a1c2e-9f6d-4c71-8c3a-6a5f2b1d0e44","created_at":"2026-04-01T09:00:03.900Z"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"tags":["Suppressions"],"operationId":"addSuppression","summary":"Suppress an address","description":"Adds an address by hand, or changes the reason on one that is already listed. Only `manual` and `unsubscribe` may be written here; `hard_bounce`, `soft_bounces` and `complaint` are written by the service from delivery events.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","description":"The address to suppress. Stored lower-cased.","maxLength":254},"reason":{"type":"string","enum":["manual","unsubscribe"],"default":"manual"}}},"example":{"email":"angry@example.com","reason":"manual"}}}},"responses":{"201":{"description":"The address is suppressed. Repeating the call updates the reason and returns 201 again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Suppression"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/suppressions/{email}":{"parameters":[{"name":"email","in":"path","required":true,"description":"The suppressed address, URL-encoded (`you%40example.com`).","schema":{"type":"string","format":"email"},"example":"bounced%40example.com"}],"delete":{"tags":["Suppressions"],"operationId":"removeSuppression","summary":"Unsuppress an address","description":"Removes the address, so it can be sent to again. Do this only when you know why it was listed: re-sending to a hard bounce or a complainer is what costs a sender its reputation.","responses":{"204":{"description":"Removed. No body."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"The address is not on this account's suppression list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"name":"not_found","message":"Suppression not found.","status_code":404}}}},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/account":{"get":{"tags":["Account"],"operationId":"getAccount","summary":"Retrieve the account","description":"The account this API key belongs to, including the daily limit actually enforced and, if sending is paused, why.","responses":{"200":{"description":"The account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}},"patch":{"tags":["Account"],"operationId":"updateAccount","summary":"Update account settings","description":"Turns open and click tracking on or off. Tracked accounts send through a second SES configuration set with a custom redirect domain, so untracked mail carries no pixel and no rewritten links. `tracking` is the only field that may be changed, and it is required.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tracking"],"properties":{"tracking":{"type":"boolean","description":"Whether to record opens and clicks."}}},"example":{"tracking":true}}}},"responses":{"200":{"description":"The updated account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Account"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/account/reputation":{"get":{"tags":["Account"],"operationId":"getAccountReputation","summary":"Retrieve sending reputation","description":"How many messages were handed to SES over the last `window_hours`, and what share of them hard bounced or drew a complaint. `sent` counts messages that reached SES, and `bounced` and `complained` count messages with at least one such recipient, so the rates are per message rather than per recipient.\n\nThe service pauses an account above 5% bounces or 0.25% complaints, once it has sent enough messages in the window for a ratio to mean anything. A paused account's keys fail with 403 until an administrator resumes it.","responses":{"200":{"description":"The account with its rates over the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Reputation"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/emails/batch":{"post":{"tags":["Emails"],"operationId":"sendBatch","summary":"Send many emails in one call","description":"Validates every entry before writing anything, and writes the whole batch in a single transaction. One invalid entry fails the call, naming its position, so a caller never has to reconcile a partly sent batch. Suppressed recipients do not fail the batch: as on the single send, a message whose every recipient is suppressed is stored with status cancelled. A batch larger than the account has allowance left for is refused whole, and the error says how much room remains.\n\nIn place of `subject`, `html` and `text`, a message may name a `template` and pass `variables`; the template provides all three, and setting both is a 422. The stored message records which template and which version rendered it. A template failure in a batch is always reported as a 422 naming the entry, even where the same failure on a single send would be a 404.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchSendRequest"},"example":{"emails":[{"from":"Acme <receipts@acme.com>","to":"kai@example.com","subject":"Your receipt","html":"<p>Thanks for your order.</p>","idempotency_key":"order-6be1f2-receipt"},{"from":"Acme <receipts@acme.com>","to":"noor@example.com","subject":"Your receipt","html":"<p>Thanks for your order.</p>","idempotency_key":"order-7cd2a9-receipt"}]}}}},"responses":{"200":{"description":"Every entry matched an idempotency key already used. Nothing new was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchSendResponse"}}}},"201":{"description":"The batch was written.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchSendResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Sending is refused. `domain_not_verified` means the host in `from` is not a verified domain on this account; `forbidden` means the account itself is paused or suspended.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"domain_not_verified":{"value":{"name":"domain_not_verified","message":"acme.com is not a verified domain on this account. Add it with POST /domains and publish its DNS records.","status_code":403}},"paused":{"value":{"name":"forbidden","message":"Sending is paused for this account: complaint rate 0.40% over 500 messages exceeds the 0.25% limit.","status_code":403}}}}}},"409":{"description":"An idempotency key in the batch was used concurrently by another request. Retrying returns every message as replayed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"$ref":"#/components/responses/ValidationError"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"description":"`unsubscribe: true` was sent but one-click unsubscribe is not configured on this deployment (`PUBLIC_BASE_URL` and `UNSUBSCRIBE_SECRET`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"name":"unsubscribe_unavailable","message":"One-click unsubscribe is not configured on this deployment.","status_code":503}}}}}}},"/emails/{id}/cancel":{"post":{"tags":["Emails"],"operationId":"cancelEmail","summary":"Cancel a queued email","description":"Stops a message that has not been handed to Amazon SES yet, which in practice means one that was scheduled for later. Cancelling an already cancelled message succeeds and changes nothing. Anything sent, delivered, bounced or failed cannot be taken back and answers 409.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The message id returned when it was sent."}],"responses":{"200":{"description":"The message is cancelled and will not be sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Email"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The message has already left, so there is nothing to cancel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"name":"conflict","message":"This message is sent and can no longer be cancelled. Only a queued message can.","status_code":409}}}}}}},"/templates":{"post":{"tags":["Templates"],"operationId":"createTemplate","summary":"Create a template","description":"Creates the template and its version 1 in one call, published and immediately sendable unless you pass `publish: false`.\n\nThe source is compiled before anything is stored: every variable it references must be declared in `variables`, and a name it loops over with `{{#each}}` must be declared as an array. Both are refused here rather than at send time. The template language is `{{ name }}`, `{{#if}}`, `{{#unless}}` and `{{#each}}` with `{{ this.field }}` for the current item; values substituted into `html` are HTML-escaped, and there is no unescaped-output syntax.\n\n`slug` is derived from `name` when you leave it out. It is how a send names the template, and renaming the template never changes it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTemplateRequest"},"examples":{"published":{"summary":"A template that is live as soon as it exists","value":{"name":"Welcome","slug":"welcome","description":"Sent when someone finishes signing up.","subject":"Welcome to Acme, {{first_name}}","html":"<p>Hi {{first_name}}, thanks for joining.</p>","text":"Hi {{first_name}}, thanks for joining.","variables":[{"name":"first_name","description":"Used in the greeting"}]}},"draft":{"summary":"A draft, with an optional list variable","value":{"name":"Order receipt","subject":"Your receipt","html":"<ul>{{#each items}}<li>{{this.name}}</li>{{/each}}</ul>","variables":[{"name":"items","type":"array","required":false,"default":[]}],"publish":false}}}}}},"responses":{"201":{"description":"The template was created. `version` carries the version 1 that came with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"},"example":{"id":"0b6f1d3c-2c47-4a56-9b1e-8c2f7a0d4e11","slug":"welcome","name":"Welcome","description":"Sent when someone finishes signing up.","current_version":1,"archived":false,"created_at":"2026-03-20T10:00:00.000Z","updated_at":"2026-04-01T08:30:00.000Z","version":{"version":1,"subject":"Welcome to Acme, {{first_name}}","html":"<p>Hi {{first_name}}, thanks for joining.</p>{{#if items}}<ul>{{#each items}}<li>{{this.name}}</li>{{/each}}</ul>{{/if}}","text":"Hi {{first_name}}, thanks for joining.","variables":[{"name":"first_name","type":"string","required":true,"description":"Used in the greeting"},{"name":"items","type":"array","required":false,"description":"What they ordered","default":[]}],"notes":"","created_at":"2026-04-01T08:30:00.000Z"}}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Another template on this account already uses that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"slug_taken":{"value":{"name":"conflict","message":"A template with the slug \"welcome\" already exists.","status_code":409}}}}}},"422":{"description":"The template could not be saved. Compiling the source proves it only uses variables it declares, and uses each as declared, so these are save-time failures rather than a surprise when a customer's receipt goes out.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"undeclared_variable":{"value":{"name":"validation_error","message":"html uses {{frist_name}}, which is not declared. Add it to variables, or correct the spelling.","status_code":422}},"wrong_use":{"value":{"name":"validation_error","message":"html loops over {{items}}, but it is declared as string, not array.","status_code":422}},"no_body":{"value":{"name":"validation_error","message":"Provide html, text, or both.","status_code":422}},"bad_slug":{"value":{"name":"validation_error","message":"slug must be lowercase letters, digits and hyphens, and start and end with one of those.","status_code":422}},"unescaped_output":{"value":{"name":"validation_error","message":"html: Unescaped output with {{{ }}} is not supported, because it would let a value inject markup. Use {{ }} and put any markup in the template itself.","status_code":422}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"get":{"tags":["Templates"],"operationId":"listTemplates","summary":"List templates","description":"Every template on the account, live ones first and then by when they were last touched. Each carries `version_count` and `message_count`; the versions themselves are on `GET /templates/{ref}`.","parameters":[{"name":"archived","in":"query","required":false,"description":"Exactly `true` includes archived templates. Any other value, or none, leaves them out.","schema":{"type":"string","enum":["true"]},"example":"true"}],"responses":{"200":{"description":"The templates.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Template"}}}},"example":{"data":[{"id":"0b6f1d3c-2c47-4a56-9b1e-8c2f7a0d4e11","slug":"welcome","name":"Welcome","description":"Sent when someone finishes signing up.","current_version":2,"archived":false,"version_count":2,"message_count":1840,"created_at":"2026-03-20T10:00:00.000Z","updated_at":"2026-04-01T08:30:00.000Z"},{"id":"2d7e4a90-6b31-4c02-8f77-3a1b5c9d0e22","slug":"order-receipt","name":"Order receipt","description":"","current_version":null,"archived":false,"version_count":1,"message_count":0,"created_at":"2026-03-28T14:05:00.000Z","updated_at":"2026-03-28T14:05:00.000Z"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/templates/{ref}":{"parameters":[{"$ref":"#/components/parameters/TemplateRef"}],"get":{"tags":["Templates"],"operationId":"getTemplate","summary":"Retrieve a template","description":"The template with every one of its versions under `versions`, newest first, so a rollback target can be read off the same response. `current_version` says which one is live.","responses":{"200":{"description":"The template and its versions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"},"example":{"id":"0b6f1d3c-2c47-4a56-9b1e-8c2f7a0d4e11","slug":"welcome","name":"Welcome","description":"Sent when someone finishes signing up.","current_version":2,"archived":false,"created_at":"2026-03-20T10:00:00.000Z","updated_at":"2026-04-01T08:30:00.000Z","versions":[{"version":2,"subject":"Welcome to Acme, {{first_name}}","html":"<p>Hi {{first_name}}, thanks for joining.</p>{{#if items}}<ul>{{#each items}}<li>{{this.name}}</li>{{/each}}</ul>{{/if}}","text":"Hi {{first_name}}, thanks for joining.","variables":[{"name":"first_name","type":"string","required":true,"description":"Used in the greeting"},{"name":"items","type":"array","required":false,"description":"What they ordered","default":[]}],"notes":"Shorter subject line","created_at":"2026-04-01T08:30:00.000Z"},{"version":1,"subject":"Welcome to Acme, {{first_name}}","html":"<p>Hi {{first_name}}, thanks for joining.</p>{{#if items}}<ul>{{#each items}}<li>{{this.name}}</li>{{/each}}</ul>{{/if}}","text":"Hi {{first_name}}, thanks for joining.","variables":[{"name":"first_name","type":"string","required":true,"description":"Used in the greeting"},{"name":"items","type":"array","required":false,"description":"What they ordered","default":[]}],"notes":"first cut","created_at":"2026-03-20T10:00:00.000Z"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No template on this account has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template":{"value":{"name":"not_found","message":"Template not found.","status_code":404}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/templates/{ref}/versions":{"parameters":[{"$ref":"#/components/parameters/TemplateRef"}],"post":{"tags":["Templates"],"operationId":"createTemplateVersion","summary":"Save a new version","description":"Appends the next version. Nothing existing changes: versions are immutable, and whatever is live stays live until something publishes this one — either `publish: true` here, or `POST /templates/{ref}/publish` later.\n\nThe source is compiled exactly as on create, so an undeclared variable is refused now rather than at send time. Variables are declared per version, so a new version may declare a different set from the live one.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTemplateVersionRequest"},"example":{"subject":"Welcome to Acme, {{first_name}}","html":"<p>Hi {{first_name}}, thanks for joining.</p>","text":"Hi {{first_name}}, thanks for joining.","variables":[{"name":"first_name","description":"Used in the greeting"}],"notes":"Shorter subject line","publish":true}}}},"responses":{"201":{"description":"The new version. It is live only if `publish` was true.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateVersion"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No template on this account has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template":{"value":{"name":"not_found","message":"Template not found.","status_code":404}}}}}},"422":{"description":"The template could not be saved. Compiling the source proves it only uses variables it declares, and uses each as declared, so these are save-time failures rather than a surprise when a customer's receipt goes out.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"undeclared_variable":{"value":{"name":"validation_error","message":"html uses {{frist_name}}, which is not declared. Add it to variables, or correct the spelling.","status_code":422}},"wrong_use":{"value":{"name":"validation_error","message":"html loops over {{items}}, but it is declared as string, not array.","status_code":422}},"no_body":{"value":{"name":"validation_error","message":"Provide html, text, or both.","status_code":422}},"bad_slug":{"value":{"name":"validation_error","message":"slug must be lowercase letters, digits and hyphens, and start and end with one of those.","status_code":422}},"unescaped_output":{"value":{"name":"validation_error","message":"html: Unescaped output with {{{ }}} is not supported, because it would let a value inject markup. Use {{ }} and put any markup in the template itself.","status_code":422}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/templates/{ref}/versions/{version}":{"parameters":[{"$ref":"#/components/parameters/TemplateRef"},{"$ref":"#/components/parameters/TemplateVersionNumber"}],"get":{"tags":["Templates"],"operationId":"getTemplateVersion","summary":"Retrieve one version","description":"One version, exactly as it was saved. Since versions never change, this is what a message sent against that version was rendered from.","responses":{"200":{"description":"The version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateVersion"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No such template, or the template has no such version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template":{"value":{"name":"not_found","message":"Template not found.","status_code":404}},"version":{"value":{"name":"not_found","message":"Version 7 of this template not found.","status_code":404}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/templates/{ref}/publish":{"parameters":[{"$ref":"#/components/parameters/TemplateRef"}],"post":{"tags":["Templates"],"operationId":"publishTemplate","summary":"Publish a version","description":"Points the template at a version that already exists, which makes releasing and rolling back the same operation: publishing version 1 again after version 2 went wrong is instant, and the message history still records which version sent what.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishTemplateRequest"},"examples":{"release":{"summary":"Make the newest version live","value":{"version":2}},"rollback":{"summary":"Go back to the previous one","value":{"version":1}}}}}},"responses":{"200":{"description":"The template, with `current_version` now pointing at the published version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"},"example":{"id":"0b6f1d3c-2c47-4a56-9b1e-8c2f7a0d4e11","slug":"welcome","name":"Welcome","description":"Sent when someone finishes signing up.","current_version":2,"archived":false,"created_at":"2026-03-20T10:00:00.000Z","updated_at":"2026-04-01T08:30:00.000Z"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No such template, or the template has no such version to point at.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template":{"value":{"name":"not_found","message":"Template not found.","status_code":404}},"version":{"value":{"name":"not_found","message":"Version 7 of this template not found.","status_code":404}}}}}},"422":{"description":"`version` was missing or was not a whole number of at least 1.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"bad_version":{"value":{"name":"validation_error","message":"version must be a whole number of at least 1.","status_code":422}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/templates/{ref}/archive":{"parameters":[{"$ref":"#/components/parameters/TemplateRef"}],"post":{"tags":["Templates"],"operationId":"archiveTemplate","summary":"Archive or restore a template","description":"Archiving rather than deleting: messages reference the template, and the history of what was sent should not disappear because someone tidied up. An archived template is still readable and still listed with `?archived=true`, but sending with it is refused until it is restored.\n\nSend `{ \"archived\": false }` to restore. An absent or unreadable body archives, so a bare POST is the same as asking to archive.","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArchiveTemplateRequest"},"examples":{"archive":{"summary":"Archive it","value":{"archived":true}},"restore":{"summary":"Put it back","value":{"archived":false}}}}}},"responses":{"200":{"description":"The template, with `archived` reflecting what was asked for.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"},"example":{"id":"0b6f1d3c-2c47-4a56-9b1e-8c2f7a0d4e11","slug":"welcome","name":"Welcome","description":"Sent when someone finishes signing up.","current_version":2,"archived":true,"created_at":"2026-03-20T10:00:00.000Z","updated_at":"2026-04-01T08:30:00.000Z"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No template on this account has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template":{"value":{"name":"not_found","message":"Template not found.","status_code":404}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/templates/{ref}/preview":{"parameters":[{"$ref":"#/components/parameters/TemplateRef"}],"post":{"tags":["Templates"],"operationId":"previewTemplate","summary":"Render a template without sending","description":"Renders the published version — or the one named in `version` — against the variables given, and returns the subject and bodies a send would produce. Nothing is stored and no message is created.\n\nThe variables are checked exactly as at send time, so this is also how you prove a draft before publishing it. The request body is required: send `{}` for a template that declares nothing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PreviewTemplateRequest"},"examples":{"published":{"summary":"The live version","value":{"variables":{"first_name":"Kai","items":[{"name":"Desk lamp"}]}}},"draft":{"summary":"A draft, before publishing it","value":{"version":3,"variables":{"first_name":"Kai"}}}}}}},"responses":{"200":{"description":"The rendered message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplatePreview"}}}},"400":{"$ref":"#/components/responses/InvalidJson"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No such template, or the version named does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"template":{"value":{"name":"not_found","message":"Template not found.","status_code":404}},"version":{"value":{"name":"not_found","message":"Version 7 of template \"welcome\" not found.","status_code":404}}}}}},"422":{"description":"The template cannot be rendered with what was given.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missing_variable":{"value":{"name":"validation_error","message":"This template requires the variable \"first_name\" (string).","status_code":422}},"wrong_type":{"value":{"name":"validation_error","message":"Variable \"items\" should be array but a string was given.","status_code":422}},"undeclared_variable":{"value":{"name":"validation_error","message":"This template does not declare \"last_name\". Remove them, or declare them on the template.","status_code":422}},"never_published":{"value":{"name":"validation_error","message":"Template \"welcome\" has no published version yet. Publish one, or name a version explicitly.","status_code":422}},"archived":{"value":{"name":"validation_error","message":"Template \"welcome\" is archived. Restore it before sending with it.","status_code":422}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/templates/library":{"get":{"tags":["Templates"],"operationId":"listLibraryTemplates","summary":"List the starter templates","description":"The built-in library: complete, tested transactional emails you can copy into your account and edit. They are the same for every account and nothing here is yours yet — adopting one is what makes a copy you own.\n\nThe response also carries `categories`, the set of categories present, so a picker can be built without hard-coding them.","parameters":[{"name":"category","in":"query","required":false,"description":"Return only starters in this category.","schema":{"type":"string","example":"Accounts"}}],"responses":{"200":{"description":"The starter library.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/LibraryTemplate"}},"categories":{"type":"array","items":{"type":"string"}}}}}}}}}},"/templates/library/{slug}/adopt":{"post":{"tags":["Templates"],"operationId":"adoptLibraryTemplate","summary":"Copy a starter into your account","description":"Copies the starter in as a normal template of your own, published at version 1 and immediately sendable. From then on it is yours: editing it creates new versions and nothing about it tracks the library.\n\nThe slug is taken from the starter. If you already have a template with that slug, the copy gets a numbered one rather than overwriting what is there.","parameters":[{"name":"slug","in":"path","required":true,"description":"The starter’s slug, from the library listing.","schema":{"type":"string","example":"password-reset"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdoptTemplateRequest"}}}},"responses":{"201":{"description":"Your copy, published and ready to send.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/events":{"post":{"operationId":"postEvent","summary":"Tell RelyKit something happened","description":"Posts an event from your application. It may start a flow, wake a flow that was waiting for it, or neither, and is recorded either way so a trigger that did nothing can still be looked at.\n\nThe id is yours, and posting the same one twice runs the flow once. Derive it from whatever happened rather than generating a fresh one per attempt.","tags":["Flows"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","name","recipient"],"properties":{"id":{"type":"string","description":"Your id for this event. Reuse it to retry safely.","example":"evt_8812"},"name":{"type":"string","description":"What happened. Matches a flow's trigger.","example":"user.signed_up"},"recipient":{"type":"string","format":"email","description":"The address this event is about. Flows send here.","example":"kai@example.com"},"data":{"type":"object","description":"Anything else. Available to templates and to conditions.","example":{"plan":"pro","first_name":"Kai"},"additionalProperties":true}}}}}},"responses":{"200":{"description":"This id was posted before. Nothing new was started."},"202":{"description":"Recorded.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"recipient":{"type":"string"},"occurred_at":{"type":"string","format":"date-time"},"flows_started":{"type":"integer"},"runs_woken":{"type":"integer"}}}}}},"422":{"description":"The event is missing a name, a recipient or an id."}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"An API key, sent as `Authorization: Bearer rlk_live_...`. Keys are issued per account and shown once, when they are created. `GET /health` is the only route that does not need one."}},"parameters":{"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"description":"A key of your choosing, unique per logical send. Replaying it on this account returns the original message with `Idempotent-Replayed: true` instead of sending again, which makes a retry after a timeout safe. At most 255 characters.","schema":{"type":"string","maxLength":255},"example":"welcome-user-42"},"EmailId":{"name":"id","in":"path","required":true,"description":"The message id returned by `POST /emails`.","schema":{"type":"string","format":"uuid"},"example":"3f8a1c2e-9f6d-4c71-8c3a-6a5f2b1d0e44"},"DomainId":{"name":"id","in":"path","required":true,"description":"The domain id.","schema":{"type":"string","format":"uuid"},"example":"7b3d9e10-4a52-4f8c-9d6b-1c0e5a2f8b33"},"WebhookId":{"name":"id","in":"path","required":true,"description":"The webhook endpoint id.","schema":{"type":"string","format":"uuid"},"example":"9d2f4a11-5c8b-4e37-b6a0-2f1e9c4d7a55"},"TemplateRef":{"name":"ref","in":"path","required":true,"description":"The template, named either by its id or by its slug. Both work on every template route.","schema":{"type":"string"},"example":"welcome"},"TemplateVersionNumber":{"name":"version","in":"path","required":true,"description":"Which version, counting from 1. Versions are never renumbered and never reused.","schema":{"type":"integer","minimum":1},"example":2}},"responses":{"InvalidJson":{"description":"The request body was empty or was not valid JSON.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"name":"invalid_json","message":"Request body is not valid JSON.","status_code":400}}}},"Unauthorized":{"description":"The API key is missing, malformed, unknown, or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missing":{"value":{"name":"unauthorized","message":"Missing API key. Send it as \"Authorization: Bearer <key>\".","status_code":401}},"revoked":{"value":{"name":"unauthorized","message":"This API key has been revoked.","status_code":401}}}}}},"Forbidden":{"description":"The key is valid but the account is blocked: paused for reputation, or suspended.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"paused":{"value":{"name":"forbidden","message":"Sending is paused for this account: bounce rate 7.20% over 250 messages exceeds the 5.00% limit.","status_code":403}},"suspended":{"value":{"name":"forbidden","message":"This account is suspended.","status_code":403}}}}}},"NotFound":{"description":"No such resource on this account. An id that is not a UUID gets this too, rather than a validation error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"name":"not_found","message":"Email not found.","status_code":404}}}},"MessageTooLarge":{"description":"The message, counting subject, bodies and decoded attachments, is over the 10 MB limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"name":"message_too_large","message":"Message exceeds the 10 MB limit.","status_code":413}}}},"ValidationError":{"description":"The request was well-formed JSON but something in it is not acceptable. `message` names the field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missing_field":{"value":{"name":"validation_error","message":"subject is required.","status_code":422}},"bad_address":{"value":{"name":"validation_error","message":"to[0] is not a valid email address: \"not-an-address\".","status_code":422}},"reserved_header":{"value":{"name":"validation_error","message":"Header \"DKIM-Signature\" is set by the service and cannot be overridden.","status_code":422}},"template_and_content":{"value":{"name":"validation_error","message":"A message using a template cannot also set subject and html. The template provides them.","status_code":422}},"missing_variable":{"value":{"name":"validation_error","message":"This template requires the variable \"first_name\" (string).","status_code":422}}}}}},"RateLimited":{"description":"Too many requests, or the account's daily send limit is used up. `rate_limited` carries a `Retry-After` header; `daily_limit_exceeded` does not, because the limit resets at midnight UTC.","headers":{"Retry-After":{"description":"Seconds to wait before retrying. Sent with `rate_limited` only.","schema":{"type":"integer","minimum":0},"example":1}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"rate_limited":{"value":{"name":"rate_limited","message":"Too many requests. Retry after 1 second(s).","status_code":429}},"daily_limit_exceeded":{"value":{"name":"daily_limit_exceeded","message":"This account may send 100 messages per day until it is approved. Contact support to have it reviewed.","status_code":429}}}}}}},"schemas":{"Error":{"type":"object","title":"Error","description":"Every failing request returns this shape. Branch on `name`, not on `message`.","required":["name","message","status_code"],"properties":{"name":{"type":"string","description":"The stable error identifier.","enum":["invalid_json","validation_error","message_too_large","unauthorized","forbidden","domain_not_verified","unsubscribe_unavailable","not_found","conflict","method_not_allowed","rate_limited","daily_limit_exceeded","internal_error"]},"message":{"type":"string","description":"A sentence saying what to fix. Written for a developer, not for an end user."},"status_code":{"type":"integer","description":"The HTTP status, repeated in the body."}},"example":{"name":"validation_error","message":"Provide html, text, or both.","status_code":422}},"EmailAddress":{"type":"string","title":"EmailAddress","description":"One address, bare (`you@example.com`), with a display name (`Acme <hello@acme.com>`), or quoted (`\"Acme, Inc.\" <hello@acme.com>`). The domain part is lower-cased and the normalised form is what the API echoes back. At most 254 characters.","maxLength":320,"examples":["you@example.com","Acme <hello@acme.com>"]},"AddressList":{"title":"AddressList","description":"One address or a list of them.","oneOf":[{"$ref":"#/components/schemas/EmailAddress"},{"type":"array","items":{"$ref":"#/components/schemas/EmailAddress"}}]},"Attachment":{"type":"object","title":"Attachment","required":["filename","content"],"properties":{"filename":{"type":"string","description":"The name the recipient sees. No path separators and no line breaks.","minLength":1},"content":{"type":"string","format":"byte","description":"The file, base64 encoded. Whitespace is ignored."},"content_type":{"type":["string","null"],"description":"MIME type. Guessed from the filename when omitted.","maxLength":200,"examples":["application/pdf"]}}},"SendEmailRequest":{"type":"object","title":"SendEmailRequest","description":"Everything needed to compose one message. `to`, `cc` and `bcc` together may name at most 50 recipients, and the whole message may be at most 10 MB.\n\nThe content comes from one of two places: either `subject` with `html` and/or `text`, or `template` with `variables`. A request that names a template and also sets `subject`, `html` or `text` is refused with `validation_error` (422) — the template provides all three.","required":["from","to"],"properties":{"from":{"allOf":[{"$ref":"#/components/schemas/EmailAddress"}],"description":"The sender. Its host must be a verified domain on this account, or a subdomain of one."},"to":{"allOf":[{"$ref":"#/components/schemas/AddressList"}],"description":"At least one recipient."},"cc":{"$ref":"#/components/schemas/AddressList"},"bcc":{"$ref":"#/components/schemas/AddressList"},"reply_to":{"allOf":[{"$ref":"#/components/schemas/AddressList"}],"description":"At most 10 addresses."},"subject":{"type":"string","description":"Required and non-empty, unless `template` is given, in which case the template provides it and sending it here is a 422. No line breaks; at most 998 characters. Trimmed.","minLength":1,"maxLength":998},"html":{"type":["string","null"],"description":"The HTML body. Provide `html`, `text`, or both — unless `template` is given, which provides them."},"text":{"type":["string","null"],"description":"The plain text body. Not allowed alongside `template`."},"headers":{"type":"object","description":"Extra headers. Names must match `[A-Za-z0-9-]+`, values are strings of at most 998 characters with no line breaks. Headers the composer owns are refused: From, To, Cc, Bcc, Subject, Reply-To, Sender, Date, Message-ID, Return-Path, Received, Content-Type, Content-Transfer-Encoding, MIME-Version, DKIM-Signature, List-Unsubscribe and List-Unsubscribe-Post.","additionalProperties":{"type":"string","maxLength":998},"examples":[{"X-Entity-Ref-ID":"inv_2026_04"}]},"attachments":{"type":"array","description":"At most 20 files, within the 10 MB message limit.","maxItems":20,"items":{"$ref":"#/components/schemas/Attachment"}},"tags":{"type":"object","description":"Up to 20 string labels stored with the message and repeated in webhook payloads. Keys match `[A-Za-z0-9_.-]{1,64}`; values are at most 256 characters.","additionalProperties":{"type":"string","maxLength":256},"examples":[{"campaign":"welcome"}]},"scheduled_at":{"type":["string","null"],"format":"date-time","description":"Hold the message until this ISO 8601 instant, at most 30 days ahead. A time already past is treated as null and sends immediately."},"unsubscribe":{"type":"boolean","default":false,"description":"Add the `List-Unsubscribe` and `List-Unsubscribe-Post` headers for one-click unsubscribe. Requires exactly one recipient across `to`, `cc` and `bcc`, because the signed link identifies a single address. Returns 503 `unsubscribe_unavailable` if the deployment has no public base URL and signing secret."},"template":{"type":"string","description":"The template to render, named by its id or its slug. Its published version is used unless `template_version` names another. An archived template, or one that has never been published, is refused with `validation_error`; a name that matches no template on this account is a `not_found`.","examples":["welcome"]},"template_version":{"type":["integer","null"],"minimum":1,"description":"Pin the send to one version, rather than following whatever is published. A version that does not exist is a `not_found`."},"variables":{"type":"object","description":"Values for the variables the template version declares. A missing required variable, a value of the wrong type, or a name the template does not declare is a `validation_error`. Only meaningful alongside `template`: sent without one it is ignored.","additionalProperties":true,"examples":[{"first_name":"Kai","items":[{"name":"Desk lamp"}]}]}},"oneOf":[{"title":"Inline content","required":["subject"],"not":{"required":["template"]}},{"title":"From a template","required":["template"],"not":{"anyOf":[{"required":["subject"]},{"required":["html"]},{"required":["text"]}]}}]},"SuppressedRecipient":{"type":"object","title":"SuppressedRecipient","description":"A recipient dropped from the message before it was queued.","required":["email","reason"],"properties":{"email":{"type":"string","format":"email"},"reason":{"$ref":"#/components/schemas/SuppressionReason"}}},"Email":{"type":"object","title":"Email","description":"A stored message. Addresses are the normalised forms; suppressed recipients have already been removed from `to`, `cc` and `bcc`.","required":["id","status","from","to","cc","bcc","reply_to","subject","tags","template_id","template_version","unsubscribe","suppressed","scheduled_at","attempts","last_error","ses_message_id","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","description":"Where the message is. It only ever moves to a worse outcome, so a delivery after a bounce leaves it `bounced`. `cancelled` means every recipient was suppressed and nothing was sent.","enum":["queued","sending","sent","delivered","bounced","complained","rejected","failed","cancelled"]},"from":{"type":"string"},"to":{"type":"array","items":{"type":"string"}},"cc":{"type":"array","items":{"type":"string"}},"bcc":{"type":"array","items":{"type":"string"}},"reply_to":{"type":"array","items":{"type":"string"}},"subject":{"type":"string"},"tags":{"type":"object","additionalProperties":{"type":"string"}},"unsubscribe":{"type":"boolean","description":"Whether the message carries List-Unsubscribe headers."},"suppressed":{"type":"array","description":"Recipients dropped at send time because they were on the suppression list. Each also appears on the timeline as an `email.suppressed` event.","items":{"$ref":"#/components/schemas/SuppressedRecipient"}},"scheduled_at":{"type":["string","null"],"format":"date-time"},"attempts":{"type":"integer","description":"How many times the worker has tried to hand this message to SES."},"last_error":{"type":["string","null"],"description":"Why the last attempt failed, or why the message was cancelled."},"ses_message_id":{"type":["string","null"],"description":"The SES message id, set once SES accepted the message."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"template_id":{"type":["string","null"],"format":"uuid","description":"The template that rendered this message, or null when the content was sent inline."},"template_version":{"type":["integer","null"],"description":"The exact version that rendered it. Kept even after the template moves on or is archived, so what was actually sent stays knowable."}},"example":{"id":"3f8a1c2e-9f6d-4c71-8c3a-6a5f2b1d0e44","status":"queued","from":"\"Acme\" <hello@acme.com>","to":["you@example.com"],"cc":[],"bcc":[],"reply_to":[],"subject":"Welcome to Acme","tags":{"campaign":"welcome"},"unsubscribe":false,"suppressed":[],"scheduled_at":null,"attempts":0,"last_error":null,"ses_message_id":null,"created_at":"2026-04-01T09:00:00.000Z","updated_at":"2026-04-01T09:00:00.000Z","template_id":null,"template_version":null}},"EmailEventType":{"type":"string","title":"EmailEventType","description":"The event types the service records and delivers to webhooks.","enum":["email.sent","email.delivered","email.delayed","email.bounced","email.complained","email.rejected","email.opened","email.clicked","email.failed","email.suppressed"]},"EmailEvent":{"type":"object","title":"EmailEvent","description":"One thing that happened to one recipient of one message. The contents of `data` depend on `type`.","required":["id","type","email_id","recipient","data","occurred_at"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"$ref":"#/components/schemas/EmailEventType"},"email_id":{"type":"string","format":"uuid"},"recipient":{"type":["string","null"],"description":"The address this event is about. Null when SES reported the event for a message with several recipients and named none of them."},"data":{"type":"object","description":"Event detail. `email.delivered` carries `smtp_response`; `email.bounced` carries `bounce_type` (`hard` or `soft`), `sub_type`, `status` and `diagnostic_code`; `email.complained` carries `feedback_type` and `user_agent`; `email.delayed` carries `delay_type`, `diagnostic_code` and `expires_at`; `email.opened` carries `ip_address` and `user_agent`; `email.clicked` adds `link`; `email.rejected` and `email.failed` carry `reason`; `email.suppressed` carries the suppression `reason`.","additionalProperties":true},"occurred_at":{"type":"string","format":"date-time","description":"When SES says it happened, not when it was recorded."}}},"DomainRecord":{"type":"object","title":"DomainRecord","description":"One DNS record the customer must publish, with the result of the last check.","required":["purpose","type","host","value","required","status","found"],"properties":{"purpose":{"type":"string","enum":["dkim","mail_from_mx","mail_from_spf","dmarc"]},"type":{"type":"string","enum":["CNAME","MX","TXT"]},"host":{"type":"string","description":"The name to create.","examples":["abc123._domainkey.acme.com"]},"value":{"type":"string","description":"The value to publish. For MX it includes the priority.","examples":["abc123.dkim.amazonses.com","10 feedback-smtp.us-east-1.amazonses.com"]},"required":{"type":"boolean","description":"Whether verification waits for it. The DMARC record is recommended and checked, but never blocks."},"status":{"type":"string","description":"`unchecked` until the first check. `mismatch` means DNS answered with something else, `missing` means it answered with nothing, `error` means the lookup itself failed.","enum":["unchecked","pass","missing","mismatch","error"]},"found":{"type":"array","description":"What DNS actually returned, so you can show the customer the wrong value. Capped at 25 entries.","maxItems":25,"items":{"type":"string"}},"error":{"type":"string","description":"The resolver error, present only when `status` is `error`.","examples":["ESERVFAIL"]},"checked_at":{"type":"string","format":"date-time","description":"When this record was last resolved. Absent until the first check."}}},"Domain":{"type":"object","title":"Domain","description":"A sending identity. A verified domain also covers its subdomains.","required":["id","name","region","status","records","ses","created_at","verified_at","last_checked_at"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","examples":["acme.com"]},"region":{"type":"string","description":"The AWS region the SES identity lives in.","examples":["us-east-1"]},"status":{"type":"string","description":"`failed` means the setup never completed within the deployment's window; such a domain stops being polled and is only re-checked on request. A domain that was verified once and later breaks goes back to `pending`, never to `failed`.","enum":["pending","verified","failed"]},"records":{"type":"array","items":{"$ref":"#/components/schemas/DomainRecord"}},"ses":{"type":"object","description":"What SES itself reports about the identity.","required":["dkim","mail_from"],"properties":{"dkim":{"type":["string","null"],"description":"SES DKIM status: SUCCESS, PENDING, FAILED, TEMPORARY_FAILURE, NOT_STARTED, or UNKNOWN when SES could not be reached.","examples":["SUCCESS"]},"mail_from":{"type":["string","null"],"description":"SES custom MAIL FROM status.","examples":["SUCCESS"]}}},"created_at":{"type":"string","format":"date-time"},"verified_at":{"type":["string","null"],"format":"date-time","description":"When it first verified. Kept even if the domain later falls back to pending."},"last_checked_at":{"type":["string","null"],"format":"date-time"}},"example":{"id":"7b3d9e10-4a52-4f8c-9d6b-1c0e5a2f8b33","name":"acme.com","region":"us-east-1","status":"pending","records":[{"purpose":"dkim","type":"CNAME","host":"abc123._domainkey.acme.com","value":"abc123.dkim.amazonses.com","required":true,"status":"unchecked","found":[]},{"purpose":"mail_from_mx","type":"MX","host":"send.acme.com","value":"10 feedback-smtp.us-east-1.amazonses.com","required":true,"status":"unchecked","found":[]},{"purpose":"mail_from_spf","type":"TXT","host":"send.acme.com","value":"v=spf1 include:amazonses.com ~all","required":true,"status":"unchecked","found":[]},{"purpose":"dmarc","type":"TXT","host":"_dmarc.acme.com","value":"v=DMARC1; p=none; rua=mailto:dmarc@acme.com","required":false,"status":"unchecked","found":[]}],"ses":{"dkim":null,"mail_from":null},"created_at":"2026-03-30T12:00:00.000Z","verified_at":null,"last_checked_at":null}},"CreateWebhookRequest":{"type":"object","title":"CreateWebhookRequest","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Where to POST events. Must be https, except for localhost, which may be http. At most 2048 characters.","maxLength":2048,"examples":["https://hooks.acme.com/relay"]},"event_types":{"type":"array","description":"Which events to receive. Omit it, or send an empty list, to receive every event. Duplicates are collapsed.","items":{"$ref":"#/components/schemas/EmailEventType"}},"description":{"type":"string","description":"A label for your own use. At most 200 characters.","maxLength":200}}},"UpdateWebhookRequest":{"type":"object","title":"UpdateWebhookRequest","description":"Every field is optional; only what you send is changed.","properties":{"url":{"type":"string","format":"uri","maxLength":2048},"event_types":{"type":"array","items":{"$ref":"#/components/schemas/EmailEventType"}},"description":{"type":"string","maxLength":200},"enabled":{"type":"boolean","description":"Set to true to resume an endpoint that repeated failures disabled. Doing so also resets `consecutive_failures` and clears `disabled_reason`."}}},"Webhook":{"type":"object","title":"Webhook","description":"An endpoint that receives signed events.","required":["id","url","description","event_types","enabled","consecutive_failures","disabled_reason","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"description":{"type":"string","description":"Empty string when none was given."},"event_types":{"type":"array","description":"An empty list means every event.","items":{"$ref":"#/components/schemas/EmailEventType"}},"enabled":{"type":"boolean"},"consecutive_failures":{"type":"integer","description":"Failed deliveries since the last success. The endpoint is disabled when this reaches the deployment's limit, ten by default."},"disabled_reason":{"type":["string","null"],"description":"Why the service disabled the endpoint, if it did."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"example":{"id":"9d2f4a11-5c8b-4e37-b6a0-2f1e9c4d7a55","url":"https://hooks.acme.com/relay","description":"production","event_types":["email.delivered","email.bounced"],"enabled":true,"consecutive_failures":0,"disabled_reason":null,"created_at":"2026-03-31T10:15:00.000Z","updated_at":"2026-03-31T10:15:00.000Z"}},"WebhookWithSecret":{"title":"WebhookWithSecret","description":"The webhook as returned by `POST /webhooks`, the one response that includes the signing secret.","allOf":[{"$ref":"#/components/schemas/Webhook"},{"type":"object","required":["secret"],"properties":{"secret":{"type":"string","description":"The signing secret. Shown once, at creation. Verify each delivery by recomputing `v1,<base64 HMAC-SHA256 of \"<webhook-id>.<webhook-timestamp>.<raw body>\">` with it, and reject timestamps older than five minutes.","examples":["whsec_2m4bT1x8Qq4bY9Jk3pL0aZ5s"]}}}]},"WebhookDelivery":{"type":"object","title":"WebhookDelivery","description":"One attempt, or series of attempts, to deliver one event to one endpoint. Any 2xx within ten seconds counts as delivered; failures retry after 1 min, 5 min, 30 min, 2 h and 8 h.","required":["id","webhook_id","event_id","status","attempts","status_code","response_excerpt","last_error","next_attempt_at","delivered_at","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"webhook_id":{"type":"string","format":"uuid"},"event_id":{"type":["string","null"],"format":"uuid","description":"The timeline event being delivered. Null for a test send, which has no event behind it."},"status":{"type":"string","enum":["pending","sending","delivered","failed"]},"attempts":{"type":"integer"},"status_code":{"type":["integer","null"],"description":"The endpoint's HTTP status on the last attempt. Null if the request never completed."},"response_excerpt":{"type":["string","null"],"description":"The first 500 characters of the endpoint's response body."},"last_error":{"type":["string","null"],"description":"Why the last attempt failed.","examples":["timed out after 10000ms"]},"next_attempt_at":{"type":"string","format":"date-time","description":"When the next attempt is due. Meaningful while `status` is `pending`."},"delivered_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"SuppressionReason":{"type":"string","title":"SuppressionReason","description":"Why an address is suppressed. `hard_bounce`, `soft_bounces` (three within seven days) and `complaint` are written by the service from delivery events; `unsubscribe` comes from a one-click unsubscribe or from you; `manual` is yours.","enum":["hard_bounce","soft_bounces","complaint","unsubscribe","manual"]},"Suppression":{"type":"object","title":"Suppression","description":"An address this account will not send to. The list is checked before every send.","required":["email","reason","source_email_id","created_at"],"properties":{"email":{"type":"string","format":"email","description":"Stored lower-cased, local part included."},"reason":{"$ref":"#/components/schemas/SuppressionReason"},"source_email_id":{"type":["string","null"],"format":"uuid","description":"The message whose bounce, complaint or unsubscribe created this entry. Null for entries added by hand."},"created_at":{"type":"string","format":"date-time"}},"example":{"email":"bounced@example.com","reason":"hard_bounce","source_email_id":"3f8a1c2e-9f6d-4c71-8c3a-6a5f2b1d0e44","created_at":"2026-04-01T09:00:03.900Z"}},"Account":{"type":"object","title":"Account","required":["id","name","status","approved","tracking","daily_limit","effective_daily_limit","paused_reason","paused_at","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"status":{"type":"string","description":"`paused` and `suspended` accounts get 403 on every authenticated route.","enum":["new","active","paused","suspended"]},"approved":{"type":"boolean","description":"Whether an administrator has reviewed the account. Until then a lower daily cap applies."},"tracking":{"type":"boolean","description":"Whether opens and clicks are recorded. Untracked mail carries no pixel and no rewritten links."},"daily_limit":{"type":"integer","description":"The account's own limit."},"effective_daily_limit":{"type":"integer","description":"The limit actually enforced: the lower of `daily_limit` and the new-account cap while `approved` is false."},"paused_reason":{"type":["string","null"],"description":"Why sending was paused, quoted back in the 403."},"paused_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}},"example":{"id":"1a9f3c55-7e21-4b0d-8f4c-3d2b6e1a9c07","name":"Acme","status":"active","approved":true,"tracking":false,"daily_limit":50000,"effective_daily_limit":50000,"paused_reason":null,"paused_at":null,"created_at":"2026-03-01T08:00:00.000Z"}},"Reputation":{"title":"Reputation","description":"The account, plus how it has been sending over the last `window_hours`.","allOf":[{"$ref":"#/components/schemas/Account"},{"type":"object","required":["verified_domains","window_hours","sent","bounced","complained","bounce_rate","complaint_rate"],"properties":{"verified_domains":{"type":"integer","description":"How many domains on the account are verified."},"window_hours":{"type":"integer","description":"The width of the measurement window. 24 by default.","examples":[24]},"sent":{"type":"integer","description":"Messages handed to SES in the window."},"bounced":{"type":"integer","description":"Of those, how many had at least one hard bounce."},"complained":{"type":"integer","description":"Of those, how many drew at least one complaint."},"bounce_rate":{"type":"number","format":"double","description":"`bounced / sent`, rounded to four decimals; 0 when nothing was sent. The service pauses an account above 0.05.","minimum":0,"maximum":1},"complaint_rate":{"type":"number","format":"double","description":"`complained / sent`, rounded to four decimals. The service pauses an account above 0.0025.","minimum":0,"maximum":1}}}],"example":{"id":"1a9f3c55-7e21-4b0d-8f4c-3d2b6e1a9c07","name":"Acme","status":"active","approved":true,"tracking":false,"daily_limit":50000,"effective_daily_limit":50000,"paused_reason":null,"paused_at":null,"created_at":"2026-03-01T08:00:00.000Z","verified_domains":1,"window_hours":24,"sent":1284,"bounced":9,"complained":1,"bounce_rate":0.007,"complaint_rate":0.0008}},"BatchSendRequest":{"type":"object","required":["emails"],"properties":{"emails":{"type":"array","minItems":1,"maxItems":100,"description":"The messages to send. Each entry takes the same fields as POST /emails, including `template`, `template_version` and `variables` in place of `subject`, `html` and `text`, and may also carry its own idempotency_key. A bare JSON array of messages is accepted in place of this object.","items":{"allOf":[{"$ref":"#/components/schemas/SendEmailRequest"},{"type":"object","properties":{"idempotency_key":{"type":"string","maxLength":255,"description":"Per-entry idempotency. Repeating a key returns the message it created rather than sending again. Two entries in one batch may not share a key."}},"description":"Batch-only fields. Everything else, including the template fields, is as on POST /emails — except that a template failure in a batch is always reported as `validation_error` (422) naming the entry, even where the same failure on its own would be a 404."}]}}}},"BatchSendResponse":{"type":"object","required":["data","replayed"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Email"},"description":"One message per entry, in the order they were sent."},"replayed":{"type":"integer","description":"How many entries matched an idempotency key already used, and so were not created again."}}},"TemplateVariable":{"type":"object","title":"TemplateVariable","description":"One declared variable. A template may declare at most 100, and everything its source uses must be declared, so a template cannot quietly go out with a hole where a name should be. Only `name` is required when declaring one; the response always carries `name`, `type`, `required` and `description`.","required":["name"],"properties":{"name":{"type":"string","description":"Starts with a letter or underscore, then letters, digits and underscores. `this` is reserved: it names the current item inside `{{#each}}`.","examples":["first_name"]},"type":{"type":"string","description":"A name the source loops over with `{{#each}}` must be declared `array`, or the save is refused. At send time the value must be of exactly this type.","enum":["string","number","boolean","array"],"default":"string"},"required":{"type":"boolean","default":true,"description":"A required variable with no value at send time is a `validation_error`."},"description":{"type":"string","maxLength":200,"default":"","description":"For whoever fills the variable in. Never rendered."},"default":{"description":"Used when the value is omitted. A variable cannot be both required and defaulted. Without one, an omitted optional variable renders as `\"\"`, `[]` or `false`, by its type."}}},"TemplateVersion":{"type":"object","title":"TemplateVersion","description":"One saved version of a template. Versions are immutable — the database refuses an update — so changing a template means appending the next one.","required":["version","subject","html","text","variables","notes","created_at"],"properties":{"version":{"type":"integer","minimum":1,"description":"1 for the first, then one higher per save."},"subject":{"type":"string","description":"May contain variables. It is rendered as plain text, whatever the body is."},"html":{"type":["string","null"],"description":"The HTML body. Values substituted into it are HTML-escaped by the renderer, and there is deliberately no unescaped-output syntax: markup belongs in the template."},"text":{"type":["string","null"],"description":"The plain text body. Stored in the `text_body` column, but returned as `text`."},"variables":{"type":"array","description":"What this version declares. Proved against the source when the version was saved.","items":{"$ref":"#/components/schemas/TemplateVariable"}},"notes":{"type":"string","description":"Why this version exists. Empty when none was given."},"created_at":{"type":"string","format":"date-time"}},"example":{"version":2,"subject":"Welcome to Acme, {{first_name}}","html":"<p>Hi {{first_name}}, thanks for joining.</p>{{#if items}}<ul>{{#each items}}<li>{{this.name}}</li>{{/each}}</ul>{{/if}}","text":"Hi {{first_name}}, thanks for joining.","variables":[{"name":"first_name","type":"string","required":true,"description":"Used in the greeting"},{"name":"items","type":"array","required":false,"description":"What they ordered","default":[]}],"notes":"Shorter subject line","created_at":"2026-04-01T08:30:00.000Z"}},"Template":{"type":"object","title":"Template","description":"A template and where it stands: which version is live, how many exist, and how many messages it has rendered.","required":["id","slug","name","description","current_version","archived","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string","description":"The stable handle a sender may use in place of the id. Renaming the template never changes it. Unique per account.","examples":["welcome"]},"name":{"type":"string","description":"At most 120 characters."},"description":{"type":"string","description":"At most 500 characters. Empty when none was given."},"current_version":{"type":["integer","null"],"description":"Which version is live. Null until the first publish, which is what lets a template be drafted without being sendable."},"archived":{"type":"boolean","description":"Archived templates stay readable and keep their history, but sending with one is refused until it is restored."},"version_count":{"type":"integer","description":"How many versions exist. Counted by `GET /templates` only; absent from the other responses."},"message_count":{"type":"integer","description":"How many messages this template has rendered. `GET /templates` only."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"version":{"allOf":[{"$ref":"#/components/schemas/TemplateVersion"}],"description":"The version just created. Returned by `POST /templates` only."},"versions":{"type":"array","description":"Every version, newest first. Returned by `GET /templates/{ref}` only.","items":{"$ref":"#/components/schemas/TemplateVersion"}}},"example":{"id":"0b6f1d3c-2c47-4a56-9b1e-8c2f7a0d4e11","slug":"welcome","name":"Welcome","description":"Sent when someone finishes signing up.","current_version":2,"archived":false,"version_count":2,"message_count":1840,"created_at":"2026-03-20T10:00:00.000Z","updated_at":"2026-04-01T08:30:00.000Z"}},"CreateTemplateRequest":{"type":"object","title":"CreateTemplateRequest","description":"A template and its version 1 in one call. The source is compiled before anything is stored: every variable it uses must be declared, and used as declared.","required":["name","subject"],"properties":{"name":{"type":"string","maxLength":120,"description":"Required and non-empty. Trimmed."},"slug":{"type":"string","maxLength":64,"pattern":"^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$","description":"Lowercase letters, digits and hyphens, starting and ending with a letter or digit. Derived from `name` when omitted. A slug already used on this account answers 409."},"description":{"type":"string","maxLength":500},"subject":{"type":"string","minLength":1,"maxLength":2000,"description":"Required and non-empty. May contain variables."},"html":{"type":["string","null"],"maxLength":1048576,"description":"Provide `html`, `text`, or both."},"text":{"type":["string","null"],"maxLength":1048576},"variables":{"type":"array","maxItems":100,"description":"What the source is allowed to use. A source that references anything else is a 422.","items":{"$ref":"#/components/schemas/TemplateVariable"}},"notes":{"type":"string","maxLength":500,"description":"A note stored against version 1."},"publish":{"type":"boolean","default":true,"description":"Version 1 is published, and the template immediately sendable, unless this is false. Note that `POST /templates/{ref}/versions` defaults the other way."}}},"CreateTemplateVersionRequest":{"type":"object","title":"CreateTemplateVersionRequest","description":"The next version of a template. Saving never edits: this appends a version and leaves whatever is live exactly as it was, unless `publish` is true.","required":["subject"],"properties":{"subject":{"type":"string","minLength":1,"maxLength":2000,"description":"Required and non-empty."},"html":{"type":["string","null"],"maxLength":1048576,"description":"Provide `html`, `text`, or both."},"text":{"type":["string","null"],"maxLength":1048576},"variables":{"type":"array","maxItems":100,"description":"Declared per version, so a new version may declare different variables from the live one.","items":{"$ref":"#/components/schemas/TemplateVariable"}},"notes":{"type":"string","maxLength":500},"publish":{"type":"boolean","default":false,"description":"Publish the new version as well as saving it. Left out, the version is a draft."}}},"PublishTemplateRequest":{"type":"object","title":"PublishTemplateRequest","description":"Which existing version to make live. Releasing and rolling back are the same call.","required":["version"],"properties":{"version":{"type":"integer","minimum":1,"description":"A whole number of at least 1. A version that does not exist answers 404."}}},"ArchiveTemplateRequest":{"type":"object","title":"ArchiveTemplateRequest","description":"Archive or restore. An absent or unreadable body archives, which is the common case.","properties":{"archived":{"type":"boolean","default":true,"description":"False restores the template. Anything else, including an omitted field, archives it."}}},"PreviewTemplateRequest":{"type":"object","title":"PreviewTemplateRequest","description":"What to render with. Send `{}` to preview a template that declares nothing: an empty request body is a 400, not an empty object.","properties":{"variables":{"type":"object","description":"Values for the declared variables, checked exactly as at send time: a missing required one, a wrong type, or a name the template does not declare is a 422.","additionalProperties":true,"examples":[{"first_name":"Kai","items":[{"name":"Desk lamp"}]}]},"version":{"type":"integer","minimum":1,"description":"Preview a version other than the published one. Useful for proving a draft before releasing it."}}},"TemplatePreview":{"type":"object","title":"TemplatePreview","description":"The same rendering a send would do, without sending.","required":["template_id","slug","version","rendered"],"properties":{"template_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"version":{"type":"integer","description":"The version that rendered this, published or named."},"rendered":{"type":"object","required":["subject","html","text"],"properties":{"subject":{"type":"string"},"html":{"type":["string","null"],"description":"Null when the version has no HTML body."},"text":{"type":["string","null"]}}}},"example":{"template_id":"0b6f1d3c-2c47-4a56-9b1e-8c2f7a0d4e11","slug":"welcome","version":2,"rendered":{"subject":"Welcome to Acme, Kai","html":"<p>Hi Kai, thanks for joining.</p><ul><li>Desk lamp</li></ul>","text":"Hi Kai, thanks for joining."}}},"LibraryTemplate":{"type":"object","description":"A starter template, ready to be copied into an account.","properties":{"slug":{"type":"string","example":"password-reset","description":"How this starter is named when adopting it."},"name":{"type":"string","example":"Password reset"},"category":{"type":"string","example":"Accounts"},"description":{"type":"string","example":"Sent when someone asks to reset their password."},"subject":{"type":"string","example":"Reset your {{product}} password"},"variables":{"type":"array","items":{"$ref":"#/components/schemas/TemplateVariable"}}}},"AdoptTemplateRequest":{"type":"object","properties":{"name":{"type":"string","description":"What to call your copy. Defaults to the starter’s own name.","example":"Password reset"}}}}}}