PushMail.dev

Sending

PushMail sends emails via 10 supported providers. Use our managed infrastructure or bring your own API key for full control over your sending domain and reputation.

How sending works

API request or cron trigger

  ├── 1. Validate contact (subscribed + not suppressed)
  ├── 2. Check org has sufficient credits
  ├── 3. Render template with contact variables
  ├── 4. Store rendered HTML in R2 (for log viewing)
  ├── 5. Create send record (status: queued)
  ├── 6. Add to email queue

  Queue consumer picks up:

  ├── 7. Resolve from address (per-request fromEmail or sending config)
  ├── 8. Send via provider API (SendGrid, Postmark, SES, etc.)
  ├── 9. Deduct credits from org balance
  └── 10. Update monthly usage record

From address resolution

For transactional sends (POST /v1/send and POST /v1/send/batch), you can pass fromEmail and fromName directly in the request. This is the simplest way to send — no sending config required.

For sequences and campaigns, a sendingConfigId is required because these are automated sends with no per-request context.

Priority order:

  1. fromEmail / fromName from the API request (transactional only)
  2. Sending config specified by sendingConfigId
  3. If neither is provided, the send will fail

Specifying the sender

The sender address field is called fromEmail (not from). You have two options for specifying who the email is sent from:

  • fromEmail and optionally fromName — pass the sender address directly in the request. This uses the managed SendGrid infrastructure. Simplest option for getting started.
  • sendingConfigId — reference a pre-configured sending config that includes the provider, credentials, and from address. Required for BYOK providers.

You must provide one of fromEmail or sendingConfigId. If both are provided, fromEmail takes priority for transactional sends. For sequences and campaigns, only sendingConfigId is supported.

Auto BCC

You can automatically BCC an email address on every send from a site or sequence. This is useful for CRM integration, compliance archiving, or internal monitoring.

  • Site-level — set autoBcc on a site to BCC all emails (transactional, campaign, and sequence) sent from that site.
  • Sequence-level — set autoBcc on a sequence to override the site-level BCC for emails in that sequence.
Set auto BCC on a site
curl -X PUT https://pushmail.dev/api/v1/sites/1 \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"autoBcc": "archive@yourcompany.com"}'
Set auto BCC on a sequence (overrides site)
curl -X PUT https://pushmail.dev/api/v1/sequences/1 \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"autoBcc": "sales-team@yourcompany.com"}'

Set autoBcc to null to clear it. The BCC is applied at send time by the queue consumer and works with all 10 supported providers.

Single send

Send a transactional email with POST /v1/send. Pass fromEmail directly — no sending config setup needed.

Send a single email
curl -X POST https://pushmail.dev/api/v1/send \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "siteId": 1,
    "fromEmail": "hello@yourdomain.com",
    "fromName": "Your Company",
    "subject": "Welcome!",
    "html": "<h1>Hey {{first_name}}!</h1><p>Welcome aboard.</p>",
    "variables": { "first_name": "Alice" }
  }'

Request parameters

ParameterTypeRequiredDescription
tostringYesRecipient email address
siteIdintegerYesThe site to send from
fromEmailstringOne of fromEmail or sendingConfigIdSender email address
fromNamestringNoSender display name
sendingConfigIdintegerOne of fromEmail or sendingConfigIdSending config to use (required for BYOK)
templateIdintegerOne of templateId or html+subjectTemplate to render
htmlstringOne of templateId or html+subjectInline HTML body
subjectstringRequired with htmlSubject line
variablesobjectNoTemplate variable substitution
contactIdintegerNoExisting contact ID (auto-created if omitted)
scheduledAtstringNoISO 8601 datetime for scheduled delivery
securebooleanNoSend as encrypted secure message

Batch sending

Send up to 1,000 emails in a single API call using POST /v1/send/batch. Each recipient gets individually tracked sends with per-recipient variable substitution. This dramatically reduces latency and API call overhead for high-volume transactional use cases like order confirmations, OTPs, and notifications.

Send a batch of emails
curl -X POST https://pushmail.dev/api/v1/send/batch \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "siteId": 1,
    "templateId": 5,
    "fromEmail": "hello@yourdomain.com",
    "fromName": "Your Company",
    "recipients": [
      {
        "to": "alice@example.com",
        "variables": { "first_name": "Alice", "order_id": "ORD-001" }
      },
      {
        "to": "bob@example.com",
        "variables": { "first_name": "Bob", "order_id": "ORD-002" },
        "tags": ["vip"]
      },
      {
        "to": "carol@example.com",
        "variables": { "first_name": "Carol", "order_id": "ORD-003" }
      }
    ]
  }'
Response (202)
{
  "data": {
    "queued": 3,
    "rejected": 0,
    "estimatedCostCents": 1,
    "sends": [
      { "to": "alice@example.com", "sendId": 101, "status": "queued" },
      { "to": "bob@example.com", "sendId": 102, "status": "queued" },
      { "to": "carol@example.com", "sendId": 103, "status": "queued" }
    ]
  }
}

You can also use inline HTML instead of a template:

Batch with inline HTML
curl -X POST https://pushmail.dev/api/v1/send/batch \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "siteId": 1,
    "subject": "Your order confirmation",
    "html": "<h1>Thanks {{first_name}}!</h1><p>Order {{order_id}} confirmed.</p>",
    "recipients": [
      { "to": "alice@example.com", "variables": { "first_name": "Alice", "order_id": "ORD-001" } },
      { "to": "bob@example.com", "variables": { "first_name": "Bob", "order_id": "ORD-002" } }
    ]
  }'

Request parameters

ParameterTypeRequiredDescription
siteIdintegerYesThe site to send from
templateIdintegerOne of templateId or html+subjectTemplate to render for each recipient
htmlstringOne of templateId or html+subjectInline HTML body (supports {{variable}} substitution)
subjectstringRequired with htmlSubject line (required when using inline HTML)
fromEmailstringOne of fromEmail or sendingConfigIdSender email address (e.g. hello@yourdomain.com)
fromNamestringNoSender display name (e.g. Your Company)
sendingConfigIdintegerOne of fromEmail or sendingConfigIdSending config to use (required for BYOK providers)
recipientsarrayYesArray of recipient objects (1--1,000 items)
recipients[].tostringYesRecipient email address
recipients[].variablesobjectNoKey-value pairs for template variable substitution
recipients[].tagsstring[]NoTags to apply to this contact

Batch behavior

  • Credit check: Credits are verified upfront for the entire batch before any sends are queued. If your balance is insufficient, the entire batch is rejected with a 402 status.
  • Partial success: If some recipients fail validation (bad email, suppressed, duplicate), the valid recipients are still queued. The response includes per-recipient status.
  • Deduplication: Duplicate emails within a single batch are automatically rejected (case-insensitive).
  • Suppression: Each recipient is checked against the global bounce/complaint suppression list.
  • Tags: Per-recipient tags are created and applied to contacts automatically.
  • Tracking: Each recipient gets an individual send record with its own sendId for event tracking.
  • Contact creation: If a recipient email does not have an existing contact record, one is created automatically with source transactional.

Batch error responses

StatusConditionExample
400Invalid request body, missing required fields, empty recipients, or over 1,000 recipients{ "error": "At least one recipient is required" }
401Missing or invalid API key / session{ "error": "Unauthorized" }
402Insufficient credits for the batch{ "error": "Insufficient credits. This batch requires approximately 30 cents but your balance is 5 cents." }
404Site or template not found, or not owned by your org{ "error": "Site not found" }

When some recipients are rejected but others succeed, the endpoint returns 202 with per-recipient status:

Partial success response (202)
{
  "data": {
    "queued": 2,
    "rejected": 1,
    "estimatedCostCents": 1,
    "sends": [
      { "to": "alice@example.com", "sendId": 101, "status": "queued" },
      { "to": "bad@invalid.test", "sendId": null, "status": "rejected", "reason": "Domain has no mail server" },
      { "to": "bob@example.com", "sendId": 102, "status": "queued" }
    ]
  }
}

When all recipients are rejected, the endpoint returns 200 with zero queued:

All rejected response (200)
{
  "data": {
    "queued": 0,
    "rejected": 3,
    "sends": [
      { "to": "dup@example.com", "sendId": null, "status": "rejected", "reason": "Duplicate recipient in batch" },
      { "to": "dup@example.com", "sendId": null, "status": "rejected", "reason": "Duplicate recipient in batch" },
      { "to": "bounced@example.com", "sendId": null, "status": "rejected", "reason": "Previously bounced" }
    ]
  }
}

Batch limits

LimitValue
Max recipients per batch1,000
Max request body size10 MB

Scheduled sends

Schedule a transactional email to be sent at a future time by passing a scheduledAt ISO 8601 datetime to POST /v1/send. The cron worker processes scheduled sends every minute.

Schedule an email for later
curl -X POST https://pushmail.dev/api/v1/send \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "siteId": 1,
    "templateId": 5,
    "variables": { "first_name": "Alice" },
    "scheduledAt": "2026-03-01T09:00:00Z"
  }'
Response (202)
{
  "data": {
    "sendId": 42,
    "status": "scheduled",
    "scheduledAt": "2026-03-01T09:00:00Z"
  }
}

Status flow: scheduledqueuedsentdelivered

If scheduledAt is omitted, the email is queued immediately as before.

Managed vs BYOK

Managed (default)BYOK (Bring Your Own Key)
ProviderPushMail's managed SendGrid accountYour own account with any supported provider
From domainSent via PushMail domainYour verified domain
ReputationShared IP reputationYour own IP/domain reputation
SetupNone — works immediatelyProvide API key + verify domain
CostPushMail per-email pricing onlyPushMail pricing + your provider costs
Best forGetting started quicklyProduction apps needing branded sending

Multi-provider support

BYOK sending configs support 10 email providers. Use whichever provider you already have an account with -- or create configs for multiple providers and choose per-campaign, per-sequence, or per-send.

ProviderID
SendGridsendgrid
Amazon SESses
Postmarkpostmark
Mailgunmailgun
SparkPostsparkpost
Brevo (Sendinblue)brevo
Mailjetmailjet
Mandrill (Mailchimp Transactional)mandrill
Resendresend
Elastic Emailelastic-email

See Providers for per-provider setup details, required config fields, and webhook configuration.

Set up BYOK sending

Add your provider API key and configure a from address. PushMail will use your key for all sends from your organization.

1. Create a sending config
curl -X POST https://pushmail.dev/api/v1/sending-configs \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "sendgrid",
    "apiKey": "SG.your-sendgrid-api-key",
    "fromEmail": "hello@yourdomain.com",
    "fromName": "Your Company"
  }'

The provider field accepts any of the 10 supported provider IDs listed above. Each provider requires different config fields -- use GET /v1/providers to see the required fields for each.

2. Verify the config
curl -X POST https://pushmail.dev/api/v1/sending-configs/1/verify \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "you@yourdomain.com" }'

# Sends a test email to the address you specify

Pricing

Pay per email with volume discounts. Every new account starts with $5.00 in free credits.

Monthly volumePer email$10 gets you
0 — 10,000$0.003 (0.3¢)~3,333 emails
10,001 — 100,000$0.002 (0.2¢)~5,000 emails
100,001 — 1,000,000$0.001 (0.1¢)~10,000 emails
1,000,001+$0.0005 (0.05¢)~20,000 emails

Credits are deducted at the moment of send. Cost per email depends on your total sends that month — as volume goes up, cost per email goes down.

Branding discount — save 15%

Enable the "Sent with PushMail" footer and get 15% off every email. The footer adds a small, tasteful link at the bottom of your emails:

Sent with PushMail

Toggle it on in Settings > Branding Discount or via the API:

Enable branding discount
curl -X PUT https://pushmail.dev/api/v1/org/branding \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'
Check branding status
curl https://pushmail.dev/api/v1/org/branding \
  -H "Authorization: Bearer pm_live_YOUR_KEY"

The discount is applied automatically at send time. You can disable it anytime — no commitment, no lock-in.

Template variables

Templates support variable substitution with {{variable_name}} syntax. Variables are replaced at send time with contact data.

VariableValue
{{email}}Contact's email address
{{first_name}}Contact's first name
{{last_name}}Contact's last name
{{full_name}}First + last name combined
{{custom_field_name}}Any custom field set on the contact
Template example
<h1>Hey {{first_name}},</h1>
<p>Welcome to {{company}}! Here are your next steps...</p>

Unmatched variables are removed from the output. All values are HTML-escaped to prevent injection.

Conditional content

Show or hide sections of your email based on contact attributes using {{#if}} blocks. Conditionals are processed before variable substitution, so you can use {{variable}} tags inside conditional blocks.

Basic if block

Show content only when a variable exists and is truthy (not empty, not "false", not "0").

Simple conditional
{{#if first_name}}
  <h1>Hi {{first_name}}!</h1>
{{/if}}

If/else block

Provide fallback content when the condition is false.

With fallback
{{#if first_name}}
  <h1>Hi {{first_name}}!</h1>
{{#else}}
  <h1>Hi there!</h1>
{{/if}}

Comparison operators

Compare a variable against a specific value using an operator.

Operator example
{{#if plan_type equals "pro"}}
  <p>Thanks for being a Pro member!</p>
{{/if}}

{{#if purchase_count gt "5"}}
  <p>You're one of our top customers!</p>
{{/if}}

Supported operators:

OperatorDescriptionExample
equalsExact string match{{#if plan_type equals "pro"}}
not_equalsString does not match{{#if status not_equals "inactive"}}
containsString contains substring{{#if email contains "gmail"}}
gtNumeric greater than{{#if purchase_count gt "5"}}
ltNumeric less than{{#if age lt "18"}}
existsVariable is defined (even if empty){{#if phone exists}}
not_existsVariable is not defined{{#if phone not_exists}}

Values must be wrapped in double or single quotes: "value" or 'value'. The gt and lt operators parse both sides as numbers.

Nested conditionals

You can nest conditional blocks at least 2 levels deep.

Nested example
{{#if first_name}}
  Hi {{first_name}}
  {{#if plan_type equals "pro"}}
    (Pro member)
  {{#else}}
    (Free tier)
  {{/if}}
  !
{{#else}}
  Hello!
{{/if}}

Truthiness rules

A variable is considered falsy (and the {{#else}} branch is used) when:

  • The variable does not exist in the contact data
  • The variable is an empty string ""
  • The variable is the string "false"
  • The variable is the string "0"

Everything else is truthy.

Full example

Personalized email with conditionals
{{#if first_name}}
  <h1>Hey {{first_name}},</h1>
{{#else}}
  <h1>Hey there,</h1>
{{/if}}

<p>Welcome to {{company}}!</p>

{{#if purchase_count gt "5"}}
  <p>You're one of our top customers! Here's an exclusive offer.</p>
{{/if}}

{{#if referral_code}}
  <p>Share your referral code: {{referral_code}}</p>
{{/if}}

{{#if email contains "gmail"}}
  <p>Pro tip: Add us to your Primary tab so you never miss an update.</p>
{{/if}}

Conditional blocks work in both HTML and plain text templates. The conditional tags themselves ({{#if}}, {{#else}}, {{/if}}) are completely removed from the output.

View sent emails

Every sent email's rendered HTML is stored in R2. You can retrieve it to see exactly what the recipient saw.

List recent sends
curl "https://pushmail.dev/api/v1/sends?siteId=1&page=1&limit=20" \
  -H "Authorization: Bearer pm_live_YOUR_KEY"

CAN-SPAM compliance

PushMail automatically handles CAN-SPAM compliance on every send. When enabled (on by default):

  • List-Unsubscribe header — RFC 8058 one-click unsubscribe, shown as "Unsubscribe" button in Gmail, Apple Mail, etc.
  • Physical address footer — Your mailing address appended to every email body
  • Unsubscribe link — A visible opt-out link in the footer

Set your physical address in Settings > Compliance or via the API:

Configure compliance
curl -X PUT https://pushmail.dev/api/v1/compliance \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "physicalAddress": "123 Main St, Suite 100, San Francisco, CA 94105",
    "complianceEnabled": true
  }'

See CAN-SPAM Compliance for full documentation.

Automatic suppression

PushMail automatically suppresses contacts that bounce or report spam. Suppressed contacts will not receive any emails from any sequence or campaign.

  • Hard bounce: Contact status set to bounced, added to suppression cache for 365 days
  • Spam report: Contact status set to complained, added to suppression cache for 365 days
  • Unsubscribe: Contact status set to unsubscribed, added to suppression cache for 365 days

Secure messages

Send encrypted emails with secure: true. The content is encrypted with AES-256-GCM before storage, and recipients decrypt it in their browser using a passphrase. Senders can revoke messages at any time, and expired messages are automatically cleaned up.

Send a secure message
curl -X POST https://pushmail.dev/api/v1/send \
  -H "Authorization: Bearer pm_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "siteId": 1,
    "subject": "Confidential",
    "html": "<p>Sensitive content here</p>",
    "secure": true,
    "expiresInHours": 48
  }'

See Secure Messages for full documentation on encryption, revocation, and message lifecycle.

Next steps

On this page