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.
Get your API key
Log in to the dashboard, open your site, copy the API key.
Verify your domain
Add SPF + DKIM records (one-click via Cloudflare or manual).
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 withpm_live_) - Note your
SMTP User+SMTP Passif 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()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
- Ingress — Your code calls
POST /api/sendOR connects to the SMTP relay onsmart-smtp.pingmail.cloud:587. Both paths converge. - Validation — The sender domain is checked against your verified Linked Domains. Quota is checked. Site pause state is honored.
- Policy — Block / hold / flag rules run on the message. A blocked send returns
403; a held send is parked for review. - Queue — The message is written to the database with
status='queued'. The API returns202 Acceptedimmediately. - 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.
- 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.
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
Queue an email for delivery. Returns 202 Accepted immediately; actual SMTP delivery happens asynchronously.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | ✓ | Single recipient email address. |
subject | string | ✓* | Plain text subject. UTF-8. |
body | string | ✓* | HTML body. Plain-text alternative auto-generated. |
from_email | string | — | Overrides site default. Domain must be verified. |
from_name | string | — | Display name for the From header. |
template | string | — | Template template_key (see Templates). When set, subject/body become optional. |
language | string | — | Template language. Defaults to the template's default. |
variables | object | — | 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
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
Paginated list of all emails for the site. Most recent first.
Query parameters
| Param | Default | Notes |
|---|---|---|
page | 1 | 1-indexed. |
limit | 20 | Max 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
Full row for one email — status, message-id, error message, timestamps, rendered preview.
Per-hop trace from queue to recipient. The single best tool for debugging "why didn't this arrive?".
Error Codes
| Status | Reason | Your action |
|---|---|---|
| 202 | Queued for delivery | Store the response. Optionally poll /api/emails/:id. |
| 202 / 200 | Held — 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. |
| 400 | Bad request — missing field, malformed JSON | Fix the payload. Won't succeed on retry. |
| 401 | Invalid or missing API key | Check the X-API-Key header. |
| 402 | Monthly quota exceeded | Upgrade the plan or wait for the 1st of the month. |
| 403 | From-domain not verified, or blocked by policy | Verify the domain. Read the error for the policy name. |
| 429 | Rate limit exceeded (60 req/min/key) | Back off ~1s and retry. Use exponential backoff for sustained bursts. |
| 500 | Internal error | Safe 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). Returns402when 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 API | SMTP Relay | |
|---|---|---|
| Best for | New code, microservices, serverless | Legacy apps, WordPress, ActionMailer, Postfix forwarding |
| Auth | API key header | SMTP AUTH PLAIN, or IP allowlist |
| Templates | ✓ Full support | Send raw RFC 822; build templates client-side |
| Variable substitution | ✓ | — |
| Webhook delivery | ✓ | ✓ (same) |
| Response | JSON 202 | SMTP 250 OK |
Both surfaces are equivalent for deliverability and ops — pick whichever your stack supports best.
Connection Settings
| Setting | Value |
|---|---|
| Host | smart-smtp.pingmail.cloud |
| Port (STARTTLS) | 587 (recommended) |
| Port (Implicit TLS / SMTPS) | 465 |
| Port (legacy MX) | 25 (only for server-to-server, not authenticated submission) |
| Security | TLS required (STARTTLS or SMTPS) |
| AUTH mechanism | PLAIN |
| Username | Your site's smtp_user from the dashboard |
| Password | Your site's smtp_pass from the dashboard |
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:
- Open Sites → your site → Allowed IPs.
- 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.
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
languagerows. - 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:
| Syntax | Behavior |
|---|---|
{{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
- Snippet references are expanded first.
- 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
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]"
Policies & Filters
Policies are rules that run on every send. Three actions are available:
| Action | HTTP response | Effect |
|---|---|---|
block | 403 | Send rejected. Recorded as rejected. |
hold | 200 + "status":"held" | Stored, not sent. Operator releases manually. |
flag | 202 | Sent 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
deadafterpause_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
| Symptom | Cause | Fix |
|---|---|---|
401 invalid API key | Wrong header or stale key | Header is X-API-Key (case-sensitive in some proxies). |
403 From domain not verified | SPF / DKIM not green | Linked Domains → DNS Records. Allow up to 30 min for DNS to propagate. |
402 quota exceeded | Plan limit reached | Upgrade or wait for monthly reset. GET /api/quota shows remaining. |
429 rate limit | >60 req/min on the key | Add exponential backoff. Ask ops to raise the limit for sustained high volume. |
| Email queued but never sent | Site paused / gateway down | Check Sites for paused badge; Email Gateways for red status. |
| Recipient marked bounced | Hard bounce from receiver | GET /api/emails/:id/delivery-log shows the exact SMTP response. |
SMTP 535 Authentication failed | Wrong SMTP user/pass | Regenerate in dashboard. SMTP creds are different from the API key. |
SMTP 550 Sender domain not authorized | From-domain not in Linked Domains | Add & verify the domain. |
| Template variables not rendering | Key mismatch | Variable names are case-sensitive. Check template's declared schema. |
Glossary
| Term | Meaning |
|---|---|
| Site | One tenant on PingMail. Has its own API key, SMTP credentials, quota, and policy set. |
| Linked Domain | A sender domain (e.g. yourdomain.com) verified on a site. Multiple domains can link to one site. |
| Template | Server-side stored subject + HTML body, keyed by template_key + language. |
| Snippet | Reusable HTML fragment, included in templates via {{>key}}. |
| Gateway | An outbound SMTP relay or vendor API (Postmark, Mailjet, JetEmail, Postfix). The dispatcher picks one per email. |
| Policy | A rule that blocks, holds, or flags emails based on recipient, content, or headers. |
| Held | Status of an email that was accepted but not sent — site paused, hold-policy match, awaiting review. |
| Smart Route | IP-allowlist-based auto-authentication on the SMTP relay (no AUTH required for whitelisted IPs). |
| Dispatcher | The 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
- Dashboard: app.pingmail.cloud
- Status page: status.pingmail.cloud
- Email: [email protected]
- API key rotation: Sites → Edit → Regenerate Key
- Domain status: Sites → Linked Domains
- Live delivery log: Emails → click any row → Delivery Log tab