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.
Quick start
- Sign up, then click the link in the verification email to verify your address.
- 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.
- Create a key on the API Keys page. The key is shown only once, so store it safely.
- 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>"
}'
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
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
| Parameter | Type | Description |
|---|---|---|
from | string | Required. Sender, as address or Name <address>. The domain must be verified on your account. |
to | string | string[] | Required. Recipients. |
cc | string | string[] | CC recipients. |
bcc | string | string[] | BCC recipients. |
reply_to | string | string[] | Reply-to address. |
subject | string | Required. Subject. No line breaks, up to 998 characters. |
html | string | HTML body. |
text | string | Plain-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
| Status | Meaning |
|---|---|
queued | In the send queue |
sent | Handed to the mail server, delivery in progress |
delivery_delayed | Temporarily refused by the receiver, retrying automatically |
delivered | Accepted by the receiving mail server |
bounced | Hard bounce (for example, the address doesn’t exist). The address was added to the suppression list |
complained | The recipient reported it as spam |
failed | Sending failed, for example rejected by the receiver or retries timed out. See the error field |
suppressed | Every recipient is on the suppression list, so nothing was sent |
Retrieve email
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"
}
]
}
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
{ "name": "acme.com" }
Returns the domain and the DNS records to add:
| Purpose | Type | Name | Description |
|---|---|---|---|
| DKIM | TXT | <selector>._domainkey.acme.com | Required. The signing public key. Once it’s live, the domain can send. |
| SPF | TXT | send.acme.com | Strongly recommended. Authorizes our IPs to send for the domain. |
| Bounce address | MX | send.acme.com | Strongly recommended. Receives bounces. |
| DMARC | TXT | _dmarc.acme.com | Recommended. Gmail and others require it for bulk senders. |
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.
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
| Event | When it fires |
|---|---|
email.sent | The email was handed to the mail server |
email.delivered | Accepted by the receiving mail server |
email.delivery_delayed | Temporary delivery failure, will retry automatically |
email.bounced | Hard bounce. The address was added to the suppression list |
email.rejected | Rejected by the receiver (for example as spam), or retries timed out |
email.complained | The recipient reported it as spam |
email.failed | Sending 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" }.
| Status | Meaning |
|---|---|
400 | The request body isn’t valid JSON |
401 | Missing or invalid API key |
403 | Sending domain not verified, email address not verified, or account suspended |
404 | Not found |
409 | Already exists, for example the domain was already added |
413 | Email body over 512 KB |
422 | Invalid parameters, for example a malformed address or too many recipients |
429 | Daily or monthly sending quota exceeded |
500 | Internal server error. Please retry later |
Limits and pricing
| Free | Pro | Dedicated IP | |
|---|---|---|---|
| Monthly price | ¥0 | ¥99 | From ¥699 |
| Included per month | 3,000 | 50,000 | 500,000 |
| Prepay discounts | — | 3 months: 5% off · 6 months: 10% off · 12 months: 20% off | |
| Overage | Not available | ¥2 per 1,000 | ¥1 per 1,000 |
| Daily cap | 100 | 20,000 | Unlimited |
| Monthly cap | 3,000 | 200,000 | Unlimited |
| Recipients per email | 10 | 50 | 50 |
| Sending IPs | Free shared pool | Premium shared pool | Dedicated IP (+¥200 / month for each extra) |
| Auto-suspend threshold | 4% bounces / 0.08% complaints | 5% bounces / 0.1% complaints | 5% 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.