Developers
Transactional Email API
Send receipts, sign-in codes, alerts and other one-to-one emails from your app with one HTTPS call. The API is built around safe retries, clear errors and privacy: BestEmail never stores your subjects or message bodies.
Quick start
- Sign in and open Dashboard → Developers.
- Add your sending domain and publish the SPF, DKIM and DMARC records we generate, then press Verify.
- Create a test key (
be_test_…) and build your integration. Nothing is sent with a test key. - Create a live key (
be_txn_…). New senders go live after a short review by the BestEmail team; if we need anything, we tell you why.
Authentication and keys
Send your key as Authorization: Bearer <key> (or X-API-Key: <key>). Keys are shown once when created; BestEmail stores only a SHA-256 hash. Keep keys on your server, never in a browser or mobile app.
| Key / scope | What it can do |
|---|---|
be_txn_… (live) | Sends real email and reads the message log (scope transactional). |
be_test_… (test) | Same API and responses, events and webhooks, but never hands mail to a mail server. |
messages:read | Read-only key for the message log. Cannot send. |
Rotate keys from the dashboard: choose Rotate (24 h overlap) to keep the old key working for 24 hours while you deploy, or Rotate now to stop it immediately. Revoked keys return 401.
Send an email
POST https://bestemail.in/api/v1/transactional/send
curl https://bestemail.in/api/v1/transactional/send \
-H "Authorization: Bearer $BESTEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-receipt" \
-d '{
"to": "customer@example.com",
"subject": "Your receipt for order #1042",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"reply_to": "support@yourshop.com",
"tag": "receipt"
}'| Field | Rules |
|---|---|
to | Required. One email address. |
subject | Required. Up to 255 characters, no line breaks. |
html / text | At least one. HTML up to 256 KB, text up to 128 KB, whole request up to 512 KB. |
reply_to | Optional. One email address. |
tag | Optional. 1–64 characters: letters, digits, _ . : -. Use it to search the log and route webhooks. |
idempotency_key | Optional (the Idempotency-Key header is preferred). 1–255 visible ASCII characters. |
Unknown fields are rejected, so typos never pass silently. Until your domain is verified and approved for its own From address, mail is sent from BestEmail's authenticated sender with your sender name as the display name and replies going to your reply-to address. Nothing is ever sent with a From that would fail DMARC.
Responses and errors
HTTP/1.1 200 OK
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 29
X-RateLimit-Reset: 41
X-RateLimit-Daily-Limit: 1000
X-RateLimit-Daily-Remaining: 999
{ "success": true, "id": "4f1c…", "status": "sent", "from": "noreply@yourshop.com",
"alignment": "tenant", "idempotent_replay": false }| Status / error | Meaning | Retry? |
|---|---|---|
200 sent | Accepted for delivery (test keys add "sandbox": true, "delivered": false). | No |
400 invalid_json | Body is not valid JSON. | No, fix the request |
401 unauthorized | Missing, wrong, expired or revoked key. | No |
403 forbidden / pending_review | Key lacks the scope, or the sender is still being reviewed. | No |
409 idempotency_conflict | The Idempotency-Key was used with different content. | No, use a new key |
409 in_progress | Same key is still being processed. | Yes, after Retry-After |
413 payload_too_large | Request over 512 KB. | No |
422 validation_error | A field is invalid; see "field" and "message". | No |
422 recipient_suppressed | Address previously bounced or was blocked. Nothing sent. | No |
429 rate_limited | Per-minute or per-day limit reached. | Yes, after Retry-After |
502 delivery_failed | The mail server refused or was unreachable; see "retryable". | Only if retryable is true |
500 internal_error | Unexpected error; nothing was sent. | Yes, with the same Idempotency-Key |
Rate limits
Each key has a per-minute and a per-day limit (default 30 per minute and 1,000 per day). Every send that reaches the limiter returns these headers, so you can slow down before hitting a 429:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current minute. |
X-RateLimit-Remaining | Requests left in the current minute. |
X-RateLimit-Reset | Seconds until the minute window resets. |
X-RateLimit-Daily-Limit | Requests allowed per UTC day. |
X-RateLimit-Daily-Remaining | Requests left today (UTC). |
Retry-After | On 429 and 409 in_progress: seconds to wait. |
Idempotent replays do not count against your limits. Need more volume? Ask support; increases are reviewed by a person.
Idempotency guide
Networks fail. If your request times out you cannot know whether the email went out. Send an Idempotency-Key and retry with the same key: BestEmail sends at most one email per key.
- Pick a key from your own data, such as
order-1042-receiptorotp-<login attempt id>. A random UUID works if you store it before the first try. - Same key, same content: you get the original result again with the header
Idempotent-Replayed: true. No second email. - Same key, different content:
409 idempotency_conflict. Use a new key for a new email. - Still processing:
409 in_progresswith Retry-After. Wait and retry with the same key. - Temporary failures release the key (
502with"retryable": true, and 5xx): retry with the same key and BestEmail tries again. Permanent failures keep the key, so a retry returns the same failure. - Keys are scoped to your domain. Retry with exponential backoff (2, 4, 8 seconds) and honour Retry-After.
Test mode
Test keys (be_test_…) run the whole pipeline: authentication, validation, idempotency, rate limits, suppression, the message log, events and webhooks. They never hand mail to a mail server, and they work before your sender is approved. Messages are marked "sandbox": true. Use these recipients to simulate outcomes:
| Recipient | Result |
|---|---|
anything@yourdomain.com | 200 sent (not delivered), event email.accepted |
bounce@simulator.bestemail.in | 502 delivery_failed smtp_550, event email.bounced |
deferred@simulator.bestemail.in | 502 delivery_failed smtp_421 (retryable), event email.deferred |
suppressed@simulator.bestemail.in | 422 recipient_suppressed, event email.suppressed |
Simulator addresses are refused by live keys, so a test can never become a real send.
Message log API
GET /api/v1/transactional/messages lists your messages, newest first. Filters: recipient (full address, exact match), tag, status (sent, failed, suppressed, sending), since, until (ISO dates), limit (1–100), cursor (from next_cursor). GET /api/v1/transactional/messages/<id> adds the event timeline. The same log is searchable in Dashboard → Developers → Message log.
curl "https://bestemail.in/api/v1/transactional/messages?tag=receipt&status=failed&limit=20" \ -H "Authorization: Bearer $BESTEMAIL_API_KEY"
Webhooks
Add an https endpoint in Dashboard → Developers → Webhooks and choose events. Each event is POSTed as JSON with a signature header. Reply with any 2xx within 10 seconds. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (6 attempts); every attempt appears in the delivery log, where you can also resend.
| Event | When |
|---|---|
email.accepted | The mail server accepted the message for delivery. |
email.deferred | Temporary rejection (4xx). Retry with the same Idempotency-Key. |
email.bounced | Permanent recipient rejection. The address is suppressed for your domain. |
email.suppressed | Not sent because the address is on a suppression list. |
email.failed | Not sent (mail server or network error). |
Coming later: email.delivered and email.complained (need mailbox-provider feedback), and opt-in email.opened / email.clicked.
Payload
{
"id": "6b0d1d0e-…", // event id: use it to ignore duplicates
"type": "email.bounced",
"created_at": "2026-10-14T14:05:11.000Z",
"data": {
"message_id": "4f1c…",
"tag": "receipt",
"status": "failed",
"to_masked": "cu***@example.com",
"to_sha256": "9f86d0…", // sha256(lowercase(trimmed address))
"to_domain": "example.com",
"error_code": "smtp_550",
"retryable": false,
"suppressed_future_sends": true
}
}Verify the signature
Header BestEmail-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of <t>.<raw body> with your endpoint's signing secret (whsec_…, shown once). Reject requests older than 5 minutes. Deliveries can repeat, so de-duplicate on the event id (also sent as BestEmail-Event-Id).
import { createHmac, timingSafeEqual } from 'node:crypto';
// Use the RAW request body (before JSON parsing). Express: app.post(path, express.raw({ type: 'application/json' }), ...)
export function verifyBestEmail(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(String(header).split(',').map((p) => p.trim().split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !parts.v1) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}<?php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_BESTEMAIL_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);
$t = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $raw, getenv('BESTEMAIL_WEBHOOK_SECRET'));
if (!$t || abs(time() - $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true); // e.g. $event['type'] === 'email.bounced'
http_response_code(204);Node.js and PHP
Node.js: send with safe retries
// Node.js 18+ (built-in fetch). npm packages are not needed.
import { randomUUID } from 'node:crypto';
export async function sendEmail({ to, subject, html, text, tag, idempotencyKey = randomUUID() }) {
for (let attempt = 1; attempt <= 4; attempt++) {
const res = await fetch('https://bestemail.in/api/v1/transactional/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BESTEMAIL_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // same key on every retry = never a duplicate email
},
body: JSON.stringify({ to, subject, html, text, tag }),
});
const body = await res.json();
if (res.ok) return body; // { id, status: 'sent', ... }
const retryable = res.status === 429 || res.status >= 500 || (res.status === 409 && body.error === 'in_progress');
if (!retryable || body.retryable === false) throw Object.assign(new Error(body.message), { body });
const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error('BestEmail: gave up after 4 attempts');
}PHP
<?php
// PHP 7.4+ with the curl extension. No Composer package needed.
function bestemail_send(array $mail, string $idempotencyKey): array {
$ch = curl_init('https://bestemail.in/api/v1/transactional/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('BESTEMAIL_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . $idempotencyKey,
],
CURLOPT_POSTFIELDS => json_encode($mail),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$body = json_decode((string) $raw, true) ?: [];
if ($status !== 200) {
throw new RuntimeException('BestEmail ' . $status . ': ' . ($body['message'] ?? 'request failed'));
}
return $body; // ['id' => ..., 'status' => 'sent']
}
bestemail_send([
'to' => 'customer@example.com',
'subject' => 'Your receipt for order #1042',
'text' => 'Thanks for your order.',
'tag' => 'receipt',
], 'order-1042-receipt');Privacy
BestEmail stores delivery metadata only: recipient address, tag, status, timestamps, sizes, error codes and a hash of the subject for idempotency checks. Subjects and bodies are never written to our database or logs. The dashboard and webhooks show recipients masked, with a SHA-256 you can match against your own records.
Questions? Contact the BestEmail team.