Skip to content
← Back to documentation

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

  1. Sign in and open Dashboard → Developers.
  2. Add your sending domain and publish the SPF, DKIM and DMARC records we generate, then press Verify.
  3. Create a test key (be_test_…) and build your integration. Nothing is sent with a test key.
  4. 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 / scopeWhat 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:readRead-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"
  }'
FieldRules
toRequired. One email address.
subjectRequired. Up to 255 characters, no line breaks.
html / textAt least one. HTML up to 256 KB, text up to 128 KB, whole request up to 512 KB.
reply_toOptional. One email address.
tagOptional. 1–64 characters: letters, digits, _ . : -. Use it to search the log and route webhooks.
idempotency_keyOptional (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 / errorMeaningRetry?
200 sentAccepted for delivery (test keys add "sandbox": true, "delivered": false).No
400 invalid_jsonBody is not valid JSON.No, fix the request
401 unauthorizedMissing, wrong, expired or revoked key.No
403 forbidden / pending_reviewKey lacks the scope, or the sender is still being reviewed.No
409 idempotency_conflictThe Idempotency-Key was used with different content.No, use a new key
409 in_progressSame key is still being processed.Yes, after Retry-After
413 payload_too_largeRequest over 512 KB.No
422 validation_errorA field is invalid; see "field" and "message".No
422 recipient_suppressedAddress previously bounced or was blocked. Nothing sent.No
429 rate_limitedPer-minute or per-day limit reached.Yes, after Retry-After
502 delivery_failedThe mail server refused or was unreachable; see "retryable".Only if retryable is true
500 internal_errorUnexpected 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:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the current minute.
X-RateLimit-RemainingRequests left in the current minute.
X-RateLimit-ResetSeconds until the minute window resets.
X-RateLimit-Daily-LimitRequests allowed per UTC day.
X-RateLimit-Daily-RemainingRequests left today (UTC).
Retry-AfterOn 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-receipt or otp-<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_progress with Retry-After. Wait and retry with the same key.
  • Temporary failures release the key (502 with "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:

RecipientResult
anything@yourdomain.com200 sent (not delivered), event email.accepted
bounce@simulator.bestemail.in502 delivery_failed smtp_550, event email.bounced
deferred@simulator.bestemail.in502 delivery_failed smtp_421 (retryable), event email.deferred
suppressed@simulator.bestemail.in422 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.

EventWhen
email.acceptedThe mail server accepted the message for delivery.
email.deferredTemporary rejection (4xx). Retry with the same Idempotency-Key.
email.bouncedPermanent recipient rejection. The address is suppressed for your domain.
email.suppressedNot sent because the address is on a suppression list.
email.failedNot 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.