Skip to content

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.

AbilityAllows
emails:sendPOST /v1/emails
bulk:sendPOST /v1/bulk/emails
emails:readGET /v1/emails and GET /v1/emails/{id}
domains:manage/v1/domains
suppressions:manage/v1/suppressions
webhooks:manage/v1/webhooks
sandbox:readReading the Email Sandbox: inboxes, messages, wait
sandbox:manageCreating, cleaning and deleting sandbox inboxes and messages
sandbox:sendPOST /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

FieldTypeRequiredNotes
from.emailstringYesSender address. Its domain must be verified on your account.
from.namestringNoDisplay name.
toarray of stringsYes1 to 50 recipients. Each gets its own message, status and events.
subjectstringYesUp to 998 characters.
htmlstringOne of html, textHTML body.
textstringOne of html, textPlain-text body.
reply_tostringNoReply-To address.
headersobjectNoExtra headers, for example {"X-Campaign": "receipt"}. Names SendPelican sets itself (From, To, Bcc, Subject, Message-Id and similar) are refused.
tagsarray of stringsNoUp to 20 labels, returned in events and webhooks.
metadataobjectNoYour own key-value data, returned in events and webhooks.
track_opensbooleanNoOverrides the domain default for this request.
track_clicksbooleanNoOverrides the domain default for this request.
categorystringNoOne 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.

Hostsmtp.sendpelican.com
Ports
465 TLS (implicit, SMTPS)
587 STARTTLS
AuthenticationAUTH PLAIN or AUTH LOGIN, only after TLS is up. A plain-text connection cannot log in.
Limits10 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.

ReplyTextAPI error codeMeaning
535 5.7.8Authentication credentials invalidinvalid_credentials (401)The SMTP username or password is wrong, or the credential was revoked during the session.
554 5.7.1Account suspended; sending is disabledaccount_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.1Sender domain is not verified on this accountdomain_not_verified (422)The from address is not on a verified domain of this account.
550 5.7.1Sender headers not allowedsender_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.2Sending quota exceeded; try again laterquota_exceeded (429)The request would pass the daily or monthly limit of the plan. Nothing was sent or reserved.
452 4.2.2Domain warm-up limit reached for today; try again laterwarmup_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.4Message too largemessage_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.0Message rejectedvalidation_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.1Plan does not include this streamplan_upgrade_required (403)The credential is a bulk credential and the plan no longer includes bulk sending.
554 5.7.1Bulk sending is paused for this accountbulk_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.1Bulk email needs the account's company name and postal addressbulk_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.1Sender domain does not allow bulk emailbulk_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.2Sandbox email quota exceeded; try again next periodsandbox_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.2Sandbox storage is full; delete messages or upgradesandbox_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.0Temporary server error; try again laterNoneSendPelican could not accept the message for any other reason. Retry; nothing was sent.
454 4.7.0Temporary authentication failure; try again laterNoneThe login could not be checked. Retry.
454 4.7.0Too many failed logins; try again laterNoneThis username had too many wrong passwords in a row.
530 5.7.0Authentication requiredNoneMAIL, RCPT or DATA was sent before AUTH.
454 4.7.0Too many failed logins from this address; try again laterNoneLogins from this IP address failed too often. One more attempt is allowed every few seconds (MAILFLOW_SMTP_AUTH_IP_REFILL).
421 4.7.0Too many failed logins; closing connectionNoneThis connection had too many refused logins. The server closes it.
535 5.7.8This credential belongs to the other host (sending or sandbox)NoneA 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.

FieldTypeRequiredNotes
recipientsarray of objectsYes1 to 500 recipients, in place of to. Each gets its own message, status and events. Addresses must differ, ignoring case.
recipients[].emailstringYesThe recipient address.
recipients[].variablesobjectNoUp 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, textstringAs /v1/emailsMay contain {{ name }} placeholders, filled per recipient.
from, reply_to, headers, tags, metadata, track_opens, track_clicks, categoryAs /v1/emailsThe 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-Key covers 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 as news.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.

MethodPathAbilityDoes
GET/v1/sandbox/inboxessandbox:readYour inboxes, and the sandbox plan's usage this period.
POST/v1/sandbox/inboxessandbox:manageCreate an inbox, body {"name": "Staging"}. The response holds the SMTP password, once.
DELETE/v1/sandbox/inboxes/{id}sandbox:manageDelete the inbox and every message in it.
POST/v1/sandbox/inboxes/{id}/cleansandbox:manageDelete every message, keep the inbox and its login.
GET/v1/sandbox/inboxes/{id}/messagessandbox:readNewest 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/waitsandbox:readWait for a message. Filters: to, from, subject, search, since; timeout 1 to 30 seconds.
POST/v1/sandbox/inboxes/{id}/messagessandbox:sendCapture over HTTP: the POST /v1/emails body plus attachments [{filename, content_type, content_base64}].
GET/v1/sandbox/messages/{id}sandbox:readEnvelope, headers, attachment list and links to the parts.
GET/v1/sandbox/messages/{id}/raw, /html, /textsandbox:readThe .eml, the HTML part as sent, the text part.
GET/v1/sandbox/messages/{id}/attachments/{index}sandbox:readOne attachment, always as a download.
PATCH/v1/sandbox/messages/{id}sandbox:manageBody {"read": true} or {"read": false}.
DELETE/v1/sandbox/messages/{id}sandbox:manageDelete one message.
POST/v1/sandbox/messages/{id}/forwardsandbox:manageBody {"to": "qa@example.com"}: forward the message, attached, to a confirmed forward address.
GET/v1/sandbox/forward-addressessandbox:manageThe account's forward addresses and whether each is confirmed.
POST/v1/sandbox/forward-addressessandbox:manageBody {"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:manageRemove 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"
        }
    }
}
StatusCodeWhereMeaning
422validation_failedallThe request body or query failed validation. details maps each field to its messages.
401unauthenticatedallNo API key, or the key was revoked or is wrong.
403missing_ability/v1The API key lacks the ability this route needs. details.abilities lists it.
403account_suspendedPOST /v1/emails, POST /v1/bulk/emails, /internal/sandboxThe account is suspended or closed and cannot send. A suspended account's sandbox still captures; a closed account's does not.
422domain_not_verifiedPOST /v1/emails, POST /v1/bulk/emailsThe from address is not on a verified domain of this account.
429quota_exceededPOST /v1/emails, POST /v1/bulk/emailsThe request would pass the daily or monthly limit of the plan. Nothing was sent or reserved.
429warmup_limitPOST /v1/emails, POST /v1/bulk/emailsThe 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.
429rate_limitedallToo 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).
403domain_limit_reachedPOST /v1/domainsThe plan allows no more domains.
422domain_owned_elsewherePOST /v1/domains/{id}/verifyAnother account has already verified this domain name.
403plan_upgrade_requiredPOST /v1/webhooks, POST /v1/bulk/emailsThe plan does not include this feature: webhooks, or bulk sending (Free plan). Over SMTP the server replies 550 5.7.1.
403bulk_pausedPOST /v1/bulk/emailsBulk 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.
422bulk_requires_postal_addressPOST /v1/bulk/emailsBulk 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.
422bulk_not_allowed_for_domainPOST /v1/bulk/emailsThe 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.
403sandbox_inbox_limit_reachedPOST /v1/sandbox/inboxes, /app sandbox inboxes pageThe sandbox plan allows no more inboxes. Delete one or upgrade the Email Sandbox plan. details.max_inboxes is the limit.
404not_foundallNo such resource on this account.
405method_not_allowedallThe route exists but not with this HTTP method.
413payload_too_largeallThe server refused the request body as too large.
400bad_requestallThe request could not be read, for example malformed JSON.
403forbiddenallThe request is not allowed.
4xx/5xxhttp_errorallAny other HTTP error. The status code says which.
500server_errorallUnexpected server error. Safe to retry with the same Idempotency-Key.
401invalid_credentialsPOST /auth/loginEmail or password is incorrect.
419csrf_token_mismatch/auth, /dashboardThe session CSRF token is missing or stale. Call GET /sanctum/csrf-cookie again.
409already_subscribedPOST /dashboard/billing/checkoutThe account already has a subscription. Switch plans on the Billing page.
409payment_incompletePOST /dashboard/billing/checkoutA subscription is waiting for its first payment.
409billing_customer_missing/dashboard/billingThe account's Stripe customer no longer exists but the account has subscriptions on record. Contact support.
503billing_unavailable/dashboard/billingStripe returned an error. Nothing changed. Retry later.
422plan_not_purchasablePOST /dashboard/billing/checkoutThat plan cannot be bought through checkout.
422plan_not_switchable/app billing pageThe plan is the current one, not public, or has no Stripe price yet.
409not_subscribed/app billing pageThe account has no subscription to switch, keep or cancel.
409subscription_not_active/app billing pageThe subscription is past due or incomplete. Fix the payment in the billing portal before switching plans.
409subscription_cancelling/app billing pageThe subscription cancels at the end of the period. Renew it in the billing portal before switching plans.
409upgrade_awaiting_payment/app billing pageAn upgrade is waiting for its invoice to be paid. Pay it from the Billing page, or wait until it lapses, before switching again.
409quote_expired/app billing pageThe price quote in the switch dialog is more than 15 minutes old. Open the switch again.
413message_too_largePOST /v1/sandbox/inboxes/{id}/messages, /internal/smtp, /internal/sandboxThe SMTP message is over 10 MB, or over the sandbox plan's message size. The SMTP server replies 552.
503sandbox_forward_unavailable/v1/sandbox/forward-addresses, POST /v1/sandbox/messages/{id}/forwardForwarding 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.
403sandbox_forward_not_included/v1/sandbox/forward-addresses, POST /v1/sandbox/messages/{id}/forwardThe sandbox plan does not include forwarding (Sandbox Free). Upgrade the Email Sandbox plan.
403sandbox_forward_address_limitPOST /v1/sandbox/forward-addressesThe account has the most forward addresses allowed (10). Remove one first.
422forward_address_unverifiedPOST /v1/sandbox/messages/{id}/forwardThe to address is not a forward address of the account, or its emailed link was not confirmed yet. Nothing was sent.
429sandbox_forward_limitPOST /v1/sandbox/messages/{id}/forwardThe sandbox plan's forwards this month are used up. Nothing was sent.
429sandbox_wait_limitGET /v1/sandbox/inboxes/{id}/messages/waitToo 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.
429sandbox_quota_exceededPOST /v1/sandbox/inboxes/{id}/messages, /internal/sandboxThe sandbox plan's emails per month are used up. Nothing was stored. The SMTP server replies 452 4.2.2.
429sandbox_storage_fullPOST /v1/sandbox/inboxes/{id}/messages, /internal/sandboxThe sandbox plan's storage is full. Nothing was stored. Delete messages or inboxes, or upgrade. The SMTP server replies 452 4.2.2.
422sender_not_allowed/internal/smtpThe 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.
503smtp_internal_disabled/internal/smtpMAILFLOW_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.

StageEmails a day from the domain
1200
2500
31,000
42,500
55,000
610,000
725,000
850,000
After stage 8Plan 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 typeSubscribe asMeaning
email.queuedqueuedAccepted by the API and waiting for a worker.
email.sentsentHanded to the sending provider.
email.delivereddeliveredThe recipient's mail server accepted the message.
email.bouncedbouncedThe message bounced. A permanent bounce suppresses the address.
email.complainedcomplainedThe recipient marked the message as spam. The address is suppressed.
email.openedopenedFirst open of the message (open tracking on).
email.clickedclickedFirst click on a link (click tracking on), once per link.
email.failedfailedSendPelican could not send the message. data.error says why.
email.suppressedsuppressedNot sent, because the address is on the suppression list.
email.unsubscribedunsubscribedThe recipient used the unsubscribe link or one-click header. The address is suppressed.