Production REST API for multi-brand transactional and marketing email on AWS SES. Suppression, bounce/complaint handling, open/click tracking and signed outbound webhooks are applied automatically.
All requests are HTTPS and authenticated with your company's
API key as a Bearer token. Your key looks like
smk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx and is shown
once when your account is provisioned — store it as a secret. The
company is resolved from the key; never send a company id in the body.
Base URL: https://api.sendmailer.cc
Authorization: Bearer smk_your_key_here Content-Type: application/json Accept: application/json
Two integration paths for Laravel:
sendmailer/laravel, set MAIL_MAILER=sendmailer,
done. Your existing Mailables, queued sends, attachments, CC/BCC
and Reply-To headers all pass through unchanged. See the
package section below..env# SENDMAILER.CC HTTP API SENDMAILER_BASE_URL=https://api.sendmailer.cc SENDMAILER_KEY=smk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Default From identity — must match an authorised sender configured for your company [email protected] MAIL_FROM_NAME="Your Company"
Note: the consuming app's MAIL_FROM_ADDRESS
is informational — SENDMAILER.CC uses the company's default From,
configured by the platform admin. When sender-identity enforcement
is on, the local-part is checked against an allowlist.
config/services.php'sendmailer' => [
'key' => env('SENDMAILER_KEY'),
'base_url' => env('SENDMAILER_BASE_URL', 'https://api.sendmailer.cc'),
],
use Illuminate\Support\Facades\Http;
Http::withToken(config('services.sendmailer.key'))
->acceptJson()
->post(config('services.sendmailer.base_url') . '/api/v1/email/send', [
'to' => ['email' => $user->email, 'name' => $user->name],
'template' => 'order-confirmation',
'data' => [
'first_name' => $user->first_name,
'order_id' => $order->id,
'total' => $order->total,
],
])
->throw();
Two one-time setup items before going live:
{{ placeholders }} matching your data keys).GET /api/v1/ping
— confirms the key resolves to the right active company before
any real traffic flows.The full v3 REST surface lives under https://api.sendmailer.cc/v3/
and covers transactional sends, templates, sender identities,
suppression, marketing contacts & lists, single sends, stats
and webhook subscriptions. Authenticate with your Bearer key:
Http::withToken(env('SENDMAILER_KEY'))
->acceptJson()
->post('https://api.sendmailer.cc/v3/mail/send', $payload);
| Group | Endpoints |
|---|---|
| Send | POST /v3/mail/send — accepts personalizations[], template_id, content[], categories, custom_args; returns 202 + X-Message-Id |
| Templates | GET POST /v3/templates, GET PATCH DELETE /v3/templates/{id} |
| Senders | GET POST /v3/senders, GET PATCH DELETE /v3/senders/{id} |
| Suppression | GET DELETE /v3/suppression/bounces[/{email}], GET DELETE /v3/suppression/unsubscribes[/{email}] |
| Marketing contacts | GET PUT DELETE /v3/marketing/contacts[/{id}] (PUT = batch upsert by email) |
| Marketing lists | GET POST PATCH DELETE /v3/marketing/lists[/{id}] + POST DELETE /v3/marketing/lists/{id}/contacts |
| Single sends | GET POST DELETE /v3/marketing/singlesends[/{id}] + lifecycle actions /now, /schedule, /pause, /resume, /cancel, /test |
| Stats | GET /v3/stats?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD |
API conventions: responses are unwrapped JSON (no {"data": ...}
envelope); bounce/suppression deletes are idempotent
(204 whether or not the row existed); rate limits +
per-key IP allowlist + sender-identity enforcement all apply to
/v3 the same as to the native /api/v1
surface.
/v3/mail/send fields| Payload field | Behaviour |
|---|---|
personalizations[].to/cc/bcc/subject | Per-recipient fan-out; CC + BCC delivered; per-personalization subject overrides top-level |
personalizations[].dynamic_template_data | Bound to template {{ placeholders }} when template_id is set |
reply_to (top or per-personalization) | Per-send Reply-To header; personalization overrides top-level |
from {email,name} | Per-send From; still gated by sender-identity enforcement when on |
content[] {type, value} | Raw HTML/text body — alternative to template_id |
template_id | Slug of a template you created on this account |
categories, custom_args | Persisted on each message; surfaced on events |
headers | Arbitrary custom headers; reserved headers (From/To/Subject/etc.) and CRLF-injection attempts are filtered |
attachments[] | Per-send attachments (10 MB per file, 25 MB aggregate, MIME-allowlisted) |
mail_settings.sandbox_mode.enable: true | Validates + persists an audit row, does not actually send. Returns 202 + X-Message-Id |
Idempotency-Key request header | Replays with same body return the cached 2xx + Idempotent-Replayed: true; replays with a different body return 422 |
Response envelope: 202 Accepted with X-Message-Id
(first), X-Message-Ids (CSV), X-Message-Count.
When your key has a per-minute rate limit, every authenticated
response — including 429s — also carries X-RateLimit-Limit,
X-RateLimit-Remaining, X-RateLimit-Reset.
Send the optional Idempotency-Key request header (any
string, typically a UUID or business identifier) and we'll cache the
2xx response for 24 hours. Replays with the same
body return the cached response plus an extra
Idempotent-Replayed: true header. Replays with the
same key but a different body return
422 — the contract protects you from a retry that
accidentally double-sends:
curl -X POST https://api.sendmailer.cc/v3/mail/send \
-H "Authorization: Bearer $SENDMAILER_KEY" \
-H "Idempotency-Key: order-abc-123-confirmation" \
-H "Content-Type: application/json" \
-d '{ "personalizations":[…], "from":{…}, "content":[…] }'
Each entry is base64-encoded content with a MIME type and filename.
Per-file cap 10 MB, total cap 25 MB,
MIME allowlist (executables like application/x-msdownload,
.exe, .msi, .sh,
.php are rejected with a clear 422). Inline images
carry a content_id:
{
"attachments": [
{
"content": "JVBERi0xLjQKJ…",
"type": "application/pdf",
"filename": "invoice.pdf"
},
{
"content": "iVBORw0KGgo…",
"type": "image/png",
"filename": "logo.png",
"disposition": "inline",
"content_id": "logo"
}
]
}
Pass mail_settings.sandbox_mode.enable: true and we
validate + persist the message but do not actually deliver.
Same 202 + X-Message-Id envelope as a real send;
the persisted row is marked status: sandboxed so you
can see it on the activity page. Useful for staging environments,
CI smoke tests, and rate-limit experiments:
{
"personalizations":[…],
"from":{…},
"content":[…],
"mail_settings": { "sandbox_mode": { "enable": true } }
}
For sending a single template to a contact list (newsletter blast, announcement), use the Single Sends surface:
# 1. create the single send (status: draft)
curl -X POST https://api.sendmailer.cc/v3/marketing/singlesends \
-H "Authorization: Bearer $SENDMAILER_KEY" -H "Content-Type: application/json" \
-d '{"name":"Q3 newsletter","template_id":"q3-news","list_id":42}'
# 2. start it immediately…
curl -X POST https://api.sendmailer.cc/v3/marketing/singlesends/{id}/now \
-H "Authorization: Bearer $SENDMAILER_KEY"
# …or schedule it
curl -X POST https://api.sendmailer.cc/v3/marketing/singlesends/{id}/schedule \
-H "Authorization: Bearer $SENDMAILER_KEY" -H "Content-Type: application/json" \
-d '{"send_at":"2026-06-01T10:00:00Z"}'
# pause / resume / cancel mid-flight
curl -X POST https://api.sendmailer.cc/v3/marketing/singlesends/{id}/pause …
curl -X POST https://api.sendmailer.cc/v3/marketing/singlesends/{id}/resume …
curl -X POST https://api.sendmailer.cc/v3/marketing/singlesends/{id}/cancel …
# preview to your own inbox first
curl -X POST https://api.sendmailer.cc/v3/marketing/singlesends/{id}/test \
-H "Authorization: Bearer $SENDMAILER_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
State machine: draft → scheduled /
sending → paused ↔ sending →
completed / cancelled. Invalid transitions
return 422 with a clear reason; recipient counts +
sent/skipped/failed are visible on
GET /v3/marketing/singlesends/{id}.
Batch-upsert contacts (insert-or-update by email) and manage lists:
# batch upsert (up to 30,000 per call)
curl -X PUT https://api.sendmailer.cc/v3/marketing/contacts \
-H "Authorization: Bearer $SENDMAILER_KEY" -H "Content-Type: application/json" \
-d '{
"list_ids": [42],
"contacts": [
{"email":"[email protected]","first_name":"Jane","custom_fields":{"plan":"growth"}},
{"email":"[email protected]","first_name":"Bob"}
]
}'
# → 202 { "job_id":"…", "created_count":1, "updated_count":1 }
# create a list
curl -X POST https://api.sendmailer.cc/v3/marketing/lists \
-H "Authorization: Bearer $SENDMAILER_KEY" -H "Content-Type: application/json" \
-d '{"name":"VIPs","description":"Top-100 by LTV"}'
# attach existing contacts
curl -X POST https://api.sendmailer.cc/v3/marketing/lists/42/contacts \
-H "Authorization: Bearer $SENDMAILER_KEY" -H "Content-Type: application/json" \
-d '{"contact_ids":[1,2,3]}'
Daily aggregates of sends + events over a window. Returns one row
per day, with a metrics object holding the
message-status counts and event counts:
GET https://api.sendmailer.cc/v3/stats?start_date=2026-05-01&end_date=2026-05-20
{
"start_date": "2026-05-01",
"end_date": "2026-05-20",
"stats": [
{ "date": "2026-05-19", "metrics": { "sent": 1240, "delivered": 1220, "bounce": 8, "open": 612, "click": 187 } },
{ "date": "2026-05-20", "metrics": { "sent": 980, "delivered": 974, "bounce": 3, "open": 503, "click": 142 } }
]
}
Subscribe a URL of yours to receive bounce / delivered / spamreport / unsubscribe /
open / click events. Path: /v3/user/webhooks/event/settings.
curl -X POST https://api.sendmailer.cc/v3/user/webhooks/event/settings \
-H "Authorization: Bearer $SENDMAILER_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "prod-events",
"url": "https://your-app.example/sendmailer/events",
"event_types": ["bounce","delivered","spamreport","unsubscribe"]
}'
# → 201 with {subscription, signing_secret, message}
Store the signing_secret at creation time — it is shown
only once. Rotate via POST /v3/user/webhooks/event/settings/{id}/rotate;
the old secret is invalidated immediately.
| Header | Meaning |
|---|---|
X-Sendmailer-Signature | hex(hmac_sha256(body, signing_secret)) |
X-Sendmailer-Timestamp | unix epoch — reject if older than ~5 min |
X-Sendmailer-Event-Id | uuid — use as receiver-side idempotency key |
Route::post('/sendmailer/events', function (Request $request) {
$body = $request->getContent();
$expected = hash_hmac('sha256', $body, config('services.sendmailer.webhook_secret'));
if (! hash_equals($expected, $request->header('X-Sendmailer-Signature', ''))) {
abort(401);
}
if (abs(now()->timestamp - (int) $request->header('X-Sendmailer-Timestamp', 0)) > 300) {
abort(401);
}
// process $request->json()
return response()->noContent();
})->withoutMiddleware([VerifyCsrfToken::class]);
4xx other than 408/429 is permanent — no further retries; consecutive_failures increments.PATCH once the receiver is fixed.webhook_deliveries for debugging (response status + truncated body).sendmailer/laravelAlready on Laravel Mailables? Install the companion package and your
Mail::to(...)->send(new Mailable) code keeps working —
no rewrites:
composer require sendmailer/laravel
# .env MAIL_MAILER=sendmailer SENDMAILER_KEY=smk_xxxxx SENDMAILER_BASE_URL=https://api.sendmailer.cc
// config/mail.php
'mailers' => [
'sendmailer' => ['transport' => 'sendmailer'],
// …
],
Attachments, CC, BCC, Reply-To, inline images (content_id)
and custom X-* headers all pass through to
/v3/mail/send unchanged. SentMessage::getMessageId()
returns sendmailer's X-Message-Id.
curl https://api.sendmailer.cc/api/v1/ping \
-H "Authorization: Bearer $SENDMAILER_KEY" -H "Accept: application/json"
{ "ok": true, "company": "your-company" }
A 401 means the key is missing, invalid, revoked, or the
company is not active.
{
"to": { "email": "[email protected]", "name": "Jane Doe" },
"template": "order-confirmation",
"data": { "first_name": "Jane", "order_id": "ORD-1234", "total": "$59.00" }
}
| Field | Required | Description |
|---|---|---|
to.email | yes | Recipient address |
to.name | no | Recipient display name |
template | yes | Template slug (managed per company in the admin) |
data | no | Values substituted into template placeholders |
| HTTP | Body | Meaning |
|---|---|---|
| 202 | {"id":123,"status":"queued"} | Accepted & queued for delivery |
| 200 | {"id":123,"status":"suppressed"} | Recipient suppressed (unsubscribed / bounced / complained). Not sent — expected, not an error. |
| 422 | {"message":"Unknown or inactive template..."} | Template not found/inactive, or validation failed |
| 401 | {"message":"..."} | Missing/invalid/revoked key, or company inactive |
| 429 | {"message":"Rate limit exceeded."} | Per-key rate limit hit |
Treat 202 and 200 as success. Retry only on
5xx / network errors using the same payload.
curl -X POST https://api.sendmailer.cc/api/v1/email/send \
-H "Authorization: Bearer $SENDMAILER_KEY" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"to":{"email":"[email protected]","name":"Jane"},
"template":"order-confirmation",
"data":{"first_name":"Jane","order_id":"ORD-1234"}}'
use Illuminate\Support\Facades\Http;
Http::withToken(config('services.sendmailer.key'))
->acceptJson()
->post('https://api.sendmailer.cc/api/v1/email/send', [
'to' => ['email' => $user->email, 'name' => $user->name],
'template' => 'order-confirmation',
'data' => ['first_name' => $user->first_name, 'order_id' => $order->id],
])
->throw();
Templates are created per company in the admin panel. Each has a slug
(used as template), a subject and HTML/text bodies.
Placeholders use {{ key }} and are filled from
data:
{{ order_id }} → data.order_id{{ user.first_name }} → nested via dot notationA verified sending domain lets your company send from some
address on that domain. Your account admin may further restrict you
to specific local-parts (e.g. no-reply,
support, bookings) — a "Single Sender"
allowlist tied to your company. You don't authorise these yourself;
it's an admin-side setting on this account.
support identity
covers support+booking, support+invoice,
etc.202 queued — the rejection happens
when the worker picks it up, and the message ends up
failed with the reason recorded. Check the message
status if a send doesn't arrive.status: suppressed. You don't track this yourself.Need an API key? Get started.