Guide · REST API · PHP

Send email with PHP

To send email with PHP, build the message as an array, json_encode it and POST it to tinyRelay's /email/campaign endpoint with cURL or Guzzle, with your API key in the X-RELAY-ACCESS-TOKEN header.

STATUS SWITCHER
What send_email() does with each status
202 · Acceptedsend_email() returns
tinyrelay.php · cURLCopy
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // 202
return json_decode($response, true)['requestId'];
// "833c5151-5a98-4bad-a30f-7cc816352b7a"

Store the requestId on the order. Delivery results arrive later as webhook events.

400 · Bad Requestsend_email() throws
tinyrelay.php · cURLCopy
if ($status !== 202) {  // $status is 400
    throw new RuntimeException("tinyRelay $status: $response");
}
// tinyRelay 400: {"message":"Some error message"}

The body failed validation. Read the message field, fix the field it names and resend.

401 · Key missing or invalidsend_email() throws
tinyrelay.php · cURLCopy
if ($status !== 202) {  // $status is 401
    throw new RuntimeException("tinyRelay $status: $response");
}

Check the header is named X-RELAY-ACCESS-TOKEN and the call comes from a server whose IP is on your allowlist.

405 · Method Not Allowedsend_email() throws
tinyrelay.php · cURLCopy
if ($status !== 202) {  // $status is 405
    throw new RuntimeException("tinyRelay $status: $response");
}

Send a POST to /email/campaign. Other methods are refused.

412 · Metadata limitssend_email() throws
tinyrelay.php · cURLCopy
if ($status !== 202) {  // $status is 412
    throw new RuntimeException("tinyRelay $status: $response");
}

More than 10 merged metadata keys, or more than 500 recipients with their own metadata. Nothing was sent.

The five statuses the Send Email docs list. The 400 body is the docs example; bodies for 401, 405 and 412 are not documented.

Endpoint
POST /email/campaign
Auth header
X-RELAY-ACCESS-TOKEN
Success
202 · requestId
Clients
ext-curl · Guzzle

Before you start

Accounts are set up with you during onboarding. For the DKIM record and the rest of your sending setup, see transactional email deliverability.

No package to install. tinyRelay works with any language that can make HTTP requests. Everything below uses a standard HTTP client.

Send email from PHP, step by step

The example sends a receipt and attaches the order and invoice IDs as metadata.

1. Store your API key

Keep the key out of your code. Set it as an environment variable on the server that calls tinyRelay.

export TINYRELAY_API_KEY="your-api-key"
$env:TINYRELAY_API_KEY = "your-api-key"

2. Build the request body

Build an associative array. Keys match the JSON field names exactly, including replyTo and campaignId.

email.phpCopy
<?php
$email = [
    'subject' => 'Your receipt for order SO-1001',
    'body'    => '<p>Hi [FIRST_NAME], thanks for your order. Your receipt is below.</p>',
    'sender'  => [
        'from'    => ['name' => 'Acme Store', 'email' => 'orders@acme.com'],
        'replyTo' => ['name' => 'Acme Support', 'email' => 'support@acme.com'],
    ],
    'recipients' => [
        'to' => [
            [
                'name'       => 'Ann Lee',
                'email'      => 'ann@example.com',
                'attributes' => ['FIRST_NAME' => 'Ann'],
            ],
        ],
    ],
    'campaignId' => 'receipt_SO-1001',
    'metadata'   => ['order_id' => 'SO-1001', 'invoice_id' => 'INV-88'],
];

3. POST it to /email/campaign

Both tabs define a send_email() function that returns the requestId and throws on anything other than 202.

tinyrelay.php
<?php
// tinyrelay.php  (needs the curl extension)
function send_email(array $email): string
{
    $ch = curl_init('https://api.tinyemail.com/v1/relay/email/campaign');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'X-RELAY-ACCESS-TOKEN: ' . getenv('TINYRELAY_API_KEY'),
        ],
        CURLOPT_POSTFIELDS => json_encode($email, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
    ]);

    $response = curl_exec($ch);
    if ($response === false) {
        throw new RuntimeException('tinyRelay request failed: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

    if ($status !== 202) {
        throw new RuntimeException("tinyRelay $status: $response");
    }
    return json_decode($response, true)['requestId'];
}
<?php
// tinyrelay.php  (composer require guzzlehttp/guzzle)
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

function send_email(array $email): string
{
    $client = new Client([
        'base_uri' => 'https://api.tinyemail.com/v1/relay/',
        'timeout'  => 10,
    ]);

    try {
        $response = $client->post('email/campaign', [
            'headers' => ['X-RELAY-ACCESS-TOKEN' => getenv('TINYRELAY_API_KEY')],
            'json'    => $email,
        ]);
    } catch (RequestException $e) {
        // Guzzle throws on 4xx and 5xx by default
        $res = $e->getResponse();
        $detail = $res ? $res->getStatusCode() . ': ' . $res->getBody() : $e->getMessage();
        throw new RuntimeException('tinyRelay ' . $detail, 0, $e);
    }

    return json_decode((string) $response->getBody(), true)['requestId'];
}

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

4. Check for a 202

send_email($email) returns the requestId. Store it on the order so a support agent can trace the receipt.

The 202 response

A request that passes validation returns 202. That means tinyRelay accepted the send; what happens to each email afterwards arrives as webhook events.

202 Accepted · response bodyCopy
{
  "requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
  "message": "SUCCESS"
}
  • requestId identifies this send. Store it next to your own record.
  • If you did not set campaignId, the requestId is used as the campaign ID, and every webhook event for the send carries it.
  • A 202 is not delivery. Watch for DELIVERED or BOUNCED events.

Send receipts to many customers in one PHP request

Loop over your orders to build recipients.to. Each customer gets their own merge attributes and their own order_id in metadata.

batch.phpCopy
<?php
$to = [];
foreach ($orders as $order) {
    $to[] = [
        'email'      => $order['customer_email'],
        'attributes' => ['FIRST_NAME' => $order['first_name'], 'ORDER_ID' => $order['id']],
        'metadata'   => ['order_id' => $order['id']],
    ];
}

$requestId = send_email([
    'subject'    => 'Your Acme receipt',
    'body'       => '<p>Hi [FIRST_NAME], here is your receipt for order [ORDER_ID].</p>',
    'sender'     => ['from' => ['name' => 'Acme Store', 'email' => 'orders@acme.com']],
    'metadata'   => ['tenant_id' => 'acme', 'app' => 'billing'],
    'recipients' => ['to' => $to],
]);

The rules, from the Send Email docs:

10keys
Merged metadata map

Request-level and recipient-level maps merge. The cap is on the merged map.

Over412 · nothing sent
500recipients
With their own metadata

At most 500 recipients in one request may carry their own metadata.

Over412 · nothing sent
64chars
Key length

Each metadata key can be up to 64 characters.

Applies toevery key
256chars
Value length

Values up to 256 characters: flat strings, numbers or booleans.

Valuesflat only

attributes are merge tags rendered into the body, such as [FIRST_NAME], and never echoed on webhooks. metadata is never rendered into the email and is always echoed on webhooks.

"attributes": { "FIRST_NAME": "Ann" }     // in the email: "Hi Ann"
"metadata":   { "user_id": "u_1042" }    // on every webhook event

Request-level and recipient-level maps merge, and the recipient's keys win.

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

Values must be flat strings, numbers or booleans.

{ "order_id": "SO-1", "total": 42, "gift": true }   // flat: fine
{ "order": { "id": "SO-1" } }                       // nested object: not flat

Metadata is scoped to campaignId. Reusing a campaignId across batches replaces the earlier metadata, so use a unique one per batch or leave it out.

"campaignId": "password_reset_batch_0142"   // unique per batch
// or leave it out: the requestId is used as the campaign ID

PHP pitfall: empty arrays become JSON arrays

metadata and attributes must be JSON objects. json_encode turns an empty PHP array into [], not {}, so either leave the key out or pass an empty object.

json_encode behaviourCopy
<?php
json_encode(['metadata' => []]);              // {"metadata":[]}   an array, not an object
json_encode(['metadata' => new stdClass()]);  // {"metadata":{}}   an empty object
// Simplest: leave the key out when you have no metadata

Handle delivery webhooks

tinyRelay posts an event to your webhook endpoint for each thing that happens to an email. There are seven event types:

POST → your webhook endpoint · 7 event typessigned via Svix
  1. SENDSent to the receiving server
  2. DELIVEREDEmail delivered
  3. BOUNCEDHARD_BOUNCED or SOFT_BOUNCED · adds bounceCategory
  4. OPENOpened by the recipient
  5. CLICKA tracked link was clicked · adds url
  6. UNSUBSCRIBEUnsubscribe link clicked
  7. SPAMMarked as spam

SEND: the email was sent. Apps usually mark the message as sent against their record.

Each event carries eventType, receiver, requestId, campaignId and your merged metadata. BOUNCED events add bounceCategory, and CLICK events add the url that was clicked.

Verify, then deduplicate. Webhooks are signed with svix-id, svix-timestamp and svix-signature headers and delivered at least once, so verify each request and deduplicate on svix-id.

Read email webhooks and delivery events, then verify webhook signatures. Bounce categories are explained in the email bounce codes reference; field-level detail is in the webhook docs.

Troubleshooting

The Send Email endpoint documents four error statuses. Match the status you get to the row below.

StatusMeaningWhat to check
400MeaningBad RequestWhat to checkThe body failed validation. The response carries a message field; read it, fix the field it names and resend.
401MeaningAPI key missing or invalidWhat to checkCheck the header is named X-RELAY-ACCESS-TOKEN and holds the key issued at onboarding. If the key is right, confirm the server you are calling from has an IP on your allowlist.
405MeaningMethod Not AllowedWhat to checkSend a POST to /email/campaign. Other methods are refused.
412MeaningMetadata limits brokenWhat to checkThe merged metadata map has more than 10 keys, or more than 500 recipients carry their own metadata. Nothing is sent; trim the metadata and resend.

Full error schemas are in the Send Email API reference.

FAQ

Do I need Guzzle to send email with PHP?

No. The curl extension is enough. Guzzle is optional and gives you a shorter call with JSON encoding built in.

Check that metadata is a JSON object. An empty PHP array encodes as [], so leave the key out or pass new stdClass(). Also keep the merged map to 10 keys or fewer.

Yes, for the headers object in the request body. Send List-Unsubscribe and List-Unsubscribe-Post exactly as written, not in lowercase.

In an environment variable or your framework's secrets store, read with getenv or your config layer. Never commit it or expose it to the browser.

Next steps

Sources

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.