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://irisend-api.lucamanuel.dev.
Quickstart
Create an account, grab an API key from Settings → API Keys, and send your first email.
1. Send a request
curl -X POST https://irisend-api.lucamanuel.dev/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>"
}'2. Get an email ID back
{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }3. Check its status any time
curl https://irisend-api.lucamanuel.dev/emails/em_9f2K3mQ1zPr7vT6yB4wL0nC \
-H "Authorization: Bearer ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ"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.
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_parameter | 422 | A field failed validation, e.g. an ID that is not a valid UUID. |
| invalid_from_address | 422 | The `from` address is not on a verified domain. |
| invalid_region | 422 | The domain `region` is not one of the supported SES regions. |
| validation_error | 400 | The request body does not match the expected schema. |
| 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 | A sending-only key called an endpoint it is not scoped for. |
| invalid_api_key | 403 | The API key is malformed or does not exist. |
| invalid_access | 403 | The key is not permitted to act on this resource. |
| not_found | 404 | The endpoint or resource ID does not exist. |
| method_not_allowed | 405 | The HTTP method is not supported on this endpoint. |
| concurrent_idempotent_requests | 409 | Another request with the same idempotency key is still in flight. |
| invalid_idempotent_request | 409 | The idempotency key was reused with a different request body. |
| 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.
{
"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. Poll GET /domains/:id or wait for the domain.updated webhook to see records 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...", "status": "not_started", "required": true },
{ "record": "SPF", "type": "MX", "name": "send", "value": "feedback-smtp.eu-west-1.amazonses.com", "priority": 10, "status": "not_started", "required": true },
{ "record": "SPF", "type": "TXT", "name": "send", "value": "v=spf1 include:amazonses.com ~all", "status": "not_started", "required": true },
{ "record": "DMARC", "type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=none;", "status": "not_started", "required": false }
]
}/domains/:idRetrieve a domainSame shape as create, with each record's current verification status.
/domains/:idUpdate tracking settingsAccepts open_tracking, click_tracking, and tls (opportunistic or enforced).
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. The token is only ever returned on creation.
{ "name": "Production", "permission": "full_access" }{
"id": "key_7mN2xQ4vP9sL1yB8wC6hT3r",
"object": "api_key",
"name": "Production",
"token": "ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ"
}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",
"endpoint": "https://acme.dev/webhooks/irisend",
"events": ["email.delivered", "email.bounced", "email.complained"],
"status": "enabled",
"signing_secret": "whsec_MfKzB3n2Rp9vQwL7xC1sH5tY"
}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);
});
}