Counted on the merged map: 10 in total, not 10 per level.
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.
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.
What the email sending API sends back.
Five documented status codes. Pick one to see the body and what your code should do next.
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
{
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"message": "SUCCESS"
}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
{
"message": "Some error message"
}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
HTTP/1.1 401 Unauthorized
# Docs: "API key is missing or invalid"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
HTTP/1.1 405 Method Not Allowed
# Send with: POST /email/campaignA 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
HTTP/1.1 412 Precondition Failed
# Metadata limits: 10 keys merged,
# 500 recipients with their own metadataStatus 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.
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 # 202Base URL from current site samples; confirm in the docs.
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.
Per request. Recipients using only the request-level map are not counted.
Each metadata key can be up to 64 characters long.
Each value can be up to 256 characters long.
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: rejectedThe 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 usedAttributes 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 eventSource: Send Email reference, docs.tinyrelay.com.
Full Send Email reference →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.
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.
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.