API reference
A JSON API over HTTPS. Every request carries an API key, every response is JSON, and every error has a stable name you can branch on. The machine-readable description of everything on this page is at /openapi.json.
Create an API key in the dashboard, add and verify the domain you want to send from,
then send. The base URL for this deployment is https://api.relykit.com.
curl https://api.relykit.com/emails \
-H "Authorization: Bearer rlk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.com>",
"to": "you@example.com",
"subject": "Hello",
"text": "It works."
}'
The response comes back immediately with a message id and the status
queued. Delivery happens in the background; poll
GET /emails/:id or, better, subscribe to webhooks.
Send your key as a bearer token. Keys are shown once when created and stored only as a hash, so a lost key is replaced rather than recovered.
Authorization: Bearer rlk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A revoked key, a suspended account, or a paused account each fail differently:
401 unauthorized for the key, 403 forbidden for the account,
with the pause reason in the message.
Every failure has the same shape, and name is stable across versions.
{
"name": "validation_error",
"message": "to[0] is not a valid email address: \"nope\".",
"status_code": 422
}
| Name | Status | Meaning |
|---|---|---|
| validation_error | 422 | The request was understood but a field is wrong. The message names the field. |
| unauthorized | 401 | Missing, malformed, unknown or revoked API key. |
| forbidden | 403 | The account is paused or suspended. |
| domain_limit_reached | 403 | The plan's sending domains are all in use. Remove one, or move to a plan with more. |
| account_not_approved | 403 | The account has not been approved to send yet. Everything else works; apply from the dashboard. |
| domain_not_verified | 403 | The from address is not on a verified domain of this account. |
| not_found | 404 | No such resource on this account. |
| conflict | 409 | The domain is already registered. |
| message_too_large | 413 | Over the 10 MB limit, attachments included. |
| rate_limited | 429 | Too many requests. Wait for Retry-After seconds. |
| daily_limit_exceeded | 429 | The account's daily allowance is used up. |
| unsubscribe_unavailable | 503 | One-click unsubscribe is not configured on this deployment. |
Send an Idempotency-Key header on POST /emails. If the same
account sends the same key again, the original message is returned with
Idempotent-Replayed: true and nothing new is queued. Use a key derived
from whatever caused the send, such as an order id plus a purpose.
Idempotency-Key: order-6be1f2-receipt
Create an endpoint in the dashboard or through the API and it receives a signed JSON POST for each event you subscribe to. The secret is shown once.
webhook-id: 5f2c1b7e-9d34-4a11-9d1e-5b2a0c7e4f31
webhook-timestamp: 1789458072
webhook-signature: v1,uT0k5s0V9Pq0mRr2M5ZQZ8yq3f1lQ5Yb0g2m8xV1r2c=
The signature is an HMAC-SHA256 over id.timestamp.body using the secret,
base64 encoded. Reject anything whose timestamp is more than five minutes old, and
compare in constant time. The SDK does both.
Any 2xx within ten seconds counts as delivered. Failures retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours; ten consecutive failures disable the endpoint and email the account owner.
A flow is a sequence your application starts by posting an event. It can wait, check what happened to a message it already sent, and send again. Welcoming somebody and chasing only the ones who never got started is four lines of JSON rather than a cron job and a table of your own.
There is no contact list and no consent record, because the event names its own recipient. Nothing is ever sent to somebody who did not cause the event, which is what keeps this transactional.
One call. It may start a flow, wake a flow that was waiting for it, or neither — and it is recorded either way, so a trigger that did nothing can still be looked at.
curl https://api.relykit.com/events \
-H "Authorization: Bearer rlk_live_..." \
-H "Content-Type: application/json" \
-d '{
"id": "evt_8812",
"name": "user.signed_up",
"recipient": "kai@example.com",
"data": { "plan": "pro", "first_name": "Kai" }
}'
The id is yours and it is what makes this safe to retry. Post the same
id twice and the flow runs once. Everything in data is available to your
templates and to any condition in the flow.
A flow is JSON, so it can live in your repository and be reviewed like anything else. This one welcomes a new user, leaves them alone for three days, and chases only those who never got started — unless the welcome bounced, in which case the address is wrong and chasing it would do harm.
{
"trigger": { "event": "user.signed_up" },
"from": "Acme <hello@acme.com>",
"start": "welcome",
"nodes": {
"welcome": { "type": "send", "template": "welcome", "next": "settle" },
"settle": { "type": "wait", "for": "3 days", "next": "did-they-start" },
"did-they-start": {
"type": "await",
"event": "user.activated",
"timeout": "7 days",
"on_event": "done",
"on_timeout": "was-it-delivered"
},
"was-it-delivered": {
"type": "if",
"when": { "message": "welcome", "was": "bounced" },
"then": "done",
"else": "nudge"
},
"nudge": { "type": "send", "template": "need-a-hand", "next": "done" },
"done": { "type": "stop" }
}
}
| Type | What it does | Fields |
|---|---|---|
send |
Sends one of your templates, filled in from the event data. | template, next |
wait |
Pauses. Written the way you would say it: "45 minutes", "3 days", "2 weeks". | for, next |
await |
Waits for another event about the same person, or gives up. Every await needs a timeout, or the run would never end. |
event, timeout, on_event,
on_timeout
|
if |
Splits in two. Tests either the event data or an earlier message. | when, then, else |
stop | Ends the run. | — |
Test something in the event that started the run:
{ "data": "plan", "equals": "pro" }
{ "data": "plan", "in": ["pro", "scale"] }
{ "data": "user.country", "not_equals": "NG" }
{ "data": "referrer", "exists": true }
Or test what became of a message this run already sent. This is the one worth knowing: if the welcome bounced, the address is wrong, and sending four more is how a sender teaches mailbox providers to distrust them.
{ "message": "welcome", "was": "bounced" }
{ "message": "welcome", "was": "opened" }
{ "message": "invoice", "was": "delivered" }
was may be delivered, opened,
clicked, bounced, complained,
failed or sent.
A flow is checked before it can be saved, because a mistake here sends real mail to
real people. It is refused if a branch points at a step that does not exist, if an
await has no timeout, or if steps loop without ever waiting — that last
one would send as fast as the database allows.
At run time: a suppressed address is skipped mid-sequence and the run records why, so somebody who unsubscribes on day two does not receive day three. Editing a flow never changes a run already going; it finishes the version it started under.
Open a flow in the dashboard and press Show me what it would do. It walks the whole sequence with a made-up event and shows every message it would send and which day it would land on, having sent nothing.
WhatsApp is not email with a phone number. A business may write first only with words Meta has approved, and may use its own words only for 24 hours after the person writes back. Everything below follows from those two rules.
You can do all of it today. A new account sends in the sandbox:
templates are approved at once, messages are recorded and go nowhere, and nothing
is charged. The API answers exactly as it will when you are live, with one
difference, which it tells you: "sandbox": true.
Under WhatsApp in the dashboard. Meta requires that everybody you write to has agreed to it, and bans a number that writes to people who have not. A sentence or two: where they are asked, and what they are told they will receive.
A template is the message with gaps in it. Write each gap as a name in double braces, and give an example for each: Meta reads the template with the examples filled in.
Hi {{ first_name }}, order {{ order_id }} left our warehouse today.
Choose what kind of message it is. Utility is about something the person did. Marketing is anything meant to bring them back, costs the most, and can be turned off by the recipient. Authentication is a passcode, and its words are Meta's. A template may have a picture, a video or a document at the top, and buttons underneath.
Meta rejects a message that starts or ends with a gap, so the composer refuses one first. Most reviews finish within a day. A rejection says what Meta said, and the template can be changed and sent for review again.
By its name, with its values by name. You never count a placeholder.
curl https://api.relykit.com/whatsapp \
-H "Authorization: Bearer rlk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"to": "+2348012345678",
"template": "order_shipped",
"language": "en",
"variables": { "first_name": "Kai", "order_id": "RK-1042" }
}'
The number is in international form, with its country code. The
Idempotency-Key makes a retry safe: send the same one twice and the
message goes once and is charged once.
{
"id": "3f8a1c2e-...",
"channel": "whatsapp",
"status": "sandboxed",
"sandbox": true,
"kind": "template",
"category": "utility",
"charged_cents": null,
"note": "Nothing was delivered. This account has no WhatsApp number yet. ..."
}
Read sandbox before you ship. A message that could not
leave is answered with a 202 like any other, and that field is the only thing that
says so.
A passcode takes a code and nothing else. It is not stored.
{ "to": "+2348012345678", "template": "login_code", "language": "en", "code": "402913" }
Add a webhook and subscribe to the events you want. whatsapp.delivered
means it reached the phone. whatsapp.read means the person opened the
chat, and is sent only when they have read receipts turned on, so its absence proves
nothing. whatsapp.failed carries Meta's error number. A message that
was charged for and not delivered is given back.
When somebody writes to you, your webhook receives whatsapp.received
with what they said and the id of the message they were answering. For the next 24
hours you may answer in your own words, or with a picture or a document.
{ "to": "+2348012345678", "text": "It is in Lagos and arrives tomorrow." }
The 24 hours are counted from what they sent. Answering does not extend them; only they can. Outside them the request is refused with a 422 that says when they last wrote, and you send a template instead.
STOP is obeyed. Somebody who sends STOP or UNSUBSCRIBE as their
whole message is added to your suppression list before you hear of it, and START
takes them off. The event says "meaning": "stop".
In the sandbox nothing leaves, so nothing comes back. Under Conversations you can pretend a reply arrived, which opens the 24 hours and tells your webhook exactly as a real one would.
Connect your own WhatsApp Business number from the WhatsApp page. You sign in to Meta in its own window and choose the number there. Your messages then carry your name, and what Meta thinks of the number is yours alone: its quality and its daily limit are shown on the same page.
A template belongs to the number it was reviewed for. One approved in the sandbox has never been seen by Meta, and is sent for review once you are connected.
A message is charged from your balance as it is sent, by the recipient's country and the kind of message. The prices that apply to you are on the WhatsApp page. A message is often less than a cent: what is owed is kept to four places, and a cent leaves your balance each time it adds up to one. Set a daily limit under Usage and a runaway loop stops at it.
Not everybody has WhatsApp, and a phone can be off for a day. A flow can try WhatsApp, give it ten minutes, and send a text message if it did not arrive. It is three steps, and you can see what each one is for.
"ship": { "type": "send", "channel": "whatsapp", "template": "order_shipped",
"language": "en", "to": "phone", "next": "give-it-time" },
"give-it-time": { "type": "wait", "for": "10 minutes", "next": "arrived?" },
"arrived?": { "type": "if", "when": { "message": "ship", "was": "delivered" },
"then": "done", "else": "text-them" },
"text-them": { "type": "send", "channel": "sms", "sender": "ACME", "to": "phone",
"text": "Order {{ order_id }} has shipped.", "next": "done" },
"done": { "type": "stop" }
to names the field of the event that holds the number. An event with no
number skips the step and carries on, and so does one for somebody who sent STOP.
Typed, no dependencies, retries on 429 and 5xx, and one error class for every failure.
npm install relykit
import { RelyKit } from 'relykit';
const relykit = new RelyKit({
apiKey: process.env.RELYKIT_API_KEY,
baseUrl: 'https://api.relykit.com',
});
const email = await relykit.emails.send({
from: 'Acme <hello@acme.com>',
to: 'you@example.com',
subject: 'Hello',
html: '<p>It works.</p>',
});
const events = await relykit.emails.events(email.id);
Sending messages and reading what happened to them.
Validates and queues one message. The response is the stored message with status queued; poll GET /emails/{id} or take webhooks for the outcome.
The 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.
Send 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.
In 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.
| Parameter | In | Description |
|---|---|---|
Responses: 200, 201, 400, 401, 403, 404, 413, 422, 429, 503
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, 401, 403, 404, 429
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.
{
"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"
}
]
}Responses: 200, 401, 403, 404, 429
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.
In 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.
{
"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, 201, 401, 403, 409, 422, 429, 503
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.
| Parameter | In | Description |
|---|---|---|
| id required | path | The message id returned when it was sent. |
Responses: 200, 401, 404, 409
Versioned content a send can name instead of carrying its own subject and bodies.
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}.
| Parameter | In | Description |
|---|---|---|
| archived | query | Exactly true includes archived templates. Any other value, or none, leaves them out. |
{
"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"
}
]
}Responses: 200, 401, 403, 429
Creates the template and its version 1 in one call, published and immediately sendable unless you pass publish: false.
The 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.
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.
{
"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"
}
}Responses: 201, 400, 401, 403, 409, 422, 429
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.
{
"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"
}
]
}Responses: 200, 401, 403, 404, 429
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.
The 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.
{
"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, 400, 401, 403, 404, 422, 429
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, 401, 403, 404, 429
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.
{
"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"
}Responses: 200, 400, 401, 403, 404, 422, 429
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.
Send { "archived": false } to restore. An absent or unreadable body archives, so a bare POST is the same as asking to archive.
{
"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"
}Responses: 200, 401, 403, 404, 429
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.
The 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.
Responses: 200, 400, 401, 403, 404, 422, 429
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.
The response also carries categories, the set of categories present, so a picker can be built without hard-coding them.
| Parameter | In | Description |
|---|---|---|
| category | query | Return only starters in this category. |
Responses: 200
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.
The 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.
| Parameter | In | Description |
|---|---|---|
| slug required | path | The starter’s slug, from the library listing. |
Responses: 201, 404
Sending identities and their DNS verification.
Every domain on the account, oldest first.
Responses: 200, 401, 403, 429
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.
A domain name exists once across the whole service, because it is one SES identity.
| Field | Type | Description |
|---|---|---|
| name required | string | 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. |
{
"name": "acme.com"
}Responses: 201, 400, 401, 403, 409, 422, 429
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, 401, 403, 404, 429
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, 401, 403, 404, 429
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, 401, 403, 404, 429
Signed delivery of timeline events to your endpoint.
Every endpoint on the account, oldest first. Secrets are not included.
Responses: 200, 401, 403, 429
Creates an endpoint and returns its signing secret. The secret is returned only by this call and never again, so store it now.
An empty or omitted event_types means every event type.
Responses: 201, 400, 401, 403, 422, 429
Responses: 200, 401, 403, 404, 429
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.
Responses: 200, 400, 401, 403, 404, 422, 429
Removes the endpoint and its pending deliveries.
Responses: 204, 401, 403, 404, 429
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.
A 200 here does not mean the endpoint accepted it — read status and status_code on the returned delivery.
{
"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"
}Responses: 200, 401, 403, 404, 429
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, 401, 403, 404, 429
The per-account list of addresses that are never sent to.
The account's suppression list, newest first. Pass email to look one address up exactly, which returns zero or one entry and ignores limit.
| Parameter | In | Description |
|---|---|---|
| query | Look up one address. Matched case-insensitively on the whole address. | |
| limit | query | How many entries to return. Clamped to 1..1000. Ignored when email is given. |
{
"data": [
{
"email": "bounced@example.com",
"reason": "hard_bounce",
"source_email_id": "3f8a1c2e-9f6d-4c71-8c3a-6a5f2b1d0e44",
"created_at": "2026-04-01T09:00:03.900Z"
}
]
}Responses: 200, 401, 403, 422, 429
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.
| Field | Type | Description |
|---|---|---|
| email required | string | The address to suppress. Stored lower-cased. |
| reason | string |
{
"email": "angry@example.com",
"reason": "manual"
}Responses: 201, 400, 401, 403, 422, 429
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, 401, 403, 404, 422, 429
Account settings and sending reputation.
The account this API key belongs to, including the daily limit actually enforced and, if sending is paused, why.
Responses: 200, 401, 403, 429
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.
| Field | Type | Description |
|---|---|---|
| tracking required | boolean | Whether to record opens and clicks. |
{
"tracking": true
}Responses: 200, 400, 401, 403, 422, 429
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.
The 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, 401, 403, 429
Text messages, sent synchronously and charged by segment.
Sends one SMS and answers once it has been handed to the network, which is the difference between this and POST /emails: there is no queue to poll. A passcode nobody receives for thirty seconds is a passcode somebody has already given up on.
The message is charged by segment, from the account balance, at the price for the destination country. The response carries the number of segments and the amount charged, so a bill can be reconciled against what was sent.
A destination with no route configured is sandboxed: the message is recorded, nothing is sent, nothing is charged, and the response says "sandbox": true at the top level with a note in words. Check that field before you rely on delivery.
Numbers on the account's SMS suppression list are refused with a 422 rather than dropped quietly. Suppression is per channel: an unsubscribed email address says nothing about a phone number.
Send an Idempotency-Key header to make a retry safe. A replay answers 200 with Idempotent-Replayed: true and the original message, and is charged once.
| Parameter | In | Description |
|---|---|---|
| Idempotency-Key | header | Your key for this send, at most 255 characters. Repeating it replays the first result instead of sending again. |
Responses: 200, 202, 400, 401, 402, 422, 429
Messages sent by template through a WhatsApp Business number.
Sends one message and answers once Meta has accepted it. What became of it afterwards arrives as events: whatsapp.delivered, whatsapp.read and whatsapp.failed.
There are two kinds of message. A template, which may be sent at any time. Templates are written under WhatsApp in the dashboard and reviewed by Meta; you write placeholders by name, {{ first_name }}, and send values by name. And your own words, as text, which WhatsApp allows for 24 hours after the recipient last wrote to you.
When somebody writes to you, your webhook receives whatsapp.received with what they said and the id of the message they were answering. The 24 hours are counted from what they sent; answering does not extend them.
STOP is obeyed. Somebody who sends STOP, UNSUBSCRIBE or the like as their whole message is added to your WhatsApp suppression list, and START takes them off it. Numbers on that list are refused with a 422. Suppression is per channel.
A message is sandboxed when this deployment has no WhatsApp number or the account has not been cleared for live sending: it is recorded, nothing is sent, and the response says "sandbox": true at the top level with the reason in note. In the sandbox a template is approved at once and a reply can be made up from the dashboard, so both halves can be built against.
A message is charged from your balance as it is sent, at the price for the recipient's country and the kind of message, and given back if Meta does not deliver it. A message is often less than a cent: what is owed is kept to four places, and a cent leaves your balance each time it adds up to one. The prices that apply to you are under WhatsApp in the dashboard. Nothing in the sandbox is charged for.
Send an Idempotency-Key header to make a retry safe. A replay answers 200 with Idempotent-Replayed: true and the original message.
| Parameter | In | Description |
|---|---|---|
| Idempotency-Key | header | Your key for this send, at most 255 characters. Repeating it replays the first result instead of sending again. |
Responses: 200, 202, 400, 401, 402, 403, 422, 429
Every template on the account, newest first, with where each stands.
Responses: 200, 401
Saves a template, and sends it to Meta for review if submit is true. What Meta would reject is refused first, with the reason: a message that starts or ends with a placeholder, a missing example, too many links.
A name is used once per language. In the sandbox a template is approved at once, and marked as one Meta has never seen.
When submit is asked for and Meta will not take the template for review, it is still saved: the answer is a 201 with status: "draft" and what Meta said in review_error.
Responses: 201, 400, 401, 403, 409, 422, 429
One template, with where it stands and what a send has to give it.
Responses: 200, 401, 404
For a draft, or for one Meta rejected. What Meta has read cannot be changed underneath it: a template that is in review or approved is a 409, and the way to change one is to make another under a new name.
Only what is sent changes. examples are added to the ones there are. buttons replace the ones there are. A file at the top is kept unless another is sent. The name and the language are how Meta knows a template and cannot be changed.
Pass submit: true to send it for review once it is changed.
Responses: 200, 400, 401, 403, 404, 409, 422, 429
Deletes it here and at Meta, with its picture. Messages that name it afterwards are refused.
Responses: 204, 401, 404, 422
For a draft, or for one Meta rejected that has been changed. Most reviews finish within a day. Retrieve the template again to see where it stands.
Responses: 200, 401, 403, 404, 409, 422, 429
Tells Meta that everything this person has written has been read, which turns the two ticks on their phone blue. For an application that shows the conversation itself. Opening a conversation in the dashboard does the same.
Answers 200 whatever Meta says: told is false when Meta refused, and those messages are tried again the next time.
Responses: 200, 401, 422, 429
Events from your application, and what they start.
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.
The 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.
| Field | Type | Description |
|---|---|---|
| id required | string | Your id for this event. Reuse it to retry safely. |
| name required | string | What happened. Matches a flow's trigger. |
| recipient required | string | The address this event is about. Flows send here. |
| data | object | Anything else. Available to templates and to conditions. |
Responses: 200, 202, 422
Liveness.
Returns 200 as long as the HTTP server is up. It does not check the database, and it needs no API key.
{
"ok": true
}Responses: 200