Documentation

Everything the API can do.

One REST API for transactional sending, campaigns, and automation — plus an SMTP front door for anything that already speaks mail. The machine-readable spec lives at GET /v1/openapi.json.

Start here

Quickstart

Grab your secret key from project settings, verify a sending domain, and you can send with one request. The Idempotency-Key header makes retries safe — a repeated key replays the stored response instead of sending again.

curl

curl http://localhost:4100/v1/send \
  -H "Authorization: Bearer sk_..." \
  -H "Idempotency-Key: order-4921" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "ada@example.com",
    "subject": "Your order shipped",
    "html": "<p>On its way, {{firstName}}.</p>",
    "variables": { "firstName": "Ada" }
  }'

# → { "messages": [{ "id": "…", "to": "ada@example.com", "status": "sent" }],
#     "testMode": false }

TypeScript — @volanea/sdk

import { Volanea } from "@volanea/sdk";

const volanea = new Volanea({ apiKey: process.env.VOLANEA_SECRET_KEY! });

await volanea.send(
  {
    to: "ada@example.com",
    subject: "Your order shipped",
    html: "<p>On its way, {{firstName}}.</p>",
    variables: { firstName: "Ada" },
  },
  { idempotencyKey: "order-4921" },
);

Keys

Authentication

Every request carries an API key as Authorization: Bearer <key>. Each project has three:

sk_…

Secret key

Full access to every endpoint. Server-side only — never ship it to a browser or mobile app.

sk_test_…

Test key

Same access as the secret key, but nothing is delivered: messages render, log, and return results, and domain verification is not enforced. Responses include testMode: true.

pk_…

Public key

Accepted only by POST /v1/events. Safe to embed in client-side code for product event tracking.

Conventions

Errors & pagination

Every error is the same envelope with an appropriate HTTP status — a machine-readable code and a human-readable message. Validation failures (422, code validation_error) add a details array of issues.

error envelope

{
  "error": {
    "code": "domain_not_verified",
    "message": "The domain \"example.com\" is not verified for this project."
  }
}

List endpoints return { data, nextCursor }. Pass nextCursor back as the cursor query parameter until it is null.

Port 2525

SMTP relay

Anything that already speaks SMTP can send through Volanea — the relay parses the message and hands it to the same pipeline as POST /v1/send. Authenticate with any username and your secret key as the password. Unauthenticated sessions are treated as inbound mail: messages addressed to one of your verified domains are stored, the sender becomes a contact, and an email.received event fires (which can trigger webhooks and workflows).

smtp · localhost:2525

# Any SMTP client works — username is ignored, the password is your secret key.
swaks --server localhost:2525 \
      --auth-user volanea --auth-password sk_... \
      --from hello@yourdomain.com --to someone@example.com \
      --header "Subject: Hello from SMTP"

# nodemailer
const transport = nodemailer.createTransport({
  host: "localhost", port: 2525,
  auth: { user: "volanea", pass: process.env.VOLANEA_SECRET_KEY },
});

Signed deliveries

Webhook verification

Register an endpoint and Volanea POSTs each matching event as JSON, retrying with backoff for about 24 hours. Every delivery is signed with your endpoint's whsec_… secret in the X-Volanea-Signature header — verify it against the raw request body before trusting the payload.

verify X-Volanea-Signature

import { createHmac, timingSafeEqual } from "node:crypto";

// X-Volanea-Signature: t=<unix-ms>,v1=<hex hmac-sha256 of "<t>.<raw body>">
export function verifyWebhook(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
  const timestamp = Number(parts.t);
  if (!parts.v1 || !Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() - timestamp) > 5 * 60_000) return false; // reject replays

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return expected.length === parts.v1.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Reference

Every endpoint

The full surface, grouped by resource. Request and response shapes live in the OpenAPI document at /v1/openapi.json.

Send

Transactional sending with batching, scheduling, template variables, and idempotency keys.

POST/v1/send

Send one message (to one recipient — address or {email, name} — or up to 50). Supports templateId, variables, custom headers, attachments, sendAt scheduling, and the Idempotency-Key header.

sk_ · sk_test_
POST/v1/send/batch

Send up to 1,000 personalized messages in one call; each succeeds or fails independently.

sk_ · sk_test_

Events

Product event ingestion and the event log. Events feed segments, workflow triggers, and webhooks.

POST/v1/events

Track a product event for a contact (auto-created if unknown). Safe to call from client code with a public key.

any key
GET/v1/events

The event log, newest first, cursor-paginated. Filter by name or contactId.

sk_ · sk_test_
GET/v1/events/names

Every distinct event name logged for the project, alphabetical.

sk_ · sk_test_
GET/v1/events/stats

Per-name counts and first/last-seen timestamps, most frequent first.

sk_ · sk_test_
GET/v1/events/{name}/usage

Usage detail for one event name: count, distinct contacts, first/last seen.

sk_ · sk_test_
DELETE/v1/events/{name}

Delete all events with this name. System event names (email./contact./segment./workflow.) are reserved.

sk_ · sk_test_

Contacts

The contact graph: one record per address, shared by every send.

GET/v1/contacts

List contacts, newest first, cursor-paginated.

sk_ · sk_test_
POST/v1/contacts

Create or update a contact by email; only provided fields change.

sk_ · sk_test_
GET/v1/contacts/{id}

Get one contact.

sk_ · sk_test_
DELETE/v1/contacts/{id}

Delete a contact.

sk_ · sk_test_
GET/v1/contacts/{id}/timeline

Activity timeline: events and emails merged, newest first (last 100).

sk_ · sk_test_
POST/v1/contacts/bulk

Subscribe, unsubscribe, delete, or suppress up to 10,000 addresses at once.

sk_ · sk_test_
POST/v1/contacts/import

CSV import with header-based column mapping and an update/skip dedupe strategy.

sk_ · sk_test_
GET/v1/contacts/export

Export the full audience as CSV.

sk_ · sk_test_
GET/v1/contacts/fields

Discover custom contact fields (data keys) with counts, for building segment filters dynamically.

sk_ · sk_test_
GET/v1/contacts/fields/{field}/values

Up to 100 distinct values seen for one custom field, most common first.

sk_ · sk_test_
GET/v1/contacts/fields/{field}/usage

How many contacts have this custom field set.

sk_ · sk_test_
DELETE/v1/contacts/fields/{field}

Remove this key from every contact's data object. Irreversible.

sk_ · sk_test_

Emails

The send log — every message across transactional, campaign, workflow, and inbound sources.

GET/v1/emails

List sent emails, newest first. Filter by status, source, or recipient. Bodies omitted for size.

sk_ · sk_test_
GET/v1/emails/{id}

Full message detail including the rendered HTML body and engagement counters.

sk_ · sk_test_

Templates

Reusable content with {{variable}} placeholders, versioning, rollback, and test sends.

GET/v1/templates

List templates, most recently updated first.

sk_ · sk_test_
POST/v1/templates

Create a template (version 1 is snapshotted).

sk_ · sk_test_
GET/v1/templates/{id}

Get one template.

sk_ · sk_test_
PATCH/v1/templates/{id}

Update; content changes bump the version and snapshot it for rollback.

sk_ · sk_test_
DELETE/v1/templates/{id}

Delete a template.

sk_ · sk_test_
GET/v1/templates/{id}/versions

List all version snapshots, newest first.

sk_ · sk_test_
POST/v1/templates/{id}/rollback

Restore an old version's content as a new version.

sk_ · sk_test_
POST/v1/templates/{id}/test-send

Send the template to one address with sample variables (subject prefixed "[Test]").

sk_ · sk_test_
POST/v1/templates/{id}/duplicate

Clone as a new template with its own fresh version-1 history.

sk_ · sk_test_

Domains

Sending domains with ownership, DKIM, SPF, DMARC, and tracking DNS records.

GET/v1/domains

List domains with their DNS records.

sk_ · sk_test_
POST/v1/domains

Add a domain and get the DNS records to install.

sk_ · sk_test_
GET/v1/domains/{id}

Get one domain with DNS records and per-record status.

sk_ · sk_test_
POST/v1/domains/{id}/verify

Check DNS now; the domain verifies once ownership + DKIM + SPF pass.

sk_ · sk_test_
DELETE/v1/domains/{id}

Remove a domain.

sk_ · sk_test_

Segments

Dynamic (condition-driven) and static audiences over the contact graph.

GET/v1/segments

List segments.

sk_ · sk_test_
POST/v1/segments

Create a dynamic (conditions) or static segment.

sk_ · sk_test_
POST/v1/segments/preview

Live member-count preview for a condition tree, with a sample of matches.

sk_ · sk_test_
GET/v1/segments/{id}

Get one segment.

sk_ · sk_test_
PATCH/v1/segments/{id}

Update; changing conditions recomputes membership immediately.

sk_ · sk_test_
DELETE/v1/segments/{id}

Delete a segment.

sk_ · sk_test_
POST/v1/segments/{id}/refresh

Recompute membership now; returns entered/exited/memberCount.

sk_ · sk_test_
GET/v1/segments/{id}/members

Current members joined to contacts, newest entries first.

sk_ · sk_test_
POST/v1/segments/{id}/members

Static segments: add up to 1,000 members by email.

sk_ · sk_test_
DELETE/v1/segments/{id}/members/{contactId}

Static segments: remove a member.

sk_ · sk_test_

Campaigns

One-off broadcasts with scheduling, A/B subject experiments, and engagement stats.

GET/v1/campaigns

List campaigns.

sk_ · sk_test_
POST/v1/campaigns

Create a draft. Audience: everyone, a segment, or ad-hoc filter conditions.

sk_ · sk_test_
GET/v1/campaigns/{id}

Get one campaign, with a live audience estimate while in draft.

sk_ · sk_test_
PATCH/v1/campaigns/{id}

Update (drafts only).

sk_ · sk_test_
DELETE/v1/campaigns/{id}

Delete (cancel a sending campaign first).

sk_ · sk_test_
POST/v1/campaigns/{id}/send

Send now, or schedule for later with scheduledFor.

sk_ · sk_test_
POST/v1/campaigns/{id}/cancel

Stop a scheduled or in-flight campaign.

sk_ · sk_test_
GET/v1/campaigns/{id}/recipients

Recipient snapshot with per-recipient outcome and A/B variant.

sk_ · sk_test_
GET/v1/campaigns/{id}/links

Per-link click counts (top 50).

sk_ · sk_test_
POST/v1/campaigns/{id}/resend-non-openers

Create a follow-up draft targeting recipients who never opened.

sk_ · sk_test_
POST/v1/campaigns/{id}/test-send

Send the campaign content to one address (subject prefixed "[Test]").

sk_ · sk_test_
POST/v1/campaigns/{id}/duplicate

Clone content and targeting as a new draft; stats and A/B decision are reset.

sk_ · sk_test_

Workflows

Event-triggered automation graphs: delays, branches, waits — every contact walks at their own pace.

GET/v1/workflows

List workflows.

sk_ · sk_test_
POST/v1/workflows

Create a draft workflow from a validated node/edge graph.

sk_ · sk_test_
GET/v1/workflows/{id}

Get one workflow with per-node aggregates (stepStats).

sk_ · sk_test_
PATCH/v1/workflows/{id}

Update; also activates or pauses via status.

sk_ · sk_test_
DELETE/v1/workflows/{id}

Delete a workflow.

sk_ · sk_test_
POST/v1/workflows/{id}/enroll

Manually enroll a contact by email (workflow must be active).

sk_ · sk_test_
GET/v1/workflows/{id}/executions

List executions with contact emails; filter by status.

sk_ · sk_test_
GET/v1/workflows/executions/{executionId}

One contact's walk through the graph with the ordered step log.

sk_ · sk_test_
POST/v1/workflows/{id}/duplicate

Clone the graph/trigger/reentry as a new paused workflow (never auto-activated).

sk_ · sk_test_
POST/v1/workflows/{id}/executions/{executionId}/cancel

Cancel one active or waiting execution.

sk_ · sk_test_
POST/v1/workflows/{id}/executions/cancel-all

Cancel every active/waiting execution for this workflow.

sk_ · sk_test_

Webhooks

Signed event deliveries to your endpoints, retried with backoff for ~24 hours.

GET/v1/webhooks

List endpoints.

sk_ · sk_test_
POST/v1/webhooks

Register an endpoint; the response includes the whsec_… signing secret.

sk_ · sk_test_
PATCH/v1/webhooks/{id}

Update URL, event patterns, or enabled state.

sk_ · sk_test_
DELETE/v1/webhooks/{id}

Delete an endpoint.

sk_ · sk_test_
GET/v1/webhooks/{id}/deliveries

Delivery log for one endpoint, newest first, cursor-paginated.

sk_ · sk_test_
POST/v1/webhooks/deliveries/{id}/replay

Reset a delivery (any status) and re-attempt it immediately.

sk_ · sk_test_

Suppressions

The do-not-send list: hard bounces, complaints, unsubscribes, and manual blocks.

GET/v1/suppressions

List suppressions; filter by reason.

sk_ · sk_test_
GET/v1/suppressions/check

Is this address suppressed, and why?

sk_ · sk_test_
POST/v1/suppressions

Manually suppress an address.

sk_ · sk_test_
DELETE/v1/suppressions/{id}

Remove a suppression.

sk_ · sk_test_

Verify

Email address validation before you send.

POST/v1/verify

Validate an address: syntax, MX records, disposable domain, role account.

sk_ · sk_test_

Billing

Usage, monthly caps, reputation, and invoices.

GET/v1/billing

Current-month usage (overall and per-category), caps, computed reputation across 24h/7d/all-time, and the last 12 invoices.

sk_ · sk_test_
PATCH/v1/billing

Set or clear (null) the overall cap and/or any per-category cap (transactional/campaign/workflow/inbound). Provide any subset.

sk_ · sk_test_
POST/v1/billing/invoices/{id}/order

Create (or fetch) the Razorpay payment order for an issued invoice.

sk_ · sk_test_

Inbound

Inbound mail ingestion — normally called by the SMTP relay's unauthenticated listener.

POST/v1/inbound

Store an inbound message, upsert the sender as a contact, and emit email.received.

sk_ · sk_test_

Meta

API metadata.

GET/v1/openapi.json

The OpenAPI 3.1 description of this API.

no auth