REST APIPOST /email/campaign

Transactional email API. One request, every recipient.

tinyRelay's transactional email API sends password resets, sign-in codes, receipts and alerts with one HTTP POST. Each recipient gets their own merge attributes and metadata, and every delivery event comes back to your webhook with your IDs attached.

BUILD A SEND REQUEST · LIVE
−To · 2+
Request metadata
Per-recipient metadata
Open + click tracking
Link wrapping
One-click unsubscribe
POST /email/campaign
X-RELAY-ACCESS-TOKEN: $TINYRELAY_KEY

{
  "subject": "Reset your password",
  "body": "<p>Hi [FIRST_NAME], use this link to reset…</p>",
  "sender": { "from": { "name": "Acme", "email": "no-reply@acme.com" } },
  "metadata": { "tenant_id": "acme" },
  "recipients": { "to": [
    { "email": "ann@example.com", "attributes": { "FIRST_NAME": "Ann" }, "metadata": { "order_id": "SO-1" } },
    { "email": "bob@example.com", "attributes": { "FIRST_NAME": "Bob" }, "metadata": { "order_id": "SO-2" } }
  ] }
}

Flip a switch or add a recipient: the body rewrites itself. Values illustrative.

Responses

What the email sending API sends back.

Five documented status codes. Pick one to see the body and what your code should do next.

202 · Successful operation

Accepted. Keep the requestId.

The send was accepted. The requestId comes back on every webhook event for this send, and if you leave out campaignId it is used as the campaign ID, which is where metadata is stored.

  • Store requestId next to your own record
  • Wait for SEND, DELIVERED or BOUNCED on your webhook
application/json
{
  "requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
  "message": "SUCCESS"
}
400 · Bad Request

Something in the body is wrong.

The request could not be processed as sent. The message field says why. Fix the request rather than retrying it unchanged.

  • Log the message field
  • Do not retry the same body
application/json
{
  "message": "Some error message"
}
401 · Unauthorized

The API key is missing or invalid.

Check that the key is in the X-RELAY-ACCESS-TOKEN header and that it is the key we issued at onboarding. Remember that calls must also come from an allowlisted IP.

  • Check the header name and value
  • Load the key from a secret store, not client code
Response
HTTP/1.1 401 Unauthorized
# Docs: "API key is missing or invalid"
405 · Method Not Allowed

Wrong HTTP method.

The send endpoint takes POST. A GET or PUT to /email/campaign is refused.

  • Use POST with a JSON body
  • Set Content-Type: application/json
Response
HTTP/1.1 405 Method Not Allowed
# Send with: POST /email/campaign
412 · Metadata limit

A metadata rule was broken. Nothing was sent.

The merged metadata map went over 10 keys, or more than 500 recipients in the request carried their own metadata. The whole request is rejected before any email is sent, so fix it and send again.

  • Count keys after merging, not per level
  • Split large batches with per-recipient metadata
Response
HTTP/1.1 412 Precondition Failed
# Metadata limits: 10 keys merged,
# 500 recipients with their own metadata

Status codes and the 202 and 400 bodies are from the Send Email docs. Bodies for 401, 405 and 412 are not shown in the docs.

Code

An email API for developers in any language.

There is nothing to install. The API works with any language that can make HTTP requests: set one header, post JSON, read the requestId.

  • API key in the X-RELAY-ACCESS-TOKEN header
  • Calls from the IP addresses you gave us at setup
  • Send from a domain signed with the DKIM CNAME
curl -X POST "https://api.tinyemail.com/v1/relay/email/campaign" \
  -H "X-RELAY-ACCESS-TOKEN: $TINYRELAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Reset your password",
    "body": "<p>Hi [FIRST_NAME], use this link to reset your password.</p>",
    "sender": { "from": { "name": "Acme", "email": "no-reply@acme.com" } },
    "metadata": { "user_id": "u_1042" },
    "recipients": { "to": [
      { "email": "ann@example.com", "attributes": { "FIRST_NAME": "Ann" } }
    ] }
  }'
const res = await fetch("https://api.tinyemail.com/v1/relay/email/campaign", {
  method: "POST",
  headers: {
    "X-RELAY-ACCESS-TOKEN": process.env.TINYRELAY_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    subject: "Reset your password",
    body: "<p>Hi [FIRST_NAME], use this link to reset your password.</p>",
    sender: { from: { name: "Acme", email: "no-reply@acme.com" } },
    metadata: { user_id: "u_1042" },
    recipients: { to: [{ email: "ann@example.com", attributes: { FIRST_NAME: "Ann" } }] },
  }),
});
const { requestId } = await res.json(); // 202
import os, requests

res = requests.post(
    "https://api.tinyemail.com/v1/relay/email/campaign",
    headers={"X-RELAY-ACCESS-TOKEN": os.environ["TINYRELAY_KEY"]},
    json={
        "subject": "Reset your password",
        "body": "<p>Hi [FIRST_NAME], use this link to reset your password.</p>",
        "sender": {"from": {"name": "Acme", "email": "no-reply@acme.com"}},
        "metadata": {"user_id": "u_1042"},
        "recipients": {"to": [{"email": "ann@example.com", "attributes": {"FIRST_NAME": "Ann"}}]},
    },
    timeout=10,
)
print(res.status_code, res.json()["requestId"])  # 202
<?php
$payload = json_encode([
  'subject'    => 'Reset your password',
  'body'       => '<p>Hi [FIRST_NAME], use this link to reset your password.</p>',
  'sender'     => ['from' => ['name' => 'Acme', 'email' => 'no-reply@acme.com']],
  'metadata'   => ['user_id' => 'u_1042'],
  'recipients' => ['to' => [['email' => 'ann@example.com', 'attributes' => ['FIRST_NAME' => 'Ann']]]],
]);

$ch = curl_init('https://api.tinyemail.com/v1/relay/email/campaign');
curl_setopt_array($ch, [
  CURLOPT_POST           => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER     => [
    'X-RELAY-ACCESS-TOKEN: ' . getenv('TINYRELAY_KEY'),
    'Content-Type: application/json',
  ],
  CURLOPT_POSTFIELDS     => $payload,
]);
$response = curl_exec($ch); // 202: {"requestId": "...", "message": "SUCCESS"}
curl_close($ch);
package main

import (
	"bytes"
	"net/http"
	"os"
)

func main() {
	body := []byte(`{
	  "subject": "Reset your password",
	  "body": "<p>Hi [FIRST_NAME], use this link to reset your password.</p>",
	  "sender": {"from": {"name": "Acme", "email": "no-reply@acme.com"}},
	  "metadata": {"user_id": "u_1042"},
	  "recipients": {"to": [{"email": "ann@example.com", "attributes": {"FIRST_NAME": "Ann"}}]}
	}`)
	req, _ := http.NewRequest("POST", "https://api.tinyemail.com/v1/relay/email/campaign", bytes.NewReader(body))
	req.Header.Set("X-RELAY-ACCESS-TOKEN", os.Getenv("TINYRELAY_KEY"))
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close() // expect 202 Accepted
}
require "net/http"
require "json"

uri = URI("https://api.tinyemail.com/v1/relay/email/campaign")
req = Net::HTTP::Post.new(uri)
req["X-RELAY-ACCESS-TOKEN"] = ENV.fetch("TINYRELAY_KEY")
req["Content-Type"] = "application/json"
req.body = {
  subject: "Reset your password",
  body: "<p>Hi [FIRST_NAME], use this link to reset your password.</p>",
  sender: { from: { name: "Acme", email: "no-reply@acme.com" } },
  metadata: { user_id: "u_1042" },
  recipients: { to: [{ email: "ann@example.com", attributes: { FIRST_NAME: "Ann" } }] }
}.to_json

res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code # 202

Base URL from current site samples; confirm in the docs.

Metadata rules

Application email sending, tied to your own IDs.

Attach an order ID, tenant ID or user ID to a send and it comes back on every webhook event. These are the rules, straight from the docs.

10keys
Keys per send

Counted on the merged map: 10 in total, not 10 per level.

Over the limit412 · nothing sent
500recipients
With their own metadata

Per request. Recipients using only the request-level map are not counted.

Over the limit412 · nothing sent
64chars
Key length

Each metadata key can be up to 64 characters long.

LongerRejected
256chars
Value length

Each value can be up to 256 characters long.

LongerRejected

Flat strings, numbers or booleans. Numbers and booleans come back as strings. Nested objects, arrays and null are not supported.

// you send
{ "order_id": "SO-1", "total": 42, "gift": true }
// every webhook event echoes
{ "order_id": "SO-1", "total": "42", "gift": "true" }

Keys are normalised to lowercase snake_case. Two keys that normalise to the same name are rejected.

"OrderID"   → "order_id"
"TenantId"  → "tenant_id"
// "OrderID" and "order_id" in one map normalise to the same key: rejected

The request-level map and each recipient's map merge. Where both set a key, the recipient's value wins.

// request    { "tenant_id": "acme", "plan": "pro" }
// recipient  { "plan": "team" }
// merged     { "tenant_id": "acme", "plan": "team" }

Metadata is stored against campaignId. Reusing a campaignId replaces the earlier metadata, per-recipient entries included. Use a unique campaignId per batch, or leave it out and the requestId is used.

"campaignId": "receipts-2041"   // unique per batch
// or omit it: the requestId from the 202 response is used

Attributes are merge tags rendered into the body and never echoed. Metadata is never rendered and always echoed.

"attributes": { "FIRST_NAME": "Ann" }    // in the email: "Hi Ann"
"metadata":   { "order_id": "SO-1" }   // on every webhook event

Source: Send Email reference, docs.tinyrelay.com.

Full Send Email reference →
In every request

The controls a transactional sender needs.

Beyond subject, body and recipients, these fields and headers shape how each send behaves.

Tracking switch

Open and click tracking are on by default. disableTrackingLinks: true turns both off for the whole request, which suits sign-in codes.

Link wrapping

Send X-Link-Wrapping: true and tracked links hide the destination in an opaque, tamper-proof token. Off unless you send it.

One-click unsubscribe

Send your own List-Unsubscribe and List-Unsubscribe-Post headers for RFC 8058 one-click unsubscribe. One-click unsubscribe guide.

Events back

SEND, DELIVERED, BOUNCED, OPEN, CLICK, UNSUBSCRIBE and SPAM come back as signed webhooks. Email webhooks.

Prefer to keep your existing mailer? The same infrastructure takes mail over SMTP too. Compare with the SMTP relay service, or read how API keys and IP allowlisting protect each call.

FAQ

Email API questions.

The short answers. The reference is in the docs.

Send Email reference →
Is there an SDK for the tinyRelay email API?

There are no packages to install. The API works with any language that can make HTTP requests, and this page shows examples for cURL, Node.js, Python, PHP, Go and Ruby.

Send your API key in the X-RELAY-ACCESS-TOKEN header. Requests must also come from an IP address you gave us during setup, because calling IPs are allowlisted.

Yes. List recipients in recipients.to. Each recipient can carry their own merge attributes, which are rendered into the email, and their own metadata, which is echoed on their webhook events.

Attributes are merge tags rendered into the email body and are never echoed on webhooks. Metadata is never rendered into the email and is always echoed on webhook events.

A 412 means the request broke a metadata limit: more than 10 keys after merging, or more than 500 recipients carrying their own metadata. It is rejected before any email is sent.

Yes. Set disableTrackingLinks to true and the request gets no link rewriting and no open pixel. It is one switch for both, and it applies to the whole request.

Get started

Send transactional email with a service that reports back.

SMTP relay or REST API, standalone or alongside tinyEmail. Tell us what you send and our team sets you up.