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.

send.tsTypeScript
const res = 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]'],
    subject: 'Order #1042 is confirmed',
    html: '<p>Thanks, Asha. Your order ships tomorrow.</p>',
  }),
});

const { id } = await res.json(); // 200 OK → { "id": "…" }
send.pyPython
import os
import requests

res = requests.post(
    "https://api.refiremail.com/emails",
    headers={"Authorization": f"Bearer {os.environ['REFIREMAIL_API_KEY']}"},
    json={
        "from": "Nimbu <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Order #1042 is confirmed",
        "html": "<p>Thanks, Asha. Your order ships tomorrow.</p>",
    },
    timeout=10,
)
res.raise_for_status()
email_id = res.json()["id"]
send.phpPHP
<?php
$ch = curl_init('https://api.refiremail.com/emails');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('REFIREMAIL_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'from' => 'Nimbu <[email protected]>',
        'to' => ['[email protected]'],
        'subject' => 'Order #1042 is confirmed',
        'html' => '<p>Thanks, Asha. Your order ships tomorrow.</p>',
    ]),
]);

$email = json_decode(curl_exec($ch), true); // ['id' => '…']
send.shShell
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>"
  }'
200 OKJSON
{
  "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.
batch.tsTypeScript
const res = await fetch('https://api.refiremail.com/emails/batch', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.REFIREMAIL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify([
    {
      from: 'Nimbu <[email protected]>',
      to: ['[email protected]'],
      subject: 'Order #1042 has shipped',
      html: '<p>Your parcel is on its way.</p>',
    },
    // …up to 100 emails in one call
  ]),
});

const { data } = await res.json(); // [{ "id": "…" }, …] in the order you sent them
batch.shShell
curl -X POST https://api.refiremail.com/emails/batch \
  -H "Authorization: Bearer $REFIREMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "x-batch-validation: permissive" \
  -d '[
    {
      "from": "Nimbu <[email protected]>",
      "to": ["[email protected]"],
      "subject": "Order #1042 has shipped",
      "html": "<p>Your parcel is on its way.</p>"
    },
    {
      "from": "Nimbu <[email protected]>",
      "to": ["not-an-address"],
      "subject": "Order #1043 has shipped",
      "html": "<p>Your parcel is on its way.</p>"
    }
  ]'
200 OK · permissiveJSON
{
  "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 get 409 concurrent_idempotent_requests.
  • Sending over SMTP? Put the key in a Refire-Idempotency-Key header.
Idempotency over SMTP
send.shShell
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>"
  }'
  1. First request

    10:02:07 IST

    POST /emails · Idempotency-Key: order-1042

    200{ "id": "01a0c762-aaf4-…" }

    Accepted and queued. Asha gets one email.

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

  3. 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.
schedule.tsTypeScript
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.
send-template.tsTypeScript
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.

422 Unprocessable EntityJSON
{
  "statusCode": 422,
  "name": "missing_required_field",
  "message": "Missing `to` field."
}
NameStatusWhen you see it
validation_error422A field is wrong: an address that isn’t one, a limit out of range, both after and before.
missing_required_field422A required field is missing, such as to or subject.
validation_error403The From domain isn’t verified for your team, or the key is tied to another domain.
missing_api_key401The request has no Authorization header.
invalid_api_key403The key is malformed, unknown, revoked or expired. All four look the same on purpose.
restricted_api_key401A sending-only key called an endpoint other than the two send routes.
invalid_idempotent_request409The Idempotency-Key was used in the last 24 hours with a different body.
concurrent_idempotent_requests409Another request with the same key is still being processed.
rate_limit_exceeded429Too many requests this second. Wait for retry-after, then try again.
daily_quota_exceeded429The 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.
Webhook events
LogsSample data
GET /logsJSON
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "id": "01a0c762-aaf5-7c10-8b3e-2f9d61c4a7e0",
      "created_at": "2026-09-22T04:32:07.412Z",
      "endpoint": "/emails",
      "method": "POST",
      "response_status": 200,
      "user_agent": "node"
    },
    {
      "id": "01a0c762-4056-75d9-ad18-18e811892f90",
      "created_at": "2026-09-22T04:31:40.118Z",
      "endpoint": "/emails/batch",
      "method": "POST",
      "response_status": 200,
      "user_agent": "curl/8.5.0"
    },
    {
      "id": "01a0c761-0bac-7930-9d14-f4733f3e7d1b",
      "created_at": "2026-09-22T04:30:21.100Z",
      "endpoint": "/emails",
      "method": "POST",
      "response_status": 422,
      "user_agent": "python-requests/2.32.3"
    },
    {
      "id": "01a0c760-c336-76f0-9316-00a35a099950",
      "created_at": "2026-09-22T04:30:02.550Z",
      "endpoint": "/domains",
      "method": "GET",
      "response_status": 200,
      "user_agent": "node"
    }
  ]
}
The API log in the dashboard. Switch to API to see the GET /logs response behind it.

07, Limits

Limits, stated plainly.

The numbers your code has to respect, in one place.

429 Too Many RequestsHTTP
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.

LimitValue
Recipients per email50 in each of to, cc and bcc
Emails per batch call100, without attachments or scheduled_at
Attachments40 MB per email, after decoding
Scheduling horizon30 days ahead
Idempotency keys1–256 characters, kept for 24 hours
Rate limit10 requests a second per team, unless your plan sets another; a batch counts once
Page sizelimit 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.