Send · Email API
An email API your code already knows.
Plain REST and JSON, with the routes, fields and error names developers expect. Send one email or a hundred, schedule them, make retries safe, and read back every request.
{
"id": "01a0c762-aaf4-7269-9ef2-a74de452e6b4"
}01, Batch
Send one, or a hundred.
POST /emails sends one message to up to 50 addresses in each of to, cc and bcc. POST /emails/batch takes up to 100 messages in one call and returns an id for each.
You decide what happens when one item in a batch is wrong.
- Strict, the default: one invalid item fails the whole call with a 422, and nothing is sent.
- Permissive, with
x-batch-validation: permissive: the valid items are sent, and the response lists the index and reason of each item it refused. - A batch counts once against your rate limit. It can’t carry attachments or a scheduled time, so send those one at a time.
{
"data": [
{ "id": "01a0c762-40a1-7d2c-9b5e-6a0f3e1c8d27" }
],
"errors": [
{ "index": 1, "message": "Invalid `to` field." }
]
}02, Idempotency
Retries that can’t double-send.
Networks lose responses as well as requests. Send an Idempotency-Key header and retry as often as you need: for 24 hours, the same key with the same body returns the first response and sends nothing new.
- Keys are 1 to 256 characters, scoped to your team and to the endpoint. An order number or a job id makes a good key.
- Only successful responses are stored, so a request that failed validation can be fixed and sent again with the same key.
- The same key with a different body is refused with
409 invalid_idempotent_request. Two requests racing with one key get409 concurrent_idempotent_requests. - Sending over SMTP? Put the key in a
Refire-Idempotency-Keyheader.
curl -X POST https://api.refiremail.com/emails \
-H "Authorization: Bearer $REFIREMAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"from": "Nimbu <[email protected]>",
"to": ["[email protected]"],
"subject": "Order #1042 is confirmed",
"html": "<p>Thanks, Asha. Your order ships tomorrow.</p>"
}'First request
10:02:07 IST
POST /emails · Idempotency-Key: order-1042
200{ "id": "01a0c762-aaf4-…" }Accepted and queued. Asha gets one email.
Retry after a timeout: same key, same body
10:02:09 IST
POST /emails · Idempotency-Key: order-1042
200{ "id": "01a0c762-aaf4-…" }The stored response comes back with the same id. Nothing is sent again.
Same key, different body
10:02:31 IST
POST /emails · Idempotency-Key: order-1042
409{ "name": "invalid_idempotent_request" }Refused, so a bug can’t reuse a key for a different email.
03, Scheduling
Schedule it, or change your mind.
Add scheduled_at with an ISO 8601 time or a phrase like “in 1 hour”, up to 30 days ahead. Until the email leaves, move it with a PATCH or cancel it.
- PATCH/emails/{id}, Live
- POST/emails/{id}/cancel, Live
- Phrases are read in UTC, so “tomorrow at 9am” means 2:30 pm in India. For a wall-clock time here, send an ISO time with
+05:30. - Accepting a scheduled email fires
email.scheduled; the usual delivery events follow once it goes out. - Batches and SMTP submissions can’t be scheduled. Use a single API send for anything that should wait.
const api = (path: string, method: string, body?: object) =>
fetch(`https://api.refiremail.com${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.REFIREMAIL_API_KEY}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
}).then((r) => r.json());
const { id } = await api('/emails', 'POST', {
from: 'Nimbu <[email protected]>',
to: ['[email protected]'],
subject: 'Your trial ends tomorrow',
html: '<p>Keep your reading list: pick a plan today.</p>',
scheduled_at: 'in 1 hour',
});
// Move it. An ISO time with +05:30 is 9 am in India.
await api(`/emails/${id}`, 'PATCH', { scheduled_at: '2026-09-25T09:00:00+05:30' });
// Or call it off before it leaves.
await api(`/emails/${id}/cancel`, 'POST');04, Templates
Keep the HTML out of your code.
Store a template once, publish it, and send it by id or alias with the variables it declares. Your code sends data; your team edits the words.
- /templates, Live
- Sends use the latest published version, never a draft, so an edit in progress can’t reach a customer.
- Variables are checked before anything is queued. A missing variable with no fallback is a 422, not an email with a blank where a name should be.
- The template can supply the subject and sender. Anything you pass in the call takes precedence.
- Rendering email from components or a template engine in your own code? Send the HTML it produces in
html; nothing about it changes.
await fetch('https://api.refiremail.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.REFIREMAIL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Nimbu <[email protected]>',
to: ['[email protected]'],
template: {
id: 'order-confirmation', // the template's id or its alias
variables: { first_name: 'Asha', order_id: '1042' },
},
}),
});05, Errors
Errors with names.
Every error is JSON with the HTTP status, a machine-readable name and a sentence for people. Switch on the name in your handler; the name stays stable when the wording changes.
Switch on name, not on the wording: names stay the same when a message is reworded, so your error handling keeps working.
{
"statusCode": 422,
"name": "missing_required_field",
"message": "Missing `to` field."
}| Name | Status | When you see it |
|---|---|---|
| validation_error | 422 | A field is wrong: an address that isn’t one, a limit out of range, both after and before. |
| missing_required_field | 422 | A required field is missing, such as to or subject. |
| validation_error | 403 | The From domain isn’t verified for your team, or the key is tied to another domain. |
| missing_api_key | 401 | The request has no Authorization header. |
| invalid_api_key | 403 | The key is malformed, unknown, revoked or expired. All four look the same on purpose. |
| restricted_api_key | 401 | A sending-only key called an endpoint other than the two send routes. |
| invalid_idempotent_request | 409 | The Idempotency-Key was used in the last 24 hours with a different body. |
| concurrent_idempotent_requests | 409 | Another request with the same key is still being processed. |
| rate_limit_exceeded | 429 | Too many requests this second. Wait for retry-after, then try again. |
| daily_quota_exceeded | 429 | The day’s sending allowance is used up, on plans with a daily cap. |
06, Logs
Logs you can query.
GET /logs lists your team’s authenticated API requests: the endpoint, method, status, user agent and time. Open one to see the request and response bodies.
- Stored bodies never include the Authorization header or attachment content.
- When a customer says an email never came, start from the request, then follow its id to the email and its events.
- Each email’s own history (queued, sent, delivered, opened, bounced) comes from
GET /emails/{id}and your webhooks.
07, Limits
Limits, stated plainly.
The numbers your code has to respect, in one place.
HTTP/1.1 429 Too Many Requests
content-type: application/json
ratelimit-limit: 10
ratelimit-remaining: 0
ratelimit-reset: 1
ratelimit-policy: 10;w=1
retry-after: 1
{
"statusCode": 429,
"name": "rate_limit_exceeded",
"message": "Too many requests. See retry-after."
}Every response carries ratelimit-limit, ratelimit-remaining and ratelimit-reset. A 429 adds retry-after: the number of seconds to wait before trying again.
| Limit | Value |
|---|---|
| Recipients per email | 50 in each of to, cc and bcc |
| Emails per batch call | 100, without attachments or scheduled_at |
| Attachments | 40 MB per email, after decoding |
| Scheduling horizon | 30 days ahead |
| Idempotency keys | 1–256 characters, kept for 24 hours |
| Rate limit | 10 requests a second per team, unless your plan sets another; a batch counts once |
| Page size | limit 1–100, default 20, with after or before cursors |
Send your first email.
Request early access. Once your account is on, create a key, verify your domain and send.