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.
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" }'{ "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.
curl -X POST https://api.irisend.com/domains \
-H "Authorization: Bearer ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ" \
-H "Content-Type: application/json" \
-d '{ "name": "acme.dev" }'{
"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 }
]
}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
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>"
}'{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }4. Check its status any time
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_accesskeys can call every endpoint;sending_accesskeys can only callPOST /emailsandPOST /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.
| name | status | meaning |
|---|---|---|
| missing_required_field | 422 | A required field is missing from the request body. |
| invalid_from_address | 422 | The `from` field doesn't parse as `email@example.com` or `Name <email@example.com>`. |
| validation_error | 400 / 403 / 422 | The 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_key | 400 | The `Idempotency-Key` header is not 1–256 characters. |
| missing_api_key | 401 | No `Authorization` header was sent. |
| restricted_api_key | 401 / 403 | A sending-only key called a management endpoint (401), or a domain-scoped key tried to send from a non-matching domain (403). |
| invalid_api_key | 403 | The API key is malformed or does not exist. |
| not_found | 404 | The endpoint, or a resource ID owned by a different team, does not exist. |
| rate_limit_exceeded | 429 | Your team exceeded its requests-per-second limit. |
| internal_server_error | 500 | Something 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.
| Header | Meaning |
|---|---|
| ratelimit-limit | Requests allowed per second for this team |
| ratelimit-remaining | Requests left in the current one-second window |
| ratelimit-reset | Seconds until the window resets |
| retry-after | Sent 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
/emailsSend an emailProvide 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.
{
"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"
}{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }/emails/batchSend up to 100 emailsBody 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.
[
{ "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>" }
]{
"data": [
{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" },
{ "id": "em_2wR8vLp1kNs5tG3xC9hQ7mB" }
]
}/emails/:idRetrieve an email{
"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" }]
}/emails/:idReschedule an emailOnly scheduled_at can change, and only while the email is still scheduled.
{ "scheduled_at": "2026-10-01T12:00:00Z" }{ "object": "email", "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }/emails/:id/cancelCancel a scheduled email{ "object": "email", "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }Domains
/domainsAdd a sending domainReturns 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.
{ "name": "acme.dev", "region": "eu-west-1" }{
"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 }
]
}/domains/:idRetrieve a domainSame 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.
/domains/:idUpdate tracking settingsAccepts open_tracking, click_tracking, and tls (opportunistic or enforced). Returns the { object, id } mutation shape.
/domains/:idRemove a domainReturns { object, id, deleted: true }.
API keys
/api-keysCreate an API keypermission 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.
{ "name": "Production", "permission": "full_access" }{ "id": "3f9c2b1a-6e2d-4a3f-9c0b-1a2b3c4d5e6f", "token": "ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ" }/api-keysList API keysNever 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.
{
"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" }
]
}/api-keys/:idRevoke an API keyReturns a 200 with an empty body — there is no { object, id, deleted } here. Requests already in flight with this key may still complete.
Webhooks
/webhooksCreate a webhook endpointChoose from 11 event types:
email.sentemail.deliveredemail.delivery_delayedemail.bouncedemail.complainedemail.openedemail.clickedemail.failedemail.scheduledemail.suppressedemail.canceled{
"endpoint": "https://acme.dev/webhooks/irisend",
"events": ["email.delivered", "email.bounced", "email.complained"]
}{
"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.
/webhooksList webhook endpointsOmits signing_secret.
{
"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"
}
]
}/webhooks/:idRetrieve a webhook endpointIncludes signing_secret.
{
"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"
}/webhooks/:idRemove a webhook endpoint{ "object": "webhook", "id": "wh_3xC7qR1vN9sL4yB2wT8hM0z", "deleted": true }Payload shape
Every delivery is a JSON envelope of { type, created_at, data }.
{
"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.
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, andbcc— if every recipient on an email is suppressed, it's markedemail.suppressedand 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.