BulkMail API Docs

BulkMail provides an HTTP API for sending email. All requests and responses use JSON, so you can call it from any language.

Base URL: https://bulkmail.dev

Quick start

  1. Sign up, then click the link in the verification email to verify your address.
  2. Add your domain on the Domains page of the dashboard, then add the DNS records shown there at your DNS provider. Once the DKIM record is live, the domain switches to Verified automatically.
  3. Create a key on the API Keys page. The key is shown only once, so store it safely.
  4. Send your first email:
curl -X POST https://bulkmail.dev/emails \
  -H "Authorization: Bearer mk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.com>",
    "to": "user@example.com",
    "subject": "Hello",
    "html": "<p>This is my first email</p>"
  }'
We recommend sending from a subdomain (for example mail.acme.com), kept separate from your everyday company mail so neither affects the other’s reputation.

Authentication

Every API request must include your API key in a header:

Authorization: Bearer mk_your_api_key

An API key is as sensitive as your password. Use it only on the server, never in web or app frontend code. If one leaks, delete it in the dashboard and create a new one.

Send email

POST/emails

The email enters the send queue and the API returns its ID immediately. Get the delivery result from the retrieve endpoint or a webhook.

Request parameters

ParameterTypeDescription
fromstringRequired. Sender, as address or Name <address>. The domain must be verified on your account.
tostring | string[]Required. Recipients.
ccstring | string[]CC recipients.
bccstring | string[]BCC recipients.
reply_tostring | string[]Reply-to address.
subjectstringRequired. Subject. No line breaks, up to 998 characters.
htmlstringHTML body.
textstringPlain-text body. Provide at least one of html and text. If you provide both, the recipient’s mail client picks one. Combined size up to 512 KB.

The total of to, cc and bcc recipients can’t exceed your plan’s limit (see Limits).

Response

HTTP/1.1 202 Accepted

{
  "id": "9db6562a-d026-4c68-ae7d-df08dd3af862",
  "status": "queued",
  "suppressed": []
}

suppressed lists recipients skipped because they’re on the suppression list. If every recipient is suppressed, status is suppressed and the email is not sent.

Email status

StatusMeaning
queuedIn the send queue
sentHanded to the mail server, delivery in progress
delivery_delayedTemporarily refused by the receiver, retrying automatically
deliveredAccepted by the receiving mail server
bouncedHard bounce (for example, the address doesn’t exist). The address was added to the suppression list
complainedThe recipient reported it as spam
failedSending failed, for example rejected by the receiver or retries timed out. See the error field
suppressedEvery recipient is on the suppression list, so nothing was sent

Retrieve email

GET/emails/{id}

Returns the email content, its current status, and delivery events for each recipient.

{
  "id": "9db6562a-d026-4c68-ae7d-df08dd3af862",
  "from": "Acme <hello@acme.com>",
  "to": ["user@example.com"],
  "subject": "Hello",
  "status": "delivered",
  "created_at": "2026-11-08T10:24:01.000Z",
  "sent_at": "2026-11-08T10:24:02.000Z",
  "events": [
    {
      "type": "delivered",
      "recipient": "user@example.com",
      "detail": "250 2.0.0 OK",
      "created_at": "2026-11-08T10:24:03.000Z"
    }
  ]
}
GET/emails?limit=20&before={cursor}

Lists emails, newest first. limit is at most 100. When next_before in the response is not empty, pass it as before to get the next page.

Domains

POST/domains
{ "name": "acme.com" }

Returns the domain and the DNS records to add:

PurposeTypeNameDescription
DKIMTXT<selector>._domainkey.acme.comRequired. The signing public key. Once it’s live, the domain can send.
SPFTXTsend.acme.comStrongly recommended. Authorizes our IPs to send for the domain.
Bounce addressMXsend.acme.comStrongly recommended. Receives bounces.
DMARCTXT_dmarc.acme.comRecommended. Gmail and others require it for bulk senders.
POST/domains/{id}/verify

Checks the DNS records right away. We also check automatically every 10 minutes. A domain not verified within 72 hours of being added expires and must be added again.

GET/domains · GET/domains/{id} · DELETE/domains/{id}

Webhook

PUT/webhook
{ "url": "https://your-app.com/webhooks/mail" }

The first time you set it, the response includes the signing secret, shown only this once. After that, every time an email’s status changes, we send a POST request to this URL:

{
  "type": "email.delivered",
  "created_at": "2026-11-08T10:24:03.000Z",
  "data": {
    "email_id": "9db6562a-d026-4c68-ae7d-df08dd3af862",
    "recipient": "user@example.com",
    "detail": "250 2.0.0 OK"
  }
}

Event types

EventWhen it fires
email.sentThe email was handed to the mail server
email.deliveredAccepted by the receiving mail server
email.delivery_delayedTemporary delivery failure, will retry automatically
email.bouncedHard bounce. The address was added to the suppression list
email.rejectedRejected by the receiver (for example as spam), or retries timed out
email.complainedThe recipient reported it as spam
email.failedSending failed (for example, the account is suspended)

Any 2xx response from your server counts as success. Otherwise we retry with exponential backoff, up to 8 times.

Verifying signatures

Each request carries two headers: X-Webhook-Timestamp (Unix seconds) and X-Webhook-Signature. Compute an HMAC-SHA256 of timestamp + "." + raw body with your signing secret and compare it with the signature:

import crypto from "node:crypto";

export function verifyWebhook(headers, rawBody, secret) {
  const ts = headers["x-webhook-timestamp"];
  const sig = headers["x-webhook-signature"] ?? "";
  const expected = "v1=" + crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");

  // Reject requests older than 5 minutes to prevent replays
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  return fresh
    && sig.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Error codes

On error, the response body is { "error": "description" }.

StatusMeaning
400The request body isn’t valid JSON
401Missing or invalid API key
403Sending domain not verified, email address not verified, or account suspended
404Not found
409Already exists, for example the domain was already added
413Email body over 512 KB
422Invalid parameters, for example a malformed address or too many recipients
429Daily or monthly sending quota exceeded
500Internal server error. Please retry later

Limits and pricing

FreeProDedicated IP
Monthly price¥0¥99From ¥699
Included per month3,00050,000500,000
Prepay discounts—3 months: 5% off · 6 months: 10% off · 12 months: 20% off
OverageNot available¥2 per 1,000¥1 per 1,000
Daily cap10020,000Unlimited
Monthly cap3,000200,000Unlimited
Recipients per email105050
Sending IPsFree shared poolPremium shared poolDedicated IP (+¥200 / month for each extra)
Auto-suspend threshold4% bounces / 0.08% complaints5% bounces / 0.1% complaints5% bounces / 0.1% complaints

Quota counts recipients (one email to 3 people counts as 3). The daily quota resets at 00:00 UTC and the monthly quota on the 1st of each month. Overage is billed monthly. To upgrade, submit a request on the Plans page of the dashboard.

Bounce and complaint rates are measured over the last 7 days. Accounts above the threshold are suspended automatically, to protect deliverability for everyone on the platform.