SendPanda API integration
Interactive OpenAPI is served at /docs; the contract is at /openapi.json and exported to apps/docs/openapi.json. Base production URL: https://api.sendpanda.com/v1. Local base URL: http://localhost:4000/v1.
Send your first email
Verify your account, add a project sending domain, publish its DNS records, recheck readiness and obtain operator approval. Create a send API key. Use it only on your server.
import { SendPanda } from "@sendpanda/sdk";
const panda = new SendPanda(process.env.SENDPANDA_API_KEY!, {
baseUrl: process.env.SENDPANDA_API_URL ?? "https://api.sendpanda.com/v1",
timeout: 10_000,
});
const email = await panda.emails.send(
{
from: "Your app <hello@your-verified-domain.com>",
to: ["success@simulator.amazonses.com"],
subject: "Hello from SendPanda",
text: "Your integration is working.",
},
{ idempotencyKey: "welcome-account-123" },
);
console.log(email.id, email.status); // queued on acceptance
The SDK is built locally with npm run build -w @sendpanda/sdk; it has not been published to npm. The example recipient is controlled by SES; configure SEND_MODE=ses_simulator during integration. In production mode use actual intended recipients, never test against strangers.
Reuse one idempotency key for retries of the same business event. Different content with the same key returns 409. Keys are project-scoped and retained with message metadata; one key is not a reusable sending credential. A timeout may follow acceptance: retry with the same key to retrieve the stored ID. The SDK retries reads and keyed sends, and never automatically retries an unkeyed send.
Request constraints: max 50 total unique To/CC/BCC recipients, 500 KB JSON payload, a nonempty HTML/text body or published template, valid mailboxes, verified sender domain, approved account, rate limits, quota and suppressions. Tags use ASCII letters/numbers/underscore/dash; sendpanda_id is reserved. Executable HTML and unsafe URL destinations are rejected. full keys can administer their project, so prefer send keys for application integrations. Session access additionally needs X-Project-Id.
Delivery status
Acceptance reserves recipient quota and returns queued. Workers move through processing and submitted. Only provider delivery events produce delivered. Recipient records have separate status, so a mixed multi-recipient message may be submitted or bounced while another recipient delivered. needs_review means an uncertain submission requires operator reconciliation; do not resend blindly. Cancellation is possible only before worker claiming.
GET /emails/:id returns recipient outcomes and an immutable event timeline. Message body content is not returned by that endpoint. Domain readiness must pass SendPanda ownership TXT, DKIM, custom MAIL FROM and provider checks. Deliverability to an inbox is not guaranteed by domain verification.
Search and pagination
GET /emails supports status, recipient, domain (domain ID), after/before (ISO UTC timestamps), and tag (tag name or value substring). Pages contain up to 50 messages plus next_cursor; pass that opaque value as cursor with the same filters to fetch older messages. Timestamp and message ID ordering preserves messages created together. before is exclusive.
GET /usage returns the organisation's UTC calendar-month recipient usage and a projects breakdown. Definitive failures before submission and queued cancellations release their reservations.
Webhook verification
Webhook headers are webhook-id, webhook-timestamp (Unix seconds), and webhook-signature containing space-separated v1=HEX signatures. Verify against the raw body before JSON parsing and use constant-time comparison:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(
secret: string,
eventId: string,
timestamp: string,
body: string,
header: string,
) {
const seconds = Number(timestamp);
if (!Number.isFinite(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300)
return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${eventId}.${body}`)
.digest();
return header.split(" ").some((value) => {
const signature = value.startsWith("v1=") ? value.slice(3) : "";
return (
/^[a-f0-9]{64}$/i.test(signature) &&
timingSafeEqual(expected, Buffer.from(signature, "hex"))
);
});
}
Deduplicate by event ID in a transaction. Respond 2xx after durable receipt; dispatch work asynchronously. Deliveries retry up to 8 attempts, with exponential jitter. Manual replay preserves event ID and refreshes timestamp/signature. During rotation both old and new secrets sign deliveries for 24 hours. Never log the signing secret. Endpoints require public HTTPS/443, public DNS for every resolved address and no redirects; the sender pins DNS per attempt to resist rebinding.
Templates
Create a template, save versions and publish one. Use template_id and variables instead of html/text. A published template supplies the subject; you can omit it from template-based sends. Variables use {{name}}, with HTML escaping; customer code is never executed. Use POST /templates/:id/preview with a variables object and optional version to render through the same escaping rules. The dashboard displays this in a sandboxed iframe. Duplicate creates a draft copy; test sends use the latest version and an SES simulator recipient with all normal approval/domain/quota checks. Review template output and links before publishing.
Error shape
{
"error": {
"code": "quota_exceeded",
"message": "Monthly recipient quota exhausted"
},
"request_id": "req-123"
}
400 invalid payload, 401 invalid credentials, 403 role/account/domain restriction, 404 inaccessible resource, 409 idempotency conflict, 422 suppressed/unsafe message, 429 quota/rate limit, 503 disabled or unconfigured service. Bodies and credentials never belong in application logs.
Interactive API reference ↗