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

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

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

Flows

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