Log in Dashboard →
📘 Developer Documentation

PingMail Developer Guide

Send transactional and marketing email through a simple REST API or authenticated SMTP relay. Multi-domain, multi-tenant, with templates, policy enforcement, and built-in deliverability — engineered for production traffic.

PingMail.Cloud is a multi-tenant email platform that exposes two equivalent sending surfaces — a REST API (for new code) and a standards-compliant SMTP relay (drop-in replacement for any existing mailer). Both run through the same dispatcher, policy engine, gateway routing, quota system, and delivery log. Pick whichever is easier to integrate; you can mix them per site.

1

Get your API key

Log in to the dashboard, open your site, copy the API key.

2

Verify your domain

Add SPF + DKIM records (one-click via Cloudflare or manual).

3

Send your first email

One curl away. See below.

Quick Start

The fastest path from zero to a delivered email — under five minutes.

1. Get your credentials

From the dashboard:

  • Open Sites → your site
  • Copy the API Key (starts with pm_live_)
  • Note your SMTP User + SMTP Pass if you prefer SMTP

2. Verify your sending domain

Open Sites → your site → Linked Domains and follow the on-screen DNS records. You need both SPF and DKIM green ✓ before sending. With Cloudflare connected, the dashboard provisions the records automatically.

3. Send your first email

curl -X POST https://api.pingmail.cloud/api/send \
  -H 'X-API-Key: pm_live_XXXXXXXXXXXXXXXXXXXXXXXX' \
  -H 'Content-Type: application/json' \
  -d '{
    "to":         "[email protected]",
    "from_email": "[email protected]",
    "from_name":  "Your App",
    "subject":    "Hello from PingMail",
    "body":       "<h1>It works!</h1><p>This is a test send.</p>"
  }'
$ch = curl_init('https://api.pingmail.cloud/api/send');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    'X-API-Key: ' . getenv('PINGMAIL_API_KEY'),
    'Content-Type: application/json',
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'to'         => '[email protected]',
    'from_email' => '[email protected]',
    'from_name'  => 'Your App',
    'subject'    => 'Hello from PingMail',
    'body'       => '<h1>It works!</h1>',
  ]),
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 202) {
  error_log("PingMail failed ($code): $response");
}
const res = await fetch('https://api.pingmail.cloud/api/send', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.PINGMAIL_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to:         '[email protected]',
    from_email: '[email protected]',
    from_name:  'Your App',
    subject:    'Hello from PingMail',
    body:       '<h1>It works!</h1>',
  }),
});
if (!res.ok) throw new Error(`PingMail ${res.status}`);
import os, requests

r = requests.post(
    "https://api.pingmail.cloud/api/send",
    headers={
        "X-API-Key": os.environ["PINGMAIL_API_KEY"],
        "Content-Type": "application/json",
    },
    json={
        "to":         "[email protected]",
        "from_email": "[email protected]",
        "from_name":  "Your App",
        "subject":    "Hello from PingMail",
        "body":       "<h1>It works!</h1>",
    },
    timeout=10,
)
r.raise_for_status()
✓ Expected response HTTP/1.1 202 Accepted with {"success": true, "emails_remaining": 4999}. The email is queued; the dispatcher delivers it within seconds.

How PingMail Works

Understanding the pipeline helps you design retry logic, observability, and error handling correctly.

Pipeline overview

  1. Ingress — Your code calls POST /api/send OR connects to the SMTP relay on smart-smtp.pingmail.cloud:587. Both paths converge.
  2. Validation — The sender domain is checked against your verified Linked Domains. Quota is checked. Site pause state is honored.
  3. Policy — Block / hold / flag rules run on the message. A blocked send returns 403; a held send is parked for review.
  4. Queue — The message is written to the database with status='queued'. The API returns 202 Accepted immediately.
  5. Dispatcher — A background worker drains the queue, pacing by per-site and per-IP rate limits. It picks the right outbound gateway (Postmark, Mailjet, JetEmail, or your own Postfix) and sends.
  6. Delivery log — Every hop is recorded. Webhook callbacks fire on bounces, opens, and clicks (when enabled by the gateway).

Async semantics

The API is asynchronous. A successful 202 means "we accepted responsibility for the message" — not "the recipient has it". To get the final state, poll GET /api/emails/:id or wire a delivery webhook. For most use cases, the 202 is enough — the dispatcher's success rate is >99% for verified domains.

API Authentication

Every request to /api/* must carry your API key in the X-API-Key header:

X-API-Key: pm_live_XXXXXXXXXXXXXXXXXXXXXXXX

Where to find it

Dashboard → Sites → your site → API Key. You can regenerate at any time; the old key keeps working until you revoke it.

Alternative: IP allowlist

If your sender can't set custom HTTP headers (legacy CRMs), enable Allowed IPs on the site and send unauthenticated requests. PingMail matches the client IP to a site and grants the same permissions as the API key.

⚠ Keep your key private Treat pm_live_* like a password. Never commit it to git, never ship it in client-side JS. Store it in environment variables or a secret manager.

Send Email

POST /api/send

Queue an email for delivery. Returns 202 Accepted immediately; actual SMTP delivery happens asynchronously.

Request body

FieldTypeRequiredDescription
tostring✓Single recipient email address.
subjectstring✓*Plain text subject. UTF-8.
bodystring✓*HTML body. Plain-text alternative auto-generated.
from_emailstring—Overrides site default. Domain must be verified.
from_namestring—Display name for the From header.
templatestring—Template template_key (see Templates). When set, subject/body become optional.
languagestring—Template language. Defaults to the template's default.
variablesobject—Key-value substitution map for {{var}} placeholders.

* Either provide subject + body directly, or set template.

Successful response

{
  "success": true,
  "message_id": "",
  "emails_remaining": 4998
}

message_id is the RFC Message-ID PingMail assigns at queue time and puts on the outgoing message header — use it to correlate this send with the delivered mail and with bounce reports. Poll GET /api/emails/:id for dispatch status and the delivery log.

Held response — 202 when the site is paused, 200 on a hold-policy match

{
  "status": "held",
  "reason": "site paused — message stored for up to 48h",
  "email_id": 8472,
  "pause_reason": "domain_dns_failed"
}

The email is parked in the queue and will be sent automatically when the underlying condition clears (DNS fixes, operator un-pauses, etc.).

Get Quota

GET /api/quota

Monthly usage for the current site. Useful for surfacing remaining budget in your own UI.

{
  "plan":      "pro",
  "sent":      1234,
  "limit":     50000,
  "remaining": 48766
}

limit: 0 means unlimited (custom enterprise plans). The counter resets at 00:00 UTC on the 1st of each month.

List Emails

GET /api/emails

Paginated list of all emails for the site. Most recent first.

Query parameters

ParamDefaultNotes
page11-indexed.
limit20Max 100.
search—Matches recipient, subject, or message-id.
status—queued, sending, sent, delivered, deferred, failed, bounced, held, rejected, dead.
curl 'https://api.pingmail.cloud/api/emails?status=bounced&limit=50' \
  -H 'X-API-Key: pm_live_...'

Get Email + Delivery Log

GET /api/emails/:id

Full row for one email — status, message-id, error message, timestamps, rendered preview.

GET /api/emails/:id/delivery-log

Per-hop trace from queue to recipient. The single best tool for debugging "why didn't this arrive?".

Error Codes

StatusReasonYour action
202Queued for deliveryStore the response. Optionally poll /api/emails/:id.
202 / 200Held — 202 when the site is paused, 200 on a hold-policy match; both carry "status":"held"Expected while parked; mail releases automatically. Treat like a successful send.
400Bad request — missing field, malformed JSONFix the payload. Won't succeed on retry.
401Invalid or missing API keyCheck the X-API-Key header.
402Monthly quota exceededUpgrade the plan or wait for the 1st of the month.
403From-domain not verified, or blocked by policyVerify the domain. Read the error for the policy name.
429Rate limit exceeded (60 req/min/key)Back off ~1s and retry. Use exponential backoff for sustained bursts.
500Internal errorSafe to retry. If persistent, contact support.

All error responses share the same shape:

{ "error": "human-readable description" }

Rate Limits

Two tiers apply per site:

  • Per-API-key rate limit: 60 requests per minute, sliding window. Returns 429.
  • Monthly quota: Defined by your plan (emails_limit_month). Returns 402 when exhausted.

For sustained high-volume traffic, contact us to raise per-key limits — there is no hard ceiling on the platform, only the per-account defaults.

SMTP Relay

PingMail's SMTP relay is a fully RFC-compliant submission service — drop in any mailer (Postfix, Nodemailer, PHPMailer, Swift Mailer, ActionMailer, msmtp, etc.) and point it at our host. All mail goes through the same pipeline as the REST API: same quota, same policy engine, same delivery log.

API vs SMTP — which should you use?

REST APISMTP Relay
Best forNew code, microservices, serverlessLegacy apps, WordPress, ActionMailer, Postfix forwarding
AuthAPI key headerSMTP AUTH PLAIN, or IP allowlist
Templates✓ Full supportSend raw RFC 822; build templates client-side
Variable substitution✓—
Webhook delivery✓✓ (same)
ResponseJSON 202SMTP 250 OK

Both surfaces are equivalent for deliverability and ops — pick whichever your stack supports best.

Connection Settings

SettingValue
Hostsmart-smtp.pingmail.cloud
Port (STARTTLS)587 (recommended)
Port (Implicit TLS / SMTPS)465
Port (legacy MX)25 (only for server-to-server, not authenticated submission)
SecurityTLS required (STARTTLS or SMTPS)
AUTH mechanismPLAIN
UsernameYour site's smtp_user from the dashboard
PasswordYour site's smtp_pass from the dashboard
ℹ Host name Use smart-smtp.pingmail.cloud (the smart router) — NOT mail.pingmail.cloud. The smart host load-balances across regions and applies per-site routing rules.

SMTP Authentication

PingMail supports AUTH PLAIN over TLS. The username + password come from your site's settings — they are independent from the REST API key, so you can rotate them separately.

Where to find SMTP credentials

Dashboard → Sites → your site → SMTP User / SMTP Pass.

Domain validation

After AUTH succeeds, every MAIL FROM domain is checked against the site's Linked Domains. Unverified domains are rejected with 550 Sender domain not authorized.

Paused sites

A paused site can still AUTH and send — messages are accepted with 250 OK but stored as held instead of immediately dispatched. This matches the API's behavior and prevents legitimate bursts from being lost during a pause window.

IP Allowlist (Smart Route)

If your sender can't store SMTP credentials (legacy hardware, embedded systems), enable Smart Route on the site:

  1. Open Sites → your site → Allowed IPs.
  2. Add the IP address (or CIDR) that will be sending.

Connections from a matching IP are auto-authenticated to your site without SMTP AUTH. The MAIL FROM domain still needs to be verified.

⚠ Don't use on shared IPs Allowlist a Smart Route only for IPs you fully control. Shared cloud egress IPs may be reused by other tenants.

SMTP Examples

Postfix relay (Linux)

# /etc/postfix/main.cf
relayhost = [smart-smtp.pingmail.cloud]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
smtp_sasl_mechanism_filter = plain

# /etc/postfix/sasl_passwd
[smart-smtp.pingmail.cloud]:587  smtp_user:smtp_pass
# Apply
sudo postmap /etc/postfix/sasl_passwd
sudo chmod 600 /etc/postfix/sasl_passwd*
sudo systemctl reload postfix

Nodemailer (Node.js)

import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: 'smart-smtp.pingmail.cloud',
  port: 587,
  secure: false,           // STARTTLS
  requireTLS: true,
  auth: {
    user: process.env.PINGMAIL_SMTP_USER,
    pass: process.env.PINGMAIL_SMTP_PASS,
  },
});

await transporter.sendMail({
  from: '"Your App" <[email protected]>',
  to:   '[email protected]',
  subject: 'Hello',
  html: '<h1>Hi</h1>',
});

PHPMailer (PHP)

use PHPMailer\PHPMailer\PHPMailer;

$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host       = 'smart-smtp.pingmail.cloud';
$mail->SMTPAuth   = true;
$mail->Username   = getenv('PINGMAIL_SMTP_USER');
$mail->Password   = getenv('PINGMAIL_SMTP_PASS');
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port       = 587;

$mail->setFrom('[email protected]', 'Your App');
$mail->addAddress('[email protected]');
$mail->isHTML(true);
$mail->Subject = 'Hello';
$mail->Body    = '<h1>Hi</h1>';
$mail->send();

Python (smtplib)

import smtplib, ssl, os
from email.mime.text import MIMEText

msg = MIMEText('<h1>Hi</h1>', 'html')
msg['From']    = 'Your App <[email protected]>'
msg['To']      = '[email protected]'
msg['Subject'] = 'Hello'

with smtplib.SMTP('smart-smtp.pingmail.cloud', 587) as s:
    s.starttls(context=ssl.create_default_context())
    s.login(os.environ['PINGMAIL_SMTP_USER'],
            os.environ['PINGMAIL_SMTP_PASS'])
    s.send_message(msg)

msmtp (sendmail-compatible CLI)

# ~/.msmtprc
defaults
auth           on
tls            on
tls_starttls   on
logfile        ~/.msmtp.log

account pingmail
host    smart-smtp.pingmail.cloud
port    587
from    [email protected]
user    SMTP_USER_HERE
password SMTP_PASS_HERE

account default : pingmail
echo -e "Subject: Test\n\nHi" | msmtp [email protected]

Templates

Templates let you store reusable subject + HTML pairs server-side and trigger them by template_key. The renderer interpolates variables, expands snippets, and produces a finished MIME body — your code only sends the trigger.

Why use templates

  • Marketing & design can edit copy without a code deploy.
  • Multi-language support — same key, different language rows.
  • Required-variable enforcement — the renderer rejects sends that omit declared required vars.
  • Smaller payloads — your servers send a few KB of JSON instead of full HTML bodies.
  • Centralized A/B and analytics — change content without touching apps.

Create templates in the dashboard: Templates → your site → New.

Variables & Syntax

Templates support Mustache-flavored interpolation in both the subject and the body:

SyntaxBehavior
{{name}}HTML-escaped substitution. Safe to use anywhere in HTML.
{{{name}}}Raw (unescaped). Use only for trusted, pre-sanitized HTML fragments.
{{>snippet_key}}Include a snippet (see below).

Variable schema

Each template declares its variables as a JSON object:

{
  "name":        { "type": "string", "required": true,  "description": "Customer first name" },
  "order_id":    { "type": "string", "required": true },
  "total":       { "type": "string", "default": "$0.00" },
  "items_html":  { "type": "string", "required": false, "description": "Pre-rendered item rows" }
}

The renderer enforces required — sending without a required variable returns 400. Missing optional variables use their default if declared, otherwise render as empty string.

Snippets (Partials)

Snippets are reusable HTML fragments — signatures, legal footers, header banners. Define once per site, reference from any template with {{>snippet_key}}.

Manage snippets

Dashboard → Templates → Snippets → New. Each snippet has a snippet_key (slug) and an HTML body.

Render order

  1. Snippet references are expanded first.
  2. Variable substitution runs on the expanded result.

This means a snippet can itself contain {{var}} placeholders that resolve against the calling template's variable context — perfect for a footer that wants to greet the recipient by name.

Example

// Snippet: "signature"
<p>Best,<br>{{from_name}} – PingMail Team</p>
<p style="font-size:11px;color:#888">
  Unsubscribe: {{unsubscribe_url}}
</p>

// Template: "welcome"
<h1>Welcome, {{name}}!</h1>
<p>Thanks for joining {{product}}.</p>
{{>signature}}

Multi-Language

One template_key can have multiple language rows. The send call specifies which language to use; if omitted, the row with is_default=1 wins.

{
  "to":       "[email protected]",
  "template": "welcome",
  "language": "he",
  "variables": { "name": "אליס" }
}

The renderer falls back to the default language row if the requested one doesn't exist for that template_key.

Attachments

⚠ The REST API does not accept attachments. POST /api/send carries a subject and an HTML body only — there is no attachments field. To send attachments, submit over SMTP instead: your message is relayed verbatim as standard MIME, attachments included, through every outbound route.

Use your site's SMTP credentials from the dashboard (see Connection Settings) and attach files the way your mail library normally does. The same From-domain rules apply as on the API.

Nodemailer (Node.js)

const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  host: 'smart-smtp.pingmail.cloud',
  port: 587,                      // STARTTLS (or 465 with secure: true)
  secure: false,
  auth: { user: process.env.PINGMAIL_SMTP_USER, pass: process.env.PINGMAIL_SMTP_PASS },
});

await transporter.sendMail({
  from: '"Your App" <[email protected]>',
  to: '[email protected]',
  subject: 'Your invoice',
  html: '<p>Invoice attached.</p>',
  attachments: [
    { filename: 'invoice-1042.pdf', path: '/var/invoices/1042.pdf' },        // from disk
    { filename: 'terms.txt', content: 'Net 30.' },                           // from a string
    { filename: 'logo.png', path: '/assets/logo.png', cid: 'logo' },         // inline: <img src="cid:logo">
  ],
});

PHPMailer (PHP)

use PHPMailer\PHPMailer\PHPMailer;

$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host       = 'smart-smtp.pingmail.cloud';
$mail->Port       = 587;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPAuth   = true;
$mail->Username   = getenv('PINGMAIL_SMTP_USER');
$mail->Password   = getenv('PINGMAIL_SMTP_PASS');

$mail->setFrom('[email protected]', 'Your App');
$mail->addAddress('[email protected]');
$mail->Subject = 'Your invoice';
$mail->isHTML(true);
$mail->Body    = '<p>Invoice attached.</p>';

$mail->addAttachment('/var/invoices/1042.pdf', 'invoice-1042.pdf');
$mail->addStringAttachment($csvBytes, 'report.csv', 'base64', 'text/csv');
$mail->addEmbeddedImage('/assets/logo.png', 'logo');   // inline: <img src="cid:logo">

$mail->send();

Python (smtplib + EmailMessage)

import os, smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg['From'] = 'Your App <[email protected]>'
msg['To'] = '[email protected]'
msg['Subject'] = 'Your invoice'
msg.set_content('Invoice attached.')                       # plain-text part
msg.add_alternative('<p>Invoice attached.</p>', subtype='html')

with open('/var/invoices/1042.pdf', 'rb') as f:
    msg.add_attachment(f.read(), maintype='application', subtype='pdf',
                       filename='invoice-1042.pdf')

with smtplib.SMTP('smart-smtp.pingmail.cloud', 587, timeout=15) as s:
    s.starttls()
    s.login(os.environ['PINGMAIL_SMTP_USER'], os.environ['PINGMAIL_SMTP_PASS'])
    s.send_message(msg)

Size limits

  • Messages are accepted up to 25 MB on the wire.
  • Keep the finished message (body + encoded attachments) under 10 MB — base64 inflates files by ~37%, and downstream relays cap out at 10 MB.

Template attachments

Files uploaded under Templates → edit → Attachments are stored with the template but are not yet included on outgoing sends — automatic MIME assembly hasn't shipped. Until it does, send attachment-bearing mail over SMTP as shown above.

Sending a Template

curl -X POST https://api.pingmail.cloud/api/send \
  -H 'X-API-Key: pm_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "to":         "[email protected]",
    "from_email": "[email protected]",
    "template":   "order_confirmation",
    "language":   "en",
    "variables": {
      "name":     "Alice",
      "order_id": "PM-1042",
      "total":    "$49.99"
    }
  }'

Override fields

Pass subject or body explicitly to override the template for a single send. Override bodies are taken raw — no variable substitution. Subjects are always interpolated.

PHP & WordPress

WordPress drop-in

Override wp_mail to route all WordPress mail through PingMail. Add to functions.php or a small must-use plugin:

<?php
add_filter('pre_wp_mail', function ($null, $atts) {
  $resp = wp_remote_post('https://api.pingmail.cloud/api/send', [
    'headers' => [
      'X-API-Key'    => defined('PINGMAIL_API_KEY') ? PINGMAIL_API_KEY : '',
      'Content-Type' => 'application/json',
    ],
    'timeout' => 10,
    'body' => wp_json_encode([
      'to'         => is_array($atts['to']) ? $atts['to'][0] : $atts['to'],
      'subject'    => $atts['subject'],
      'body'       => $atts['message'],
      'from_email' => defined('PINGMAIL_FROM_EMAIL') ? PINGMAIL_FROM_EMAIL : get_bloginfo('admin_email'),
      'from_name'  => get_bloginfo('name'),
    ]),
  ]);
  if (is_wp_error($resp)) return false;
  return wp_remote_retrieve_response_code($resp) === 202;
}, 10, 2);

Then in wp-config.php:

define('PINGMAIL_API_KEY',    'pm_live_...');
define('PINGMAIL_FROM_EMAIL', '[email protected]');

Node.js

Native fetch in Node 18+; no external dependency required.

export async function sendEmail({ to, subject, body, from_email }) {
  const res = await fetch('https://api.pingmail.cloud/api/send', {
    method: 'POST',
    headers: {
      'X-API-Key':    process.env.PINGMAIL_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ to, subject, body, from_email }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`PingMail ${res.status}: ${data.error}`);
  return data;
}

export async function sendTemplate(template, variables, to, language = 'en') {
  return sendEmail.call(this, {
    to, from_email: process.env.PINGMAIL_FROM,
    body: '', subject: '',  // ignored — template supplies these
    // (extend the helper above to forward template/language/variables)
  });
}

Python

import os, requests
from typing import Optional

class PingMail:
    BASE = "https://api.pingmail.cloud"

    def __init__(self, api_key: Optional[str] = None):
        self.api_key = api_key or os.environ["PINGMAIL_API_KEY"]

    def send(self, to, subject, body, from_email, from_name=None):
        return self._post("/api/send", {
            "to": to, "subject": subject, "body": body,
            "from_email": from_email, "from_name": from_name,
        })

    def send_template(self, to, template, variables, from_email, language="en"):
        return self._post("/api/send", {
            "to": to, "from_email": from_email,
            "template": template, "language": language,
            "variables": variables,
        })

    def quota(self):
        r = requests.get(f"{self.BASE}/api/quota",
                         headers={"X-API-Key": self.api_key}, timeout=10)
        r.raise_for_status()
        return r.json()

    def _post(self, path, payload):
        r = requests.post(f"{self.BASE}{path}",
                          headers={"X-API-Key": self.api_key,
                                   "Content-Type": "application/json"},
                          json=payload, timeout=10)
        r.raise_for_status()
        return r.json()

Laravel (mail driver)

Use Laravel's built-in SMTP mailer with PingMail's relay. In .env:

MAIL_MAILER=smtp
MAIL_HOST=smart-smtp.pingmail.cloud
MAIL_PORT=587
MAIL_USERNAME=your_smtp_user
MAIL_PASSWORD=your_smtp_pass
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="${APP_NAME}"

Then your existing Mail::to(...)->send(new ...) calls work unchanged. For per-tenant routing, swap the credentials per dispatch using Mail::mailer('pingmail-tenant-X')->send(...).

Go

package pingmail

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "time"
)

type Client struct {
    APIKey string
    BaseURL string
    HTTP    *http.Client
}

func NewClient(apiKey string) *Client {
    return &Client{
        APIKey:  apiKey,
        BaseURL: "https://api.pingmail.cloud",
        HTTP:    &http.Client{Timeout: 10 * time.Second},
    }
}

type SendRequest struct {
    To        string                 `json:"to"`
    From      string                 `json:"from_email,omitempty"`
    FromName  string                 `json:"from_name,omitempty"`
    Subject   string                 `json:"subject,omitempty"`
    Body      string                 `json:"body,omitempty"`
    Template  string                 `json:"template,omitempty"`
    Language  string                 `json:"language,omitempty"`
    Variables map[string]interface{} `json:"variables,omitempty"`
}

func (c *Client) Send(req SendRequest) error {
    body, _ := json.Marshal(req)
    r, _ := http.NewRequest("POST", c.BaseURL+"/api/send", bytes.NewReader(body))
    r.Header.Set("X-API-Key", c.APIKey)
    r.Header.Set("Content-Type", "application/json")
    resp, err := c.HTTP.Do(r)
    if err != nil { return err }
    defer resp.Body.Close()
    if resp.StatusCode != 202 {
        return fmt.Errorf("pingmail: %d", resp.StatusCode)
    }
    return nil
}

Domain Verification

Before you can send from @yourdomain.com, the domain must be a verified Linked Domain on the site. Verification checks two DNS records:

SPF

Authorizes PingMail's sending hosts. Added as a TXT record at the apex:

yourdomain.com.  TXT  "v=spf1 include:_spf.pingmail.cloud ~all"

DKIM

Cryptographic signature. Added as a TXT record on a sub-host the dashboard generates:

pingmail._domainkey.yourdomain.com.  TXT  "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQ..."

DMARC (recommended)

_dmarc.yourdomain.com.  TXT  "v=DMARC1; p=quarantine; rua=mailto:[email protected]"
✓ One-click setup with Cloudflare Connect your Cloudflare account in Settings → Cloudflare, then open Site → Cloudflare Apply. PingMail writes both records and verifies in under 30 seconds.

Policies & Filters

Policies are rules that run on every send. Three actions are available:

ActionHTTP responseEffect
block403Send rejected. Recorded as rejected.
hold200 + "status":"held"Stored, not sent. Operator releases manually.
flag202Sent normally; logged for review.

Rule types

  • Recipient blacklist — hard-stop sends to specific addresses or domains.
  • Content regex — Go RE2 patterns matched against subject + body.
  • Header rules — match arbitrary X-* headers.
  • Rate limits — per-recipient, per-domain, per-window.

Configure in Dashboard → Policies. Policies can be scoped per-site or platform-wide.

Held Mail (Paused Sites)

When a site is paused — automatically (SpamGuard, domain scheduler, blacklist) or manually — PingMail keeps accepting sends but parks them as held instead of dispatching. Rationale:

  • Mass senders do bursts. Rejecting mid-burst costs the customer thousands of retries downstream.
  • Held rows sit harmlessly in the queue. A janitor marks them dead after pause_hold_hours.
  • When the site auto-resumes, held rows still within the hold window are re-queued automatically.

Your code sees 202 (paused site) or 200 (hold-policy match), both with {"status":"held", "reason": "..."} — key off the status field, not the HTTP code, and handle it exactly like a successful send.

Bounces & Webhooks

The dispatcher classifies bounces in real-time and stores the result with each email row. To get notifications, configure a webhook URL in Settings → Webhooks:

Webhook payload

POST https://yourapp.com/hooks/pingmail
Content-Type: application/json
X-PingMail-Signature: hmac-sha256=<hex>

{
  "event":      "bounce",
  "email_id":   8472,
  "to":         "[email protected]",
  "message_id": "<abc123@pingmail>",
  "bounce_type":"hard",          // "hard" | "soft" | "complaint"
  "reason":     "550 5.1.1 mailbox does not exist",
  "timestamp":  "2026-06-01T14:32:11Z"
}

Other event types: delivered, opened, clicked, complaint, spam_report. Open/click tracking requires a gateway that supports it (Postmark, JetEmail).

Signature verification

The X-PingMail-Signature header is an HMAC-SHA256 of the raw body using your webhook secret. Always verify before acting on a payload.

Troubleshooting

SymptomCauseFix
401 invalid API keyWrong header or stale keyHeader is X-API-Key (case-sensitive in some proxies).
403 From domain not verifiedSPF / DKIM not greenLinked Domains → DNS Records. Allow up to 30 min for DNS to propagate.
402 quota exceededPlan limit reachedUpgrade or wait for monthly reset. GET /api/quota shows remaining.
429 rate limit>60 req/min on the keyAdd exponential backoff. Ask ops to raise the limit for sustained high volume.
Email queued but never sentSite paused / gateway downCheck Sites for paused badge; Email Gateways for red status.
Recipient marked bouncedHard bounce from receiverGET /api/emails/:id/delivery-log shows the exact SMTP response.
SMTP 535 Authentication failedWrong SMTP user/passRegenerate in dashboard. SMTP creds are different from the API key.
SMTP 550 Sender domain not authorizedFrom-domain not in Linked DomainsAdd & verify the domain.
Template variables not renderingKey mismatchVariable names are case-sensitive. Check template's declared schema.

Glossary

TermMeaning
SiteOne tenant on PingMail. Has its own API key, SMTP credentials, quota, and policy set.
Linked DomainA sender domain (e.g. yourdomain.com) verified on a site. Multiple domains can link to one site.
TemplateServer-side stored subject + HTML body, keyed by template_key + language.
SnippetReusable HTML fragment, included in templates via {{>key}}.
GatewayAn outbound SMTP relay or vendor API (Postmark, Mailjet, JetEmail, Postfix). The dispatcher picks one per email.
PolicyA rule that blocks, holds, or flags emails based on recipient, content, or headers.
HeldStatus of an email that was accepted but not sent — site paused, hold-policy match, awaiting review.
Smart RouteIP-allowlist-based auto-authentication on the SMTP relay (no AUTH required for whitelisted IPs).
DispatcherThe background worker that drains the queue and pushes messages out via the chosen gateway.

Changelog

  • 2026-06-01 — Multi-primary cluster sync (control-plane replication across nodes).
  • 2026-05 — Template snippets ({{>key}} partials), template attachments, multi-language send.
  • 2026-04 — Email Gateways (custom SMTP relay catalog), per-policy gateway override.
  • 2026-03 — JetEmail integration, REST transport for vendor APIs.
  • 2026-02 — Async dispatcher, held-not-bounced behavior for paused sites.
  • 2026-01 — Linked Domains (multi-domain per site).

Support

ℹ Found an issue with these docs? Email [email protected] — we read every report.