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:

  1. Sign up at app.relayly.io. Free tier mints a working API key on the spot.
  2. Verify a sending domain by adding the SPF, DKIM, and DMARC records the dashboard shows you. Most DNS providers propagate within 60 seconds.
  3. Hit the API. One curl, sample below.
Want to skip domain verification? Sandbox sends from @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:

PrefixPurposeWhere 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:

FieldTypeRequiredNotes
fromobjectyes{"email": …, "name": …}. The domain must be verified in your account.
toarrayyesUp to 50 recipients per call.
cc, bccarraynoSame shape as to. Counted toward the 50-recipient limit.
reply_toarraynoUp to 5 addresses.
subjectstringyesUp to 998 bytes (RFC 5322).
html / textstringone requiredIf only html is supplied, we auto-derive a text body for clients that prefer plain.
headersobjectnoCustom X-* headers. X-Entity-Ref-ID is preserved and indexed.
tagsarray<string>noFree-form tags for filtering in the messages log.
metadataobjectnoUp to 16 key/value pairs (string→string), 256 bytes each. Echoed back in webhooks.
attachmentsarraynoSee Attachments.
scheduled_atRFC 3339noUTC timestamp up to 30 days out. See Scheduled sends.
trackingobjectno{"opens": true, "clicks": true}. Both default to your account's default; per-call override.
regionstringno"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();
Retries. Non-2xx responses are retried with exponential backoff for up to 24 hours. Replays are visible in the dashboard under Webhooks → Delivery log.

Event types

EventWhen
email.queuedAccepted by API, awaiting worker pickup.
email.sentHanded to upstream MTA / accepted by recipient mail server.
email.deliveredRecipient SMTP returned 250 — message landed in their queue.
email.bouncedHard or soft bounce. Includes bounce_type and SMTP diagnostic.
email.complainedRecipient pressed "Spam" — recipient is automatically suppressed.
email.openedTracking pixel loaded.
email.clickedTracked link redirected.
email.unsubscribedList-Unsubscribe link or one-click POST hit.
email.failedPermanent 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:

TypeHostPurpose
TXT@SPF — adds include:_spf.relayly.io.
TXTesp1._domainkeyDKIM public key (2048-bit RSA, generated per-account).
TXT_dmarcDMARC 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:

HTTPCodeMeaning
400validation_failedField-level validation error. error.fields[] lists each.
401unauthorizedMissing or invalid API key.
403domain_not_verifiedFrom-domain has no green DKIM check.
422suppressedRecipient is on your suppression list.
422quota_exceededDaily or monthly quota reached.
429rate_limitedBurst above tier limit. Retry after Retry-After header.
5xxinternalOur problem. Safe to retry with idempotency key.

Rate limits

PlanBurstSustained
Free20 req/sec100/day
Pro100 req/sec2,500/day · 50,000/month
Scale500 req/sec25,000/day · 500,000/month
Enterprisenegotiated1M/day default

Send an Idempotency-Key header to safely retry failed requests without duplicate sends.

SDKs

LanguageInstallRepo
Node / TypeScriptnpm i @relayly/noderelayly-node
Pythonpip install relaylyrelayly-python
Gogo get github.com/relayly/relayly-gorelayly-go
Rubygem install relaylyrelayly-ruby
PHPcomposer require relayly/relayly-phprelayly-php
JavaMaven / Gradlerelayly-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.

Need help? Email hello@relayly.io — humans answer. Critical-incident SLA on Scale and Enterprise tiers.