API reference

RelyKit API

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.

Getting started

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.

Authentication

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.

Errors

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
}
NameStatusMeaning
validation_error422The request was understood but a field is wrong. The message names the field.
unauthorized401Missing, malformed, unknown or revoked API key.
forbidden403The account is paused or suspended.
domain_limit_reached403The plan's sending domains are all in use. Remove one, or move to a plan with more.
account_not_approved403The account has not been approved to send yet. Everything else works; apply from the dashboard.
domain_not_verified403The from address is not on a verified domain of this account.
not_found404No such resource on this account.
conflict409The domain is already registered.
message_too_large413Over the 10 MB limit, attachments included.
rate_limited429Too many requests. Wait for Retry-After seconds.
daily_limit_exceeded429The account's daily allowance is used up.
unsubscribe_unavailable503One-click unsubscribe is not configured on this deployment.

Idempotency

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

Webhooks

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.

Flows

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.

Post an event

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.

Write a 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" }
  }
}

The five kinds of step

TypeWhat it doesFields
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
stopEnds the run.—

Conditions

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.

What a flow will not let you do

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.

See it before you switch it on

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, from nothing to a first message

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.

1. Say how people agree to hear from you

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.

2. Write a template

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.

3. Send it

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" }

4. Hear what became of it

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.

5. Answer when they write

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.

6. Send for real

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.

7. What it costs

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.

When WhatsApp does not arrive

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.

Node SDK

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

Emails

Sending messages and reading what happened to them.

POST /emails Send an email

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.

ParameterInDescription

Responses: 200, 201, 400, 401, 403, 404, 413, 422, 429, 503

GET /emails/{id} Retrieve an email

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

GET /emails/{id}/events List an email's timeline

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.

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

POST /emails/batch Send many emails in one call

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.

request body
{
  "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

POST /emails/{id}/cancel Cancel a queued email

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.

ParameterInDescription
id required path The message id returned when it was sent.

Responses: 200, 401, 404, 409

Templates

Versioned content a send can name instead of carrying its own subject and bodies.

GET /templates List templates

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}.

ParameterInDescription
archived query Exactly true includes archived templates. Any other value, or none, leaves them out.
response
{
  "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

POST /templates Create a template

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.

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

GET /templates/{ref} Retrieve a template

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.

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

POST /templates/{ref}/versions Save a new version

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.

request body
{
  "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

GET /templates/{ref}/versions/{version} Retrieve one version

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

POST /templates/{ref}/publish Publish a version

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.

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

POST /templates/{ref}/archive Archive or restore a template

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.

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

POST /templates/{ref}/preview Render a template without sending

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

GET /templates/library List the starter templates

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.

ParameterInDescription
category query Return only starters in this category.

Responses: 200

POST /templates/library/{slug}/adopt Copy a starter into your account

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.

ParameterInDescription
slug required path The starter’s slug, from the library listing.

Responses: 201, 404

Domains

Sending identities and their DNS verification.

GET /domains List domains

Every domain on the account, oldest first.

Responses: 200, 401, 403, 429

POST /domains Add a sending domain

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.

FieldTypeDescription
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.
request body
{
  "name": "acme.com"
}

Responses: 201, 400, 401, 403, 409, 422, 429

GET /domains/{id} Retrieve a domain

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

DELETE /domains/{id} Remove a domain

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

POST /domains/{id}/verify Check a domain's DNS now

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

Webhooks

Signed delivery of timeline events to your endpoint.

GET /webhooks List webhook endpoints

Every endpoint on the account, oldest first. Secrets are not included.

Responses: 200, 401, 403, 429

POST /webhooks Register a webhook endpoint

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

GET /webhooks/{id} Retrieve a webhook endpoint

Responses: 200, 401, 403, 404, 429

PATCH /webhooks/{id} Update a webhook endpoint

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

DELETE /webhooks/{id} Delete a webhook endpoint

Removes the endpoint and its pending deliveries.

Responses: 204, 401, 403, 404, 429

POST /webhooks/{id}/test Send a test event

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.

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

GET /webhooks/{id}/deliveries List recent deliveries

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

Suppressions

The per-account list of addresses that are never sent to.

GET /suppressions List suppressed addresses

The account's suppression list, newest first. Pass email to look one address up exactly, which returns zero or one entry and ignores limit.

ParameterInDescription
email 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.
response
{
  "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

POST /suppressions Suppress an address

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.

FieldTypeDescription
email required string The address to suppress. Stored lower-cased.
reason string
request body
{
  "email": "angry@example.com",
  "reason": "manual"
}

Responses: 201, 400, 401, 403, 422, 429

DELETE /suppressions/{email} Unsuppress an address

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

Account settings and sending reputation.

GET /account Retrieve the account

The account this API key belongs to, including the daily limit actually enforced and, if sending is paused, why.

Responses: 200, 401, 403, 429

PATCH /account Update account settings

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.

FieldTypeDescription
tracking required boolean Whether to record opens and clicks.
request body
{
  "tracking": true
}

Responses: 200, 400, 401, 403, 422, 429

GET /account/reputation Retrieve sending reputation

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

SMS

Text messages, sent synchronously and charged by segment.

POST /sms Send a text message

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.

ParameterInDescription
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

WhatsApp

Messages sent by template through a WhatsApp Business number.

POST /whatsapp Send a WhatsApp message

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.

ParameterInDescription
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

GET /whatsapp/templates List WhatsApp templates

Every template on the account, newest first, with where each stands.

Responses: 200, 401

POST /whatsapp/templates Write a WhatsApp template

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

GET /whatsapp/templates/{id} Retrieve a WhatsApp template

One template, with where it stands and what a send has to give it.

Responses: 200, 401, 404

PATCH /whatsapp/templates/{id} Change a WhatsApp template

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

DELETE /whatsapp/templates/{id} Delete a WhatsApp template

Deletes it here and at Meta, with its picture. Messages that name it afterwards are refused.

Responses: 204, 401, 404, 422

POST /whatsapp/templates/{id}/submit Send a WhatsApp template for review

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

POST /whatsapp/conversations/{number}/read Say a conversation has been read

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

Flows

Events from your application, and what they start.

POST /events Tell RelyKit something happened

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.

FieldTypeDescription
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

Service

Liveness.

GET /health Liveness check

Returns 200 as long as the HTTP server is up. It does not check the database, and it needs no API key.

response
{
  "ok": true
}

Responses: 200