Docs
Everything you need to send your first email and wire Relayly into a production app — auth, sending, webhooks, errors, SDKs.
Quickstart
Three things to do before your first send:
- Sign up at app.relayly.io. Free tier mints a working API key on the spot.
- Verify a sending domain by adding the SPF, DKIM, and DMARC records the dashboard shows you. Most DNS providers propagate within 60 seconds.
- Hit the API. One
curl, sample below.
@sandbox.relayly.io work without DNS. They land in the recipient's inbox only if the recipient is also a Relayly user — useful for testing the integration shape, not deliverability.Authentication
Every request carries an X-API-Key header. Keys are scoped per account and rotated from the dashboard. Two key types:
| Prefix | Purpose | Where to use |
|---|---|---|
ek_test_ | Test mode — sends are accepted, simulated, and logged but never delivered. | Local dev, CI. |
ek_live_ | Live mode — real sends count against your quota. | Production. |
You can also scope a key to specific permissions (e.g. email.send only, no contacts.write). See SDKs for language-specific clients.
Your first send
curl -X POST https://api.relayly.io/v1/email/send \ -H 'X-API-Key: ek_live_…' \ -H 'Content-Type: application/json' \ -d '{ "from": {"email": "hello@yourdomain.com", "name": "Your App"}, "to": [{"email": "user@example.com"}], "subject": "Welcome to your app", "html": "<p>Glad you signed up.</p>", "text": "Glad you signed up." }' # 200 OK # {"message_id":"01HVQ8K7FJ4M…","status":"queued"}
import Relayly from "@relayly/node"; const client = new Relayly({ apiKey: process.env.RELAYLY_API_KEY }); await client.email.send({ from: { email: "hello@yourdomain.com", name: "Your App" }, to: [{ email: "user@example.com" }], subject: "Welcome to your app", html: "<p>Glad you signed up.</p>", });
from relayly import Relayly client = Relayly(api_key=os.environ["RELAYLY_API_KEY"]) client.email.send( from_={"email": "hello@yourdomain.com", "name": "Your App"}, to=[{"email": "user@example.com"}], subject="Welcome to your app", html="<p>Glad you signed up.</p>", )
import "github.com/relayly/relayly-go" client := relayly.NewClient(os.Getenv("RELAYLY_API_KEY")) _, err := client.Email.Send(ctx, &relayly.SendRequest{ From: &relayly.Address{Email: "hello@yourdomain.com", Name: "Your App"}, To: []relayly.Address{{Email: "user@example.com"}}, Subject: "Welcome to your app", HTML: "<p>Glad you signed up.</p>", })
require "relayly" client = Relayly::Client.new(api_key: ENV["RELAYLY_API_KEY"]) client.email.send( from: { email: "hello@yourdomain.com", name: "Your App" }, to: [{ email: "user@example.com" }], subject: "Welcome to your app", html: "<p>Glad you signed up.</p>" )
use Relayly\Client; $client = new Client(getenv("RELAYLY_API_KEY")); $client->email->send([ "from" => ["email" => "hello@yourdomain.com", "name" => "Your App"], "to" => [["email" => "user@example.com"]], "subject" => "Welcome to your app", "html" => "<p>Glad you signed up.</p>", ]);
Send email — full reference
The POST /v1/email/send endpoint accepts the following fields:
| Field | Type | Required | Notes |
|---|---|---|---|
from | object | yes | {"email": …, "name": …}. The domain must be verified in your account. |
to | array | yes | Up to 50 recipients per call. |
cc, bcc | array | no | Same shape as to. Counted toward the 50-recipient limit. |
reply_to | array | no | Up to 5 addresses. |
subject | string | yes | Up to 998 bytes (RFC 5322). |
html / text | string | one required | If only html is supplied, we auto-derive a text body for clients that prefer plain. |
headers | object | no | Custom X-* headers. X-Entity-Ref-ID is preserved and indexed. |
tags | array<string> | no | Free-form tags for filtering in the messages log. |
metadata | object | no | Up to 16 key/value pairs (string→string), 256 bytes each. Echoed back in webhooks. |
attachments | array | no | See Attachments. |
scheduled_at | RFC 3339 | no | UTC timestamp up to 30 days out. See Scheduled sends. |
tracking | object | no | {"opens": true, "clicks": true}. Both default to your account's default; per-call override. |
region | string | no | "eu", "ca", or "auto" (default — chosen from recipient locale). |
Attachments
Up to 10 attachments per message, total 25 MB combined.
"attachments": [
{
"filename": "invoice.pdf",
"content_type": "application/pdf",
"content_b64": "JVBERi0xLjQKJeLjz9MK…"
}
]
For inline images, set "disposition": "inline" and reference via a cid: URL in the HTML.
Templates
Reusable HTML with Liquid-style placeholders. Create from the dashboard or via API:
POST /v1/templates
{
"name": "welcome-v2",
"subject": "Welcome, {{ first_name }}",
"html": "<p>Hi {{ first_name | default: 'there' }}.</p>"
}
Then send by reference:
POST /v1/email/send
{
"from": { … },
"to": [{ "email": "user@example.com" }],
"template": "welcome-v2",
"variables": { "first_name": "Alex" }
}
Scheduled sends
Pass an scheduled_at timestamp (UTC, RFC 3339, up to 30 days out). The send is queued and dispatched within 1 second of the target time, subject to your tier's quota at that moment. Cancel before delivery with DELETE /v1/email/{message_id}.
Open + click tracking
When enabled, a 1×1 transparent pixel is appended to the HTML body and clickable links are rewritten to a tracker URL on t.relayly.io. The tracker subdomain is intentionally separate from your sending domain so click traffic never affects your DKIM-signing reputation. Disable per-message with "tracking": { "opens": false, "clicks": false }.
Webhooks
Configure one or more webhook endpoints under Settings → Webhooks. Every event is signed with HMAC-SHA256 using the secret shown when you create the endpoint:
X-Relayly-Signature: t=1714672000,v1=<hex hmac>
The signed payload is "{t}.{raw_body}". Reject any request more than 5 minutes old to defeat replay attacks. Verifier in 6 lines of Node:
import crypto from "node:crypto";
const expected = crypto
.createHmac("sha256", process.env.RELAYLY_WEBHOOK_SECRET)
.update(`${t}.${rawBody}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) reject();
Event types
| Event | When |
|---|---|
email.queued | Accepted by API, awaiting worker pickup. |
email.sent | Handed to upstream MTA / accepted by recipient mail server. |
email.delivered | Recipient SMTP returned 250 — message landed in their queue. |
email.bounced | Hard or soft bounce. Includes bounce_type and SMTP diagnostic. |
email.complained | Recipient pressed "Spam" — recipient is automatically suppressed. |
email.opened | Tracking pixel loaded. |
email.clicked | Tracked link redirected. |
email.unsubscribed | List-Unsubscribe link or one-click POST hit. |
email.failed | Permanent failure after retries — no further attempts. |
Suppression list
Bounced and complained recipients land in the suppression list automatically. Future sends to those addresses are short-circuited at API time with a 422 suppressed error so you don't accidentally damage reputation. View, search, and remove entries via GET/DELETE /v1/suppressions/{email}.
Domain verification
Add a domain under Domains → New. The dashboard shows three TXT records:
| Type | Host | Purpose |
|---|---|---|
| TXT | @ | SPF — adds include:_spf.relayly.io. |
| TXT | esp1._domainkey | DKIM public key (2048-bit RSA, generated per-account). |
| TXT | _dmarc | DMARC policy. We suggest p=none; rua=mailto:dmarc@your-domain while you build alignment. |
Optional but recommended: a CNAME for a custom Return-Path domain so bounce paths align with your visible From:.
DKIM, SPF, DMARC
Each verified domain gets its own DKIM key. We never share keys across accounts or rotate them without your action. Read more about our crypto handling →
IP warmup
Dedicated IPs start at 50/day and ramp on a 14-day curve based on actual delivery success per major mailbox provider. If Gmail starts deferring, the cap tightens — for Gmail specifically, not globally. View the curve under Deliverability → IP pools.
Custom Return-Path
By default the bounce envelope is bounce@vrp.relayly.io. To align: add a CNAME record (bounce.your-domain → vrp.relayly.io) under Domains → DNS → Advanced and we'll send MAIL FROM: bounce@your-domain. Helps DMARC alignment scores.
Errors
Errors return a JSON body with error.code and error.message. Common codes:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | validation_failed | Field-level validation error. error.fields[] lists each. |
| 401 | unauthorized | Missing or invalid API key. |
| 403 | domain_not_verified | From-domain has no green DKIM check. |
| 422 | suppressed | Recipient is on your suppression list. |
| 422 | quota_exceeded | Daily or monthly quota reached. |
| 429 | rate_limited | Burst above tier limit. Retry after Retry-After header. |
| 5xx | internal | Our problem. Safe to retry with idempotency key. |
Rate limits
| Plan | Burst | Sustained |
|---|---|---|
| Free | 20 req/sec | 100/day |
| Pro | 100 req/sec | 2,500/day · 50,000/month |
| Scale | 500 req/sec | 25,000/day · 500,000/month |
| Enterprise | negotiated | 1M/day default |
Send an Idempotency-Key header to safely retry failed requests without duplicate sends.
SDKs
| Language | Install | Repo |
|---|---|---|
| Node / TypeScript | npm i @relayly/node | relayly-node |
| Python | pip install relayly | relayly-python |
| Go | go get github.com/relayly/relayly-go | relayly-go |
| Ruby | gem install relayly | relayly-ruby |
| PHP | composer require relayly/relayly-php | relayly-php |
| Java | Maven / Gradle | relayly-java |
OpenAPI 3.1 spec: GET /v1/openapi.json. Use it with any auto-gen tool. Postman collection: github.com/relayly/postman.
SMTP relay
If your stack already speaks SMTP, point it at smtp.relayly.io:587 with STARTTLS:
Host: smtp.relayly.io Port: 587 (STARTTLS) or 465 (TLS) Username: apikey Password: ek_live_…
Same per-message metadata (tags, custom headers, idempotency) is supported via X-Relayly-* headers. Webhooks fire identically to the REST path.