API reference
Send email with SendPelican
One HTTPS endpoint sends your email. Requests and responses are JSON. The base URL is https://sendpelican.com.
Authentication
Every /v1 request carries an API key as a Bearer token. Create keys in the dashboard under Settings, API Keys. A key is shown once, so store it as a secret.
Authorization: Bearer <YOUR_API_TOKEN>
Each key carries abilities. A key with full access has all of them. GET /v1/usage and GET /v1/account work with any key that has at least one ability outside the sandbox.
| Ability | Allows |
|---|---|
| emails:send | POST /v1/emails |
| bulk:send | POST /v1/bulk/emails |
| emails:read | GET /v1/emails and GET /v1/emails/{id} |
| domains:manage | /v1/domains |
| suppressions:manage | /v1/suppressions |
| webhooks:manage | /v1/webhooks |
| sandbox:read | Reading the Email Sandbox: inboxes, messages, wait |
| sandbox:manage | Creating, cleaning and deleting sandbox inboxes and messages |
| sandbox:send | POST /v1/sandbox/inboxes/{id}/messages (capture over HTTP) |
Send an email
POST /v1/emails needs the emails:send ability. The from address must be on a domain you have verified.
curl -X POST https://sendpelican.com/v1/emails \
-H "Authorization: Bearer <YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"from": {
"email": "hello@yourdomain.com"
},
"to": [
"recipient@example.com"
],
"subject": "Hello from yourdomain.com",
"html": "<p>Sent with SendPelican.</p>",
"text": "Sent with SendPelican.",
"category": "welcome"
}'
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| from.email | string | Yes | Sender address. Its domain must be verified on your account. |
| from.name | string | No | Display name. |
| to | array of strings | Yes | 1 to 50 recipients. Each gets its own message, status and events. |
| subject | string | Yes | Up to 998 characters. |
| html | string | One of html, text | HTML body. |
| text | string | One of html, text | Plain-text body. |
| reply_to | string | No | Reply-To address. |
| headers | object | No | Extra headers, for example {"X-Campaign": "receipt"}. Names SendPelican sets itself (From, To, Bcc, Subject, Message-Id and similar) are refused. |
| tags | array of strings | No | Up to 20 labels, returned in events and webhooks. |
| metadata | object | No | Your own key-value data, returned in events and webhooks. |
| track_opens | boolean | No | Overrides the domain default for this request. |
| track_clicks | boolean | No | Overrides the domain default for this request. |
| category | string | No | One label for the kind of email, for example "Password reset". Up to 100 printable characters. The Email Categories stats group by it, and it is returned in events and webhooks. |
The body must be under 10 MB. Recipients on your suppression list come back with status suppressed and use no quota.
Response
202 Accepted with one entry per recipient. Use each id with GET /v1/emails/{id} to read its status and events.
{
"data": {
"batch": "01k7a9w8e5n3c2b1x0z9y8v7u5",
"messages": [
{
"id": "01k7a9w8e5n3c2b1x0z9y8v7u6",
"to": "recipient@example.com",
"status": "queued"
}
]
}
}
Send over SMTP
Any app or mail client that speaks SMTP can send through SendPelican. SMTP mail uses the same verified domains, quota, suppressions, tracking and webhooks as the API, and it can carry attachments, which the API cannot. Create a username and password under SMTP Credentials in the dashboard.
Give every message a unique Message-ID header; most libraries add one. If your client resends after a lost reply, SendPelican returns the messages it already accepted for that Message-ID and recipients instead of sending again. Without a Message-ID, an identical message to the same recipients within the same 15-minute window counts as a retry and is not sent twice.
To set the message's category, the same as the API's category field, add a X-Mailflow-Category header. SendPelican removes the header before sending, so recipients never see it. A value longer than 100 characters or with control characters is refused.
| Host | smtp.sendpelican.com |
|---|---|
| Ports |
465 TLS (implicit, SMTPS)
587 STARTTLS
|
| Authentication | AUTH PLAIN or AUTH LOGIN, only after TLS is up. A plain-text connection cannot log in. |
| Limits | 10 MB per message and 50 recipients per message. Each recipient becomes its own message with its own status and events. |
# Laravel 11+. On Laravel 10 or older, use MAIL_ENCRYPTION=tls in place of MAIL_SCHEME.
MAIL_MAILER=smtp
MAIL_SCHEME=smtp
MAIL_HOST=smtp.sendpelican.com
MAIL_PORT=587
MAIL_USERNAME=<SMTP_USERNAME>
MAIL_PASSWORD=<SMTP_PASSWORD>
MAIL_FROM_ADDRESS=hello@yourdomain.com
SMTP replies
A 4xx reply is temporary: retry later and nothing was sent. A 5xx reply is permanent: fix the cause before you resend. When SendPelican refuses a message, the reply maps from the same error code the API would return. A wrong username or password gets 535 5.7.8.
| Reply | Text | API error code | Meaning |
|---|---|---|---|
| 535 5.7.8 | Authentication credentials invalid | invalid_credentials (401) | The SMTP username or password is wrong, or the credential was revoked during the session. |
| 554 5.7.1 | Account suspended; sending is disabled | account_suspended (403) | The account is suspended or closed and cannot send. A suspended account's sandbox still captures; a closed account's does not. |
| 550 5.7.1 | Sender domain is not verified on this account | domain_not_verified (422) | The from address is not on a verified domain of this account. |
| 550 5.7.1 | Sender headers not allowed | sender_not_allowed (422) | The SMTP message names its sender in a way SendPelican refuses: two From headers, a From with no address, several addresses or a group, or a Sender, Resent-From or Resent-Sender header. The SMTP server replies 550 5.7.1. |
| 452 4.2.2 | Sending quota exceeded; try again later | quota_exceeded (429) | The request would pass the daily or monthly limit of the plan. Nothing was sent or reserved. |
| 452 4.2.2 | Domain warm-up limit reached for today; try again later | warmup_limit (429) | The from domain is warming up and the request would pass its daily warm-up cap. Nothing was sent or reserved. details has domain, stage, daily_cap, sent_today and resets_at. |
| 552 5.3.4 | Message too large | message_too_large (413) | The SMTP message is over 10 MB, or over the sandbox plan's message size. The SMTP server replies 552. |
| 550 5.6.0 | Message rejected | validation_failed (422) | The message could not be accepted as sent, for example it has no From address or no recipients, or it is signed and sent with a bulk credential. |
| 550 5.7.1 | Plan does not include this stream | plan_upgrade_required (403) | The credential is a bulk credential and the plan no longer includes bulk sending. |
| 554 5.7.1 | Bulk sending is paused for this account | bulk_paused (403) | Bulk sending is paused for the account because its bulk bounce or complaint rate passed the bulk limits. Transactional email still sends. Support resumes bulk after review. Over SMTP the server replies 554 5.7.1. |
| 550 5.7.1 | Bulk email needs the account's company name and postal address | bulk_requires_postal_address (422) | Bulk email must carry the sender's company name and postal address. Add both under Settings, Account. Over SMTP the server replies 550 5.7.1. |
| 550 5.7.1 | Sender domain does not allow bulk email | bulk_not_allowed_for_domain (422) | The from domain does not allow bulk email. Turn on "Allow bulk" in the domain's settings, ideally on a subdomain used only for bulk. Over SMTP the server replies 550 5.7.1. |
| 452 4.2.2 | Sandbox email quota exceeded; try again next period | sandbox_quota_exceeded (429) | The sandbox plan's emails per month are used up. Nothing was stored. The SMTP server replies 452 4.2.2. |
| 452 4.2.2 | Sandbox storage is full; delete messages or upgrade | sandbox_storage_full (429) | The sandbox plan's storage is full. Nothing was stored. Delete messages or inboxes, or upgrade. The SMTP server replies 452 4.2.2. |
| 451 4.3.0 | Temporary server error; try again later | None | SendPelican could not accept the message for any other reason. Retry; nothing was sent. |
| 454 4.7.0 | Temporary authentication failure; try again later | None | The login could not be checked. Retry. |
| 454 4.7.0 | Too many failed logins; try again later | None | This username had too many wrong passwords in a row. |
| 530 5.7.0 | Authentication required | None | MAIL, RCPT or DATA was sent before AUTH. |
| 454 4.7.0 | Too many failed logins from this address; try again later | None | Logins from this IP address failed too often. One more attempt is allowed every few seconds (MAILFLOW_SMTP_AUTH_IP_REFILL). |
| 421 4.7.0 | Too many failed logins; closing connection | None | This connection had too many refused logins. The server closes it. |
| 535 5.7.8 | This credential belongs to the other host (sending or sandbox) | None | A sending credential (mf_) was used on a sandbox port, or a sandbox inbox login (sb_) on a sending port. It counts as a failed login. |
Bulk email
Newsletters, announcements and other mail to many people at once go on the bulk stream. Bulk sending is included in paid plans; on the Free plan it returns 403 plan_upgrade_required. Bulk and transactional email share your plan's monthly quota.
Bulk mail has its own queue, sending rate and reputation. A campaign of 50,000 emails waits in its own line, so it never delays a password reset sent through POST /v1/emails. Each account sends bulk at up to 10 emails per second; a large batch is spread out at that rate.
Send a batch
POST /v1/bulk/emails needs the bulk:send ability. The body is the /v1/emails body with recipients in place of to. The response has the same shape: one batch id and one entry per recipient.
curl -X POST https://sendpelican.com/v1/bulk/emails \
-H "Authorization: Bearer <YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"from": {
"email": "hello@yourdomain.com"
},
"recipients": [
{
"email": "ada@example.com",
"variables": {
"name": "Ada",
"plan": "Pro"
}
},
{
"email": "grace@example.com",
"variables": {
"name": "Grace",
"plan": "Starter"
}
}
],
"subject": "News for you, {{ name }}",
"html": "<p>Hi {{ name }}, here is what is new on your {{ plan }} plan.</p>",
"text": "Hi {{ name }}, here is what is new on your {{ plan }} plan.",
"category": "newsletter"
}'
Laravel's Mail facade transport sends through /v1/emails, so its mail is transactional. For bulk from Laravel, call the endpoint with the HTTP client as in the Laravel tab above, or point Laravel's smtp mailer at a bulk SMTP credential.
| Field | Type | Required | Notes |
|---|---|---|---|
| recipients | array of objects | Yes | 1 to 500 recipients, in place of to. Each gets its own message, status and events. Addresses must differ, ignoring case. |
| recipients[].email | string | Yes | The recipient address. |
| recipients[].variables | object | No | Up to 50 values for this recipient's {{ name }} placeholders. Names start with a letter or underscore and hold letters, digits and underscores, up to 64 characters. Values are strings, numbers, booleans or null, up to 1000 characters. |
| subject, html, text | string | As /v1/emails | May contain {{ name }} placeholders, filled per recipient. |
| from, reply_to, headers, tags, metadata, track_opens, track_clicks, category | As /v1/emails | The same for every recipient of the batch. |
Variables
Write {{ name }} in the subject, HTML or text, with or without spaces inside the braces. Each recipient's message gets that recipient's value. In the HTML body the value is HTML-escaped, so a value such as <b> shows as text and can never add markup or script. In the subject and text body it is used as is. A placeholder with no value for that recipient becomes empty. Email Logs show each recipient's subject as sent.
Limits
- 500 recipients per request. The request body must be under 10 MB.
- 50 variables per recipient, each up to 1000 characters.
- The whole batch is accepted or refused. If your quota, the daily limit or a warming domain's cap cannot cover every recipient who is not suppressed, the request returns 429 and nothing is queued or counted.
- An
Idempotency-Keycovers the whole batch: a retry with the same key returns the original messages and sends nothing new. Keys are shared with/v1/emails, so never reuse a key across the two endpoints. - A subject longer than 998 characters after its variables are filled is cut to 998.
- While a domain warms up, bulk email may use at most 50% of its daily cap, so a campaign leaves room for the domain's transactional email.
Before you send bulk
- Turn on Allow bulk on the domain's Bulk Sending tab. Otherwise the request returns
422 bulk_not_allowed_for_domain. Send bulk from a subdomain such asnews.yourdomain.com: complaints about newsletters then stay off the domain your receipts and sign-in codes come from. - Add your company name and postal address under Settings, Account. Anti-spam laws require them in marketing email, and SendPelican adds them to the footer of every bulk email. Without them the request returns
422 bulk_requires_postal_address.
Unsubscribe and suppressions
Every bulk email carries an unsubscribe link in its footer and one-click List-Unsubscribe headers (RFC 8058), which Gmail and Yahoo require of bulk senders, whatever the domain's footer setting. A List-Unsubscribe header you set yourself is replaced on bulk email. Over SMTP a bulk message needs a text or HTML part to carry the footer. An unsubscribe from a bulk email suppresses the address for bulk email only, so the recipient still gets receipts and password resets. A hard bounce or a spam complaint suppresses the address for every stream. POST /v1/suppressions takes an optional stream: "bulk" or "transactional" suppresses for that stream only, and leaving it out suppresses for every stream. A scope only widens: an address already suppressed for every stream stays that way, and suppressing it for the other stream as well makes it every stream. Each suppression in GET /v1/suppressions shows its stream, null for every stream.
Bulk over SMTP
Create an SMTP credential with the Bulk stream. Every message sent with it is bulk email, with the same footer, headers and rules as the API. Connect to bulk.smtp.sendpelican.com on the same ports; the credential, not the host name, decides the stream. Signed messages (S/MIME, PGP, or your own DKIM signature) cannot be sent as bulk, because the footer would break the signature, and are refused with 550 5.6.0.
Bulk health
Bulk email has stricter limits than your account as a whole. If more than 4% of your bulk email bounces, or more than 0.08% draws spam complaints, over at least 200 bulk emails in 7 days, bulk sending is paused for your account. With fewer than 1251 bulk emails in the window, a single complaint is above 0.08%, so send bulk only to people who asked for it. Transactional email keeps sending. While paused, the bulk endpoint returns 403 bulk_paused and queued bulk email fails with that error. Contact support to resume.
Messages, events and webhooks carry a stream field, transactional or bulk, and GET /v1/emails?stream=bulk lists one stream.
Email Sandbox
The Email Sandbox catches the mail your development, staging and CI apps send and never delivers it. Each inbox has its own SMTP login (a username starting sb_). Point the app at smtp.sendpelican.com port 2525 with that login, and every message lands in the inbox, where you see its HTML, text, headers, raw source and attachments in the dashboard or through the API below.
The sandbox host and the sending host are separate doors. A sending credential on the sandbox port, or a sandbox login on a sending port, gets 535 5.7.8, so a staging app cannot send real mail by mistake.
Point your app at the sandbox
Create an inbox under Sandbox, Inboxes in the dashboard and copy its username and password into your app's mail settings in place of the placeholders. Nothing else changes: the From address can be on any domain, verified or not.
# Laravel 11+. On Laravel 10 or older, use MAIL_ENCRYPTION=tls in place of MAIL_SCHEME.
MAIL_MAILER=smtp
MAIL_SCHEME=smtp
MAIL_HOST=smtp.sendpelican.com
MAIL_PORT=2525
MAIL_USERNAME=<SMTP_USERNAME>
MAIL_PASSWORD=<SMTP_PASSWORD>
MAIL_FROM_ADDRESS=app@staging.example.com
What you see for each message
The rendered HTML (in a sandboxed frame that loads no remote content until you ask), the text part, the raw source, the headers and the attachments. HTML checks runs static rules over the markup and CSS: elements mail clients strip (scripts, forms, iframes, video), linked stylesheets, CSS position, flexbox, grid and media queries, a missing title or language, images without alt text, inline images over 100 KB, HTML over Gmail's 102 KB clipping size, a missing text part, and a missing unsubscribe link or header. It reads the HTML; it does not render it in real mail clients. When the server runs SpamAssassin, the Spam tab shows SpamAssassin's score and every rule that matched.
Sandbox API
The sandbox routes need the sandbox:read, sandbox:manage or sandbox:send ability. Developers on your team can create keys that carry only these abilities; such a key gets 403 missing_ability on every route outside /v1/sandbox.
| Method | Path | Ability | Does |
|---|---|---|---|
| GET | /v1/sandbox/inboxes | sandbox:read | Your inboxes, and the sandbox plan's usage this period. |
| POST | /v1/sandbox/inboxes | sandbox:manage | Create an inbox, body {"name": "Staging"}. The response holds the SMTP password, once. |
| DELETE | /v1/sandbox/inboxes/{id} | sandbox:manage | Delete the inbox and every message in it. |
| POST | /v1/sandbox/inboxes/{id}/clean | sandbox:manage | Delete every message, keep the inbox and its login. |
| GET | /v1/sandbox/inboxes/{id}/messages | sandbox:read | Newest first. Filters: search, to, from, subject, since, unread, limit (1 to 100). Pages with links.next like GET /v1/emails. |
| GET | /v1/sandbox/inboxes/{id}/messages/wait | sandbox:read | Wait for a message. Filters: to, from, subject, search, since; timeout 1 to 30 seconds. |
| POST | /v1/sandbox/inboxes/{id}/messages | sandbox:send | Capture over HTTP: the POST /v1/emails body plus attachments [{filename, content_type, content_base64}]. |
| GET | /v1/sandbox/messages/{id} | sandbox:read | Envelope, headers, attachment list and links to the parts. |
| GET | /v1/sandbox/messages/{id}/raw, /html, /text | sandbox:read | The .eml, the HTML part as sent, the text part. |
| GET | /v1/sandbox/messages/{id}/attachments/{index} | sandbox:read | One attachment, always as a download. |
| PATCH | /v1/sandbox/messages/{id} | sandbox:manage | Body {"read": true} or {"read": false}. |
| DELETE | /v1/sandbox/messages/{id} | sandbox:manage | Delete one message. |
| POST | /v1/sandbox/messages/{id}/forward | sandbox:manage | Body {"to": "qa@example.com"}: forward the message, attached, to a confirmed forward address. |
| GET | /v1/sandbox/forward-addresses | sandbox:manage | The account's forward addresses and whether each is confirmed. |
| POST | /v1/sandbox/forward-addresses | sandbox:manage | Body {"email": "qa@example.com"}: add an address; it gets a link to confirm. Posting an unconfirmed one again sends a new link (at most every 5 minutes). |
| DELETE | /v1/sandbox/forward-addresses/{id} | sandbox:manage | Remove a forward address. |
Forwarding
To look at a captured email in a real mail client, forward it. A forward is a new email from SendPelican's sandbox address with the captured message attached unchanged as original.eml, and it goes only to an address on your forward list whose owner opened the emailed link and confirmed it. Forwards count against the sandbox plan's forwards per month; Sandbox Free has none. An address that bounced or complained before gets nothing.
A forward is sent and logged like any other email, so SendPelican keeps a copy of it, with the whole captured message, in the sending log of the account that sends forwards. That copy is not part of your sandbox: your plan's retention, deleting the message or inbox, and closing your account do not remove it. It is deleted 7 days after the forward. Forward only test mail you are willing to have kept that long.
Wait for an email in a test
GET .../messages/wait answers as soon as a matching message lands, with the same body as GET /v1/sandbox/messages/{id}, or with 404 not_found when timeout passes first. It matches mail that arrived at or after since, which defaults to five seconds before the call, so mail sent just before the call still counts. Each wait holds a server worker, so an account may have 5 waits open at once; one more gets 429 sandbox_wait_limit.
# The app under test sends through the sandbox: MAIL_HOST=smtp.sendpelican.com MAIL_PORT=2525,
# with the inbox's sb_ username and password. Then the test:
API=https://sendpelican.com/v1/sandbox
AUTH="Authorization: Bearer $SANDBOX_API_KEY" # a key with sandbox:read and sandbox:manage
curl -sf -X POST "$APP_URL/signup" -d email=ada@example.com
# Blocks until the email lands (up to 30 s), else 404.
MESSAGE=$(curl -sf -H "$AUTH" "$API/inboxes/$SANDBOX_INBOX_ID/messages/wait?to=ada@example.com&subject=Confirm&timeout=30")
LINK=$(curl -sf -H "$AUTH" "$(jq -r .data.links.html <<<"$MESSAGE")" | grep -o 'https://[^"]*/confirm/[^"]*' | head -n 1)
curl -sf "$LINK"
# Start the next run with an empty inbox.
curl -sf -X POST -H "$AUTH" "$API/inboxes/$SANDBOX_INBOX_ID/clean"
Errors
Errors share one shape. code is stable and safe to branch on; message is for people.
{
"error": {
"code": "domain_not_verified",
"message": "The from domain 'yourdomain.com' is not verified on this account.",
"details": {
"domain": "yourdomain.com"
}
}
}
| Status | Code | Where | Meaning |
|---|---|---|---|
| 422 | validation_failed | all | The request body or query failed validation. details maps each field to its messages. |
| 401 | unauthenticated | all | No API key, or the key was revoked or is wrong. |
| 403 | missing_ability | /v1 | The API key lacks the ability this route needs. details.abilities lists it. |
| 403 | account_suspended | POST /v1/emails, POST /v1/bulk/emails, /internal/sandbox | The account is suspended or closed and cannot send. A suspended account's sandbox still captures; a closed account's does not. |
| 422 | domain_not_verified | POST /v1/emails, POST /v1/bulk/emails | The from address is not on a verified domain of this account. |
| 429 | quota_exceeded | POST /v1/emails, POST /v1/bulk/emails | The request would pass the daily or monthly limit of the plan. Nothing was sent or reserved. |
| 429 | warmup_limit | POST /v1/emails, POST /v1/bulk/emails | The from domain is warming up and the request would pass its daily warm-up cap. Nothing was sent or reserved. details has domain, stage, daily_cap, sent_today and resets_at. |
| 429 | rate_limited | all | Too many requests for this API key this minute (retry after the Retry-After header), or a forward address's confirmation link was sent less than 5 minutes ago (details.retry_after). |
| 403 | domain_limit_reached | POST /v1/domains | The plan allows no more domains. |
| 422 | domain_owned_elsewhere | POST /v1/domains/{id}/verify | Another account has already verified this domain name. |
| 403 | plan_upgrade_required | POST /v1/webhooks, POST /v1/bulk/emails | The plan does not include this feature: webhooks, or bulk sending (Free plan). Over SMTP the server replies 550 5.7.1. |
| 403 | bulk_paused | POST /v1/bulk/emails | Bulk sending is paused for the account because its bulk bounce or complaint rate passed the bulk limits. Transactional email still sends. Support resumes bulk after review. Over SMTP the server replies 554 5.7.1. |
| 422 | bulk_requires_postal_address | POST /v1/bulk/emails | Bulk email must carry the sender's company name and postal address. Add both under Settings, Account. Over SMTP the server replies 550 5.7.1. |
| 422 | bulk_not_allowed_for_domain | POST /v1/bulk/emails | The from domain does not allow bulk email. Turn on "Allow bulk" in the domain's settings, ideally on a subdomain used only for bulk. Over SMTP the server replies 550 5.7.1. |
| 403 | sandbox_inbox_limit_reached | POST /v1/sandbox/inboxes, /app sandbox inboxes page | The sandbox plan allows no more inboxes. Delete one or upgrade the Email Sandbox plan. details.max_inboxes is the limit. |
| 404 | not_found | all | No such resource on this account. |
| 405 | method_not_allowed | all | The route exists but not with this HTTP method. |
| 413 | payload_too_large | all | The server refused the request body as too large. |
| 400 | bad_request | all | The request could not be read, for example malformed JSON. |
| 403 | forbidden | all | The request is not allowed. |
| 4xx/5xx | http_error | all | Any other HTTP error. The status code says which. |
| 500 | server_error | all | Unexpected server error. Safe to retry with the same Idempotency-Key. |
| 401 | invalid_credentials | POST /auth/login | Email or password is incorrect. |
| 419 | csrf_token_mismatch | /auth, /dashboard | The session CSRF token is missing or stale. Call GET /sanctum/csrf-cookie again. |
| 409 | already_subscribed | POST /dashboard/billing/checkout | The account already has a subscription. Switch plans on the Billing page. |
| 409 | payment_incomplete | POST /dashboard/billing/checkout | A subscription is waiting for its first payment. |
| 409 | billing_customer_missing | /dashboard/billing | The account's Stripe customer no longer exists but the account has subscriptions on record. Contact support. |
| 503 | billing_unavailable | /dashboard/billing | Stripe returned an error. Nothing changed. Retry later. |
| 422 | plan_not_purchasable | POST /dashboard/billing/checkout | That plan cannot be bought through checkout. |
| 422 | plan_not_switchable | /app billing page | The plan is the current one, not public, or has no Stripe price yet. |
| 409 | not_subscribed | /app billing page | The account has no subscription to switch, keep or cancel. |
| 409 | subscription_not_active | /app billing page | The subscription is past due or incomplete. Fix the payment in the billing portal before switching plans. |
| 409 | subscription_cancelling | /app billing page | The subscription cancels at the end of the period. Renew it in the billing portal before switching plans. |
| 409 | upgrade_awaiting_payment | /app billing page | An upgrade is waiting for its invoice to be paid. Pay it from the Billing page, or wait until it lapses, before switching again. |
| 409 | quote_expired | /app billing page | The price quote in the switch dialog is more than 15 minutes old. Open the switch again. |
| 413 | message_too_large | POST /v1/sandbox/inboxes/{id}/messages, /internal/smtp, /internal/sandbox | The SMTP message is over 10 MB, or over the sandbox plan's message size. The SMTP server replies 552. |
| 503 | sandbox_forward_unavailable | /v1/sandbox/forward-addresses, POST /v1/sandbox/messages/{id}/forward | Forwarding is not set up on this server, or the account that sends forwards cannot send right now. Nothing was sent or counted; try again later. |
| 403 | sandbox_forward_not_included | /v1/sandbox/forward-addresses, POST /v1/sandbox/messages/{id}/forward | The sandbox plan does not include forwarding (Sandbox Free). Upgrade the Email Sandbox plan. |
| 403 | sandbox_forward_address_limit | POST /v1/sandbox/forward-addresses | The account has the most forward addresses allowed (10). Remove one first. |
| 422 | forward_address_unverified | POST /v1/sandbox/messages/{id}/forward | The to address is not a forward address of the account, or its emailed link was not confirmed yet. Nothing was sent. |
| 429 | sandbox_forward_limit | POST /v1/sandbox/messages/{id}/forward | The sandbox plan's forwards this month are used up. Nothing was sent. |
| 429 | sandbox_wait_limit | GET /v1/sandbox/inboxes/{id}/messages/wait | Too many waits are open for the account (5) or on the server. Each wait holds a server worker until it returns; retry when one of yours has returned. |
| 429 | sandbox_quota_exceeded | POST /v1/sandbox/inboxes/{id}/messages, /internal/sandbox | The sandbox plan's emails per month are used up. Nothing was stored. The SMTP server replies 452 4.2.2. |
| 429 | sandbox_storage_full | POST /v1/sandbox/inboxes/{id}/messages, /internal/sandbox | The sandbox plan's storage is full. Nothing was stored. Delete messages or inboxes, or upgrade. The SMTP server replies 452 4.2.2. |
| 422 | sender_not_allowed | /internal/smtp | The SMTP message names its sender in a way SendPelican refuses: two From headers, a From with no address, several addresses or a group, or a Sender, Resent-From or Resent-Sender header. The SMTP server replies 550 5.7.1. |
| 503 | smtp_internal_disabled | /internal/smtp | MAILFLOW_SMTP_INTERNAL_SECRET is not set, so the SMTP server cannot reach Laravel. |
Idempotency
Send an Idempotency-Key header (1 to 100 characters) with POST /v1/emails so a retry after a timeout cannot send twice. A repeat with the same key returns the original messages with 200 OK and sends nothing. Use a new key for each distinct email, for example your order ID plus the email type.
Idempotency-Key: order-123-receipt
Rate limits and quotas
Each API key may make 600 requests a minute. Past that, requests return 429 rate_limited with a Retry-After header.
Your plan sets a monthly quota and, on the free plan, a daily limit. A send reserves one unit per recipient that is not suppressed, plus one per message when the domain has an auto BCC address. If either window would overflow, the whole request returns 429 quota_exceeded and nothing is sent. A newly verified domain also has a daily warm-up cap. GET /v1/usage shows what is left.
Domain warm-up
Mailbox providers such as Gmail and Outlook distrust a new domain that suddenly sends thousands of emails. SendPelican also sends every customer's mail through shared infrastructure, so one domain's bounces and complaints affect everyone. A newly verified domain therefore starts with a daily cap that grows as it proves it sends wanted mail. The cap counts the same way as your quota: suppressed recipients are free, and each auto BCC copy counts.
| Stage | Emails a day from the domain |
|---|---|
| 1 | 200 |
| 2 | 500 |
| 3 | 1,000 |
| 4 | 2,500 |
| 5 | 5,000 |
| 6 | 10,000 |
| 7 | 25,000 |
| 8 | 50,000 |
| After stage 8 | Plan limits only |
Every day at 01:00 UTC SendPelican looks at the domain's sends from the previous day. A day counts only when the domain sent at least half of that day's cap. Then:
- Bounces under 2% and complaints under 0.05%: the domain moves up one stage. After the last stage, warm-up ends.
- Bounces above 2.5% or complaints above 0.05%: the domain moves back one stage.
- Anything in between, or too few sends: the domain stays where it is.
A request that would pass today's cap returns 429 warmup_limit, and nothing in it is sent or counted. Over SMTP the reply is 452 4.2.2, a temporary error, so SMTP clients queue the message and retry. Send the rest after resets_at, or spread sending through the day. GET /v1/domains/{id} shows the domain's warmup status, stage, cap and sends today, and the domain page in the dashboard shows the same.
{
"error": {
"code": "warmup_limit",
"message": "'yourdomain.com' is warming up and has reached today's limit of 200 emails.",
"details": {
"domain": "yourdomain.com",
"stage": 1,
"daily_cap": 200,
"sent_today": 199,
"resets_at": "2026-10-15T00:00:00+00:00"
}
}
}
Moving a domain that already sends with a good reputation from another provider? Contact support and we can skip warm-up for it.
Tracking and unsubscribe
Each domain has open and click tracking defaults, both on until you change them on the domain's Tracking Settings tab. track_opens and track_clicks in a request override the default for that request.
With the unsubscribe link turned on for a domain, SendPelican appends a footer to the HTML and text bodies and adds List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers (RFC 8058). An unsubscribe suppresses the address for your account and sends an email.unsubscribed event. A List-Unsubscribe header you send yourself is kept as is.
Bulk email always carries the footer and headers, whatever the domain setting, and an unsubscribe from it stops bulk email only. See Bulk email.
Webhooks
Add endpoints in the dashboard or with POST /v1/webhooks. Endpoints belong to the account and receive events from all its domains. SendPelican sends a POST with a JSON body for each event an endpoint subscribes to:
{
"id": "evt_01k7a9x3q2m8v5r4t6y0w1z2b3",
"type": "email.bounced",
"created_at": "2026-10-14T09:30:00Z",
"data": {
"message_id": "01k7a9w8e5n3c2b1x0z9y8v7u6",
"to": "ada@example.com",
"stream": "transactional",
"tags": [
"receipt"
],
"metadata": {
"order_id": "123"
},
"category": "Receipt",
"bounce": {
"type": "Permanent"
}
}
}
The id stays the same across retries, so use it to drop duplicates. Answer with any 2xx status. A failed delivery is retried after 30 s, 2 min, 10 min, 30 min, 2 h, 6 h and 12 h, up to 8 attempts. An endpoint is turned off after 50 failures in a row.
Verify the signature
Each request has a Mailflow-Signature: t=<unix seconds>,v1=<hex> header. v1 is the HMAC-SHA256 of <t>.<raw request body>, keyed with the endpoint secret (whsec_...) shown when you create the endpoint. Compute it over the raw bytes, before any JSON parsing, compare in constant time, and reject old timestamps.
function mailflowSignatureIsValid(string $header, string $rawBody, string $secret, int $toleranceSeconds = 300): bool
{
if (! preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) {
return false;
}
$expected = hash_hmac('sha256', $m[1].'.'.$rawBody, $secret);
return hash_equals($expected, $m[2]) && abs(time() - (int) $m[1]) <= $toleranceSeconds;
}
// In a controller: mailflowSignatureIsValid($request->header('Mailflow-Signature'), $request->getContent(), $secret)
Event types
| Webhook type | Subscribe as | Meaning |
|---|---|---|
| email.queued | queued | Accepted by the API and waiting for a worker. |
| email.sent | sent | Handed to the sending provider. |
| email.delivered | delivered | The recipient's mail server accepted the message. |
| email.bounced | bounced | The message bounced. A permanent bounce suppresses the address. |
| email.complained | complained | The recipient marked the message as spam. The address is suppressed. |
| email.opened | opened | First open of the message (open tracking on). |
| email.clicked | clicked | First click on a link (click tracking on), once per link. |
| email.failed | failed | SendPelican could not send the message. data.error says why. |
| email.suppressed | suppressed | Not sent, because the address is on the suppression list. |
| email.unsubscribed | unsubscribed | The recipient used the unsubscribe link or one-click header. The address is suppressed. |