Documentation

Send your first email, then read the reference.

The Irisend API is a REST API over HTTPS with JSON request and response bodies. All requests are made against https://api.irisend.com.

Quickstart

Sign up, create an API key, verify a sending domain, then send your first email. Every step below is a real request against the live API.

1. Create an account and an API key

Sign up, then open Configuration → API keys in the dashboard to create your first key — or mint one from the API using a key you already have.

RequestcURL
curl -X POST https://api.irisend.com/api-keys \
  -H "Authorization: Bearer ir_<your-first-key>" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Local dev" }'
ResponseJSON
{ "id": "3f9c2b1a-...", "token": "ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ" }

token is only ever returned here — store it now, it can't be retrieved again.

2. Add and verify a sending domain

Creating a domain returns the DKIM, SPF, and (optional) DMARC DNS records to publish. Add them at your DNS provider, then call verify once they've propagated — DNS changes can take a few minutes to a few hours.

RequestcURL
curl -X POST https://api.irisend.com/domains \
  -H "Authorization: Bearer ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ" \
  -H "Content-Type: application/json" \
  -d '{ "name": "acme.dev" }'
ResponseJSON
{
  "object": "domain",
  "id": "dom_4kP1qR9zL7vT2yB6wC0nS3x",
  "name": "acme.dev",
  "status": "not_started",
  "created_at": "2026-09-25T09:00:12.481Z",
  "region": "eu-west-1",
  "open_tracking": false,
  "click_tracking": false,
  "tls": "opportunistic",
  "records": [
    { "record": "DKIM", "type": "TXT", "name": "irisend._domainkey", "value": "p=MIGfMA0GCSq...", "ttl": "Auto", "status": "not_started", "required": true },
    { "record": "SPF", "type": "MX", "name": "send", "value": "feedback-smtp.eu-west-1.amazonses.com", "priority": 10, "ttl": "Auto", "status": "not_started", "required": true },
    { "record": "SPF", "type": "TXT", "name": "send", "value": "v=spf1 include:amazonses.com ~all", "ttl": "Auto", "status": "not_started", "required": true },
    { "record": "DMARC", "type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=none;", "ttl": "Auto", "status": "not_started", "required": false }
  ]
}
RequestcURL
curl -X POST https://api.irisend.com/domains/dom_4kP1qR9zL7vT2yB6wC0nS3x/verify \
  -H "Authorization: Bearer ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ"

You can also just poll GET /domains/:id — SES resolves records on its own schedule, and calling verify re-checks them immediately.

3. Send your first email

RequestcURL
curl -X POST https://api.irisend.com/emails \
  -H "Authorization: Bearer ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.dev>",
    "to": "you@example.com",
    "subject": "Hello from Irisend",
    "html": "<strong>It just works.</strong>"
  }'
ResponseJSON
{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }

4. Check its status any time

RequestcURL
curl https://api.irisend.com/emails/em_9f2K3mQ1zPr7vT6yB4wL0nC \
  -H "Authorization: Bearer ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ"

Prefer a typed client? A Node SDK is in active development. This page will grow SDK-specific examples once it ships — until then, the REST calls above work from any language.

Authentication

Authenticate every request with a Bearer token in the Authorization header. Keys look like ir_<8-character prefix>_<24-character secret> — the prefix is used to look the key up, the secret is only ever stored hashed.

  • full_access keys can call every endpoint; sending_access keys can only call POST /emails and POST /emails/batch.
  • A key scoped to a domain can only send with a matching `from` address.
  • The token is shown once, at creation. Store it in a secret manager, not a repo.

Idempotency

Send an Idempotency-Key header (1–256 characters) on POST /emails or POST /emails/batch to make retries safe.

  • Reusing a key returns the original email untouched — the request is not re-validated or diffed against the first attempt, so a retry with a genuinely different body still short-circuits to the first result.
  • Keys are scoped per team and currently never expire. Generate a fresh key per logical send (e.g. per user action), not a constant you reuse across different emails.
  • For POST /emails/batch, the header is combined with each item's position, so retrying the whole batch is idempotent per item.
  • Stricter conflict detection (rejecting a reused key sent with a different body) and key expiry are in progress — this section will update when that ships.

Errors

Errors return a JSON body shaped { statusCode, name, message }, so you can branch on `name` instead of parsing text.

namestatusmeaning
missing_required_field422A required field is missing from the request body.
invalid_from_address422The `from` field doesn't parse as `email@example.com` or `Name <email@example.com>`.
validation_error400 / 403 / 422The body failed schema validation (422), the JSON itself was malformed (400), or a business rule was violated — e.g. sending from an unverified domain, or re-registering a domain name (403).
invalid_idempotency_key400The `Idempotency-Key` header is not 1–256 characters.
missing_api_key401No `Authorization` header was sent.
restricted_api_key401 / 403A sending-only key called a management endpoint (401), or a domain-scoped key tried to send from a non-matching domain (403).
invalid_api_key403The API key is malformed or does not exist.
not_found404The endpoint, or a resource ID owned by a different team, does not exist.
rate_limit_exceeded429Your team exceeded its requests-per-second limit.
internal_server_error500Something went wrong on our end. Safe to retry.

Rate limits

Requests are limited per team, in a fixed one-second window shared across every API key on the team. Every response carries the current limit state.

HeaderMeaning
ratelimit-limitRequests allowed per second for this team
ratelimit-remainingRequests left in the current one-second window
ratelimit-resetSeconds until the window resets
retry-afterSent only on a 429; seconds to wait before retrying

The default limit is 10 requests per second per team. A POST /emails/batch call counts as a single request regardless of how many emails it contains.

Emails

POST/emailsSend an email

Provide at least one of html or text. to, cc, bcc, and reply_to accept a string or an array of up to 50 addresses. scheduled_at accepts an ISO 8601 timestamp. Pass an Idempotency-Key header to make retries safe — see Idempotency.

Request bodyJSON
{
  "from": "Acme <hello@acme.dev>",
  "to": ["you@example.com", "cc-fallback@example.com"],
  "subject": "Your receipt from Acme",
  "html": "<p>Thanks for your order.</p>",
  "text": "Thanks for your order.",
  "reply_to": "support@acme.dev",
  "headers": { "X-Entity-Ref-ID": "order_8841" },
  "tags": [{ "name": "category", "value": "receipt" }],
  "scheduled_at": "2026-10-01T09:00:00Z"
}
ResponseJSON
{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }
POST/emails/batchSend up to 100 emails

Body is a JSON array of up to 100 email objects, each shaped like POST /emails. IDs are returned in the same order as the input.

Request bodyJSON
[
  { "from": "Acme <hello@acme.dev>", "to": "a@example.com", "subject": "Hi A", "html": "<p>Hi A</p>" },
  { "from": "Acme <hello@acme.dev>", "to": "b@example.com", "subject": "Hi B", "html": "<p>Hi B</p>" }
]
ResponseJSON
{
  "data": [
    { "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" },
    { "id": "em_2wR8vLp1kNs5tG3xC9hQ7mB" }
  ]
}
GET/emails/:idRetrieve an email
ResponseJSON
{
  "object": "email",
  "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC",
  "to": ["you@example.com"],
  "from": "Acme <hello@acme.dev>",
  "created_at": "2026-09-25T09:00:12.481Z",
  "subject": "Your receipt from Acme",
  "html": "<p>Thanks for your order.</p>",
  "text": "Thanks for your order.",
  "bcc": null,
  "cc": null,
  "reply_to": ["support@acme.dev"],
  "last_event": "delivered",
  "scheduled_at": null,
  "tags": [{ "name": "category", "value": "receipt" }]
}
PATCH/emails/:idReschedule an email

Only scheduled_at can change, and only while the email is still scheduled.

Request bodyJSON
{ "scheduled_at": "2026-10-01T12:00:00Z" }
ResponseJSON
{ "object": "email", "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }
POST/emails/:id/cancelCancel a scheduled email
ResponseJSON
{ "object": "email", "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }

Domains

POST/domainsAdd a sending domain

Returns the DNS records you need to add — a DKIM TXT record, an SPF MX and TXT pair on the sending subdomain, and an optional DMARC record. There is no webhook for domain status changes yet: poll GET /domains/:id or call POST /domains/:id/verify to re-check records on demand and see them move from not_started to verified.

Request bodyJSON
{ "name": "acme.dev", "region": "eu-west-1" }
ResponseJSON
{
  "object": "domain",
  "id": "dom_4kP1qR9zL7vT2yB6wC0nS3x",
  "name": "acme.dev",
  "status": "not_started",
  "created_at": "2026-09-25T09:00:12.481Z",
  "region": "eu-west-1",
  "open_tracking": false,
  "click_tracking": false,
  "tls": "opportunistic",
  "records": [
    { "record": "DKIM", "type": "TXT", "name": "irisend._domainkey", "value": "p=MIGfMA0GCSq...", "ttl": "Auto", "status": "not_started", "required": true },
    { "record": "SPF", "type": "MX", "name": "send", "value": "feedback-smtp.eu-west-1.amazonses.com", "priority": 10, "ttl": "Auto", "status": "not_started", "required": true },
    { "record": "SPF", "type": "TXT", "name": "send", "value": "v=spf1 include:amazonses.com ~all", "ttl": "Auto", "status": "not_started", "required": true },
    { "record": "DMARC", "type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=none;", "ttl": "Auto", "status": "not_started", "required": false }
  ]
}
GET/domains/:idRetrieve a domain

Same shape as create, with each record's current verification status. GET /domains lists every domain on the team, but omits records — fetch a single domain to see the DNS records.

PATCH/domains/:idUpdate tracking settings

Accepts open_tracking, click_tracking, and tls (opportunistic or enforced). Returns the { object, id } mutation shape.

DELETE/domains/:idRemove a domain

Returns { object, id, deleted: true }.

API keys

POST/api-keysCreate an API key

permission is full_access (default) or sending_access. Pass domain_id to scope a sending key to one domain. token is only ever returned here — the response doesn't echo back name, permission, or domain_id.

Request bodyJSON
{ "name": "Production", "permission": "full_access" }
ResponseJSON
{ "id": "3f9c2b1a-6e2d-4a3f-9c0b-1a2b3c4d5e6f", "token": "ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ" }
GET/api-keysList API keys

Never includes the token. Each item is { id, name, created_at, last_used_at } — permission and domain_id aren't returned here either; check the dashboard if you need to see a key's scope.

ResponseJSON
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "3f9c2b1a-6e2d-4a3f-9c0b-1a2b3c4d5e6f", "name": "Production", "created_at": "2026-09-25T09:00:12.481Z", "last_used_at": "2026-09-25T10:03:41.220Z" }
  ]
}
DELETE/api-keys/:idRevoke an API key

Returns a 200 with an empty body — there is no { object, id, deleted } here. Requests already in flight with this key may still complete.

Webhooks

POST/webhooksCreate a webhook endpoint

Choose from 11 event types:

email.sentemail.deliveredemail.delivery_delayedemail.bouncedemail.complainedemail.openedemail.clickedemail.failedemail.scheduledemail.suppressedemail.canceled
Request bodyJSON
{
  "endpoint": "https://acme.dev/webhooks/irisend",
  "events": ["email.delivered", "email.bounced", "email.complained"]
}
ResponseJSON
{
  "object": "webhook",
  "id": "wh_3xC7qR1vN9sL4yB2wT8hM0z",
  "signing_secret": "whsec_MfKzB3n2Rp9vQwL7xC1sH5tY"
}

The create response only carries signing_secret — fetch GET /webhooks/:id to read back endpoint, events, and status together with the secret.

GET/webhooksList webhook endpoints

Omits signing_secret.

ResponseJSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "webhook",
      "id": "wh_3xC7qR1vN9sL4yB2wT8hM0z",
      "endpoint": "https://acme.dev/webhooks/irisend",
      "events": ["email.delivered", "email.bounced", "email.complained"],
      "status": "enabled",
      "created_at": "2026-09-25T09:00:12.481Z"
    }
  ]
}
GET/webhooks/:idRetrieve a webhook endpoint

Includes signing_secret.

ResponseJSON
{
  "object": "webhook",
  "id": "wh_3xC7qR1vN9sL4yB2wT8hM0z",
  "endpoint": "https://acme.dev/webhooks/irisend",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "status": "enabled",
  "created_at": "2026-09-25T09:00:12.481Z",
  "signing_secret": "whsec_MfKzB3n2Rp9vQwL7xC1sH5tY"
}
DELETE/webhooks/:idRemove a webhook endpoint
ResponseJSON
{ "object": "webhook", "id": "wh_3xC7qR1vN9sL4yB2wT8hM0z", "deleted": true }

Payload shape

Every delivery is a JSON envelope of { type, created_at, data }.

email.deliveredJSON
{
  "type": "email.delivered",
  "created_at": "2026-09-25T09:01:04.112Z",
  "data": {
    "email_id": "em_9f2K3mQ1zPr7vT6yB4wL0nC",
    "from": "Acme <hello@acme.dev>",
    "to": ["you@example.com"],
    "subject": "Your receipt from Acme",
    "created_at": "2026-09-25T09:00:12.481Z",
    "tags": { "category": "receipt" }
  }
}

Verifying signatures

Deliveries are signed the Standard Webhooks way, byte-compatible with Svix — the svix and standardwebhooks verifier libraries work unchanged against webhook-id, webhook-timestamp, and webhook-signature (mirrored as svix-id, svix-timestamp, svix-signature). Signatures are valid for 300 seconds from the timestamp.

verify-webhook.tsTypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyIrisendWebhook(
  secret: string,
  headers: Record<string, string | undefined>,
  body: string,
): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${body}`)
    .digest("base64");

  // headers may carry several space-separated "v1,<sig>" values during rotation
  return signature.split(" ").some((part) => {
    const candidate = Buffer.from(part.replace(/^v1,/, ""));
    const target = Buffer.from(expected);
    return candidate.length === target.length && timingSafeEqual(candidate, target);
  });
}

Suppression & bounces

Bounces and complaints are handled for you, automatically, before your reputation takes the hit.

  • A hard bounce or a spam complaint adds that recipient to your team's suppression list. Before every send, Irisend drops any suppressed address from to, cc, and bcc — if every recipient on an email is suppressed, it's marked email.suppressed and never reaches the provider.
  • Manage the list by hand from the dashboard's Suppressions page — add an address before you ever send to it, or remove one that bounced by mistake.
  • Stay under the thresholds mailbox providers actually enforce: hard bounce rate under 5% (throttling risk above 10%), spam complaint rate under 0.1% (throttling/suspension risk at or above 0.3% — this is Gmail and Yahoo's bulk-sender bar, not just ours). Full policy in the Acceptable Use Policy.
  • One-click unsubscribe (List-Unsubscribe, RFC 8058) is in progress and not sent automatically yet — don't rely on it for bulk sends to Gmail or Yahoo addresses until it ships.