# Transactional email API

> Send, batch, schedule, reschedule, cancel and look up transactional email through the Reggio Digital API, with attachments and test addresses.

Source: https://reggiodigital.com/developers/email

Every call on this page needs a key with the `email:send` ability, on an account with a transactional email plan and at least one verified sending domain. The dashboard's [Send API page](https://reggiodigital.com/email/api) shows the same examples with the account's own key and sender filled in.

## Sending one email

`POST /api/v1/emails`

cURL:

```bash
curl -X POST https://reggiodigital.com/api/v1/emails \
  -H "Authorization: Bearer $REGGIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "from": "hello@yourdomain.com",
      "to": [
          "success@simulator.amazonses.com"
      ],
      "subject": "Your receipt",
      "html": "<p>Thanks for your order.</p>",
      "text": "Thanks for your order.",
      "tags": {
          "kind": "receipt",
          "order": "1042"
      }
  }'
```

Laravel:

```php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.reggio.key'))
    ->acceptJson()
    ->post('https://reggiodigital.com/api/v1/emails', [
        'from' => 'hello@yourdomain.com',
        'to' => [
            'success@simulator.amazonses.com',
        ],
        'subject' => 'Your receipt',
        'html' => '<p>Thanks for your order.</p>',
        'text' => 'Thanks for your order.',
        'tags' => [
            'kind' => 'receipt',
            'order' => '1042',
        ],
    ]);

if ($response->failed()) {
    throw new RuntimeException($response->json('error.message'));
}

$body = $response->json();
```

PHP:

```php
$payload = [
    'from' => 'hello@yourdomain.com',
    'to' => [
        'success@simulator.amazonses.com',
    ],
    'subject' => 'Your receipt',
    'html' => '<p>Thanks for your order.</p>',
    'text' => 'Thanks for your order.',
    'tags' => [
        'kind' => 'receipt',
        'order' => '1042',
    ],
];

$ch = curl_init('https://reggiodigital.com/api/v1/emails');

curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('REGGIO_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
]);

$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['message']);
}
```

Node.js:

```js
const response = await fetch("https://reggiodigital.com/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.REGGIO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
      "from": "hello@yourdomain.com",
      "to": [
          "success@simulator.amazonses.com"
      ],
      "subject": "Your receipt",
      "html": "<p>Thanks for your order.</p>",
      "text": "Thanks for your order.",
      "tags": {
          "kind": "receipt",
          "order": "1042"
      }
  }),
});

const body = await response.json();

if (!response.ok) {
  throw new Error(body.error.message);
}
```

A message we accept answers `202`:

```json
{
  "id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
  "to": ["success@simulator.amazonses.com"],
  "suppressed": []
}
```

Store the `id`. It is how you [look the message up](#checking-what-happened) later and how our [webhooks](https://reggiodigital.com/developers/webhooks) refer to it. Treat it as an opaque string: a message sent now and a message [held for later](#sending-later) have ids of different shapes.

`to` is the recipients we are sending to. `suppressed` is any recipient we skipped because we have stopped sending to that address after a hard bounce, a spam complaint or an unsubscribe. A skipped address is not an error, so check the list rather than treating the send as failed. If every recipient is suppressed, the send is refused with a `422`.

## The fields

| Field | Required | What it is |
| --- | --- | --- |
| `from` | Yes | An address on one of the account's verified sending domains, on its own. A name in front of it, as in `Acme <hello@yourdomain.com>`, is refused with a `422`. |
| `to` | Yes | A list of up to 50 addresses. Always a list, even for one recipient. |
| `subject` | Without a template | The subject line, up to 998 characters. |
| `html` | Without a template, `html` or `text` | The formatted body. |
| `text` | Without a template, `html` or `text` | The plain body. Send both when you can. |
| `template_id` | Instead of a body | A published [template](https://reggiodigital.com/developers/templates). Leave out `subject`, `html` and `text`. |
| `variables` | With a template | The values the template fills in, as name and value pairs. |
| `reply_to` | No | Where replies go, if not the from address. |
| `cc`, `bcc` | No | Lists of up to 50 addresses each. |
| `tags` | No | Up to 20 name and value pairs for your own use, such as `{"kind": "receipt", "order": "1042"}`. Names use letters, numbers, dashes, underscores, colons and dots; values are strings up to 128 characters. They come back when you look a message up and in webhooks. |
| `headers` | No | Up to 10 extra email headers. Any name starting with `X-`, plus `List-Unsubscribe` and `List-Unsubscribe-Post`. Values cannot contain line breaks. |
| `attachments` | No | Up to 20 files. See [attachments](#attachments). |
| `scheduled_at` | No | A time up to 30 days ahead to send it instead of now. See [sending later](#sending-later). |

If Gmail threads a run of similar receipts into one conversation, set `X-Entity-Ref-ID` in `headers` to something unique per message.

## Attachments

Give each attachment a `filename` and either `content`, the file as base64, or `path`, an https link we download it from. A link saves your server holding a large file in memory.

cURL:

```bash
curl -X POST https://reggiodigital.com/api/v1/emails \
  -H "Authorization: Bearer $REGGIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "from": "hello@yourdomain.com",
      "to": [
          "success@simulator.amazonses.com"
      ],
      "subject": "Your receipt",
      "html": "<p>Thanks for your order.</p><img src=\"cid:logo\" alt=\"\">",
      "text": "Thanks for your order.",
      "tags": {
          "kind": "receipt",
          "order": "1042"
      },
      "attachments": [
          {
              "filename": "receipt-1042.txt",
              "content": "T3JkZXIgMTA0MgpUb3RhbDogJDQyLjAwCg=="
          },
          {
              "filename": "logo.png",
              "content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAMAASsJTYQAAAAASUVORK5CYII=",
              "content_id": "logo"
          }
      ]
  }'
```

Laravel:

```php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.reggio.key'))
    ->acceptJson()
    ->post('https://reggiodigital.com/api/v1/emails', [
        'from' => 'hello@yourdomain.com',
        'to' => [
            'success@simulator.amazonses.com',
        ],
        'subject' => 'Your receipt',
        'html' => '<p>Thanks for your order.</p><img src="cid:logo" alt="">',
        'text' => 'Thanks for your order.',
        'tags' => [
            'kind' => 'receipt',
            'order' => '1042',
        ],
        'attachments' => [
            [
                'filename' => 'receipt-1042.txt',
                'content' => 'T3JkZXIgMTA0MgpUb3RhbDogJDQyLjAwCg==',
            ],
            [
                'filename' => 'logo.png',
                'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAMAASsJTYQAAAAASUVORK5CYII=',
                'content_id' => 'logo',
            ],
        ],
    ]);

if ($response->failed()) {
    throw new RuntimeException($response->json('error.message'));
}

$body = $response->json();
```

PHP:

```php
$payload = [
    'from' => 'hello@yourdomain.com',
    'to' => [
        'success@simulator.amazonses.com',
    ],
    'subject' => 'Your receipt',
    'html' => '<p>Thanks for your order.</p><img src="cid:logo" alt="">',
    'text' => 'Thanks for your order.',
    'tags' => [
        'kind' => 'receipt',
        'order' => '1042',
    ],
    'attachments' => [
        [
            'filename' => 'receipt-1042.txt',
            'content' => 'T3JkZXIgMTA0MgpUb3RhbDogJDQyLjAwCg==',
        ],
        [
            'filename' => 'logo.png',
            'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAMAASsJTYQAAAAASUVORK5CYII=',
            'content_id' => 'logo',
        ],
    ],
];

$ch = curl_init('https://reggiodigital.com/api/v1/emails');

curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('REGGIO_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
]);

$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['message']);
}
```

Node.js:

```js
const response = await fetch("https://reggiodigital.com/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.REGGIO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
      "from": "hello@yourdomain.com",
      "to": [
          "success@simulator.amazonses.com"
      ],
      "subject": "Your receipt",
      "html": "<p>Thanks for your order.</p><img src=\"cid:logo\" alt=\"\">",
      "text": "Thanks for your order.",
      "tags": {
          "kind": "receipt",
          "order": "1042"
      },
      "attachments": [
          {
              "filename": "receipt-1042.txt",
              "content": "T3JkZXIgMTA0MgpUb3RhbDogJDQyLjAwCg=="
          },
          {
              "filename": "logo.png",
              "content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAMAASsJTYQAAAAASUVORK5CYII=",
              "content_id": "logo"
          }
      ]
  }),
});

const body = await response.json();

if (!response.ok) {
  throw new Error(body.error.message);
}
```

- A `path` must be https and reachable from the public internet. Links into private networks are refused.
- Add a `content_id` and the file becomes an inline image. Point at it from your HTML with `<img src="cid:logo">`.
- Set `content_type` to the file's type, such as `application/pdf`. Without it the file is sent as `application/octet-stream`, which some mail apps will not preview.
- Keep the whole message under 28MB. Anything bigger is refused with a `413`; send a link to the file instead.
- Batches cannot carry attachments.

## Sending later

Give `scheduled_at` a time with a timezone, up to 30 days ahead:

cURL:

```bash
curl -X POST https://reggiodigital.com/api/v1/emails \
  -H "Authorization: Bearer $REGGIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "from": "hello@yourdomain.com",
      "to": [
          "success@simulator.amazonses.com"
      ],
      "subject": "Your receipt",
      "html": "<p>Thanks for your order.</p>",
      "text": "Thanks for your order.",
      "tags": {
          "kind": "receipt",
          "order": "1042"
      },
      "scheduled_at": "2026-09-21T09:00:00-04:00"
  }'
```

Laravel:

```php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.reggio.key'))
    ->acceptJson()
    ->post('https://reggiodigital.com/api/v1/emails', [
        'from' => 'hello@yourdomain.com',
        'to' => [
            'success@simulator.amazonses.com',
        ],
        'subject' => 'Your receipt',
        'html' => '<p>Thanks for your order.</p>',
        'text' => 'Thanks for your order.',
        'tags' => [
            'kind' => 'receipt',
            'order' => '1042',
        ],
        'scheduled_at' => '2026-09-21T09:00:00-04:00',
    ]);

if ($response->failed()) {
    throw new RuntimeException($response->json('error.message'));
}

$body = $response->json();
```

PHP:

```php
$payload = [
    'from' => 'hello@yourdomain.com',
    'to' => [
        'success@simulator.amazonses.com',
    ],
    'subject' => 'Your receipt',
    'html' => '<p>Thanks for your order.</p>',
    'text' => 'Thanks for your order.',
    'tags' => [
        'kind' => 'receipt',
        'order' => '1042',
    ],
    'scheduled_at' => '2026-09-21T09:00:00-04:00',
];

$ch = curl_init('https://reggiodigital.com/api/v1/emails');

curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('REGGIO_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
]);

$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['message']);
}
```

Node.js:

```js
const response = await fetch("https://reggiodigital.com/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.REGGIO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
      "from": "hello@yourdomain.com",
      "to": [
          "success@simulator.amazonses.com"
      ],
      "subject": "Your receipt",
      "html": "<p>Thanks for your order.</p>",
      "text": "Thanks for your order.",
      "tags": {
          "kind": "receipt",
          "order": "1042"
      },
      "scheduled_at": "2026-09-21T09:00:00-04:00"
  }),
});

const body = await response.json();

if (!response.ok) {
  throw new Error(body.error.message);
}
```

We check everything about the message straight away, so a problem comes back on this request rather than at send time. The response has an `id` starting with `sched_` and the `scheduled_at` we recorded, in UTC. That id keeps working after the message has gone. A scheduled message does not count against the monthly allowance until it is sent.

### Changing the time

`PATCH /api/v1/emails/{id}` with a new `scheduled_at`. The message keeps its id, and only one copy ever goes out.

cURL:

```bash
curl -X PATCH https://reggiodigital.com/api/v1/emails/sched_123 \
  -H "Authorization: Bearer $REGGIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "scheduled_at": "2026-09-22T14:30:00-04:00"
  }'
```

Laravel:

```php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.reggio.key'))
    ->acceptJson()
    ->patch('https://reggiodigital.com/api/v1/emails/sched_123', [
        'scheduled_at' => '2026-09-22T14:30:00-04:00',
    ]);

if ($response->failed()) {
    throw new RuntimeException($response->json('error.message'));
}

$body = $response->json();
```

PHP:

```php
$payload = [
    'scheduled_at' => '2026-09-22T14:30:00-04:00',
];

$ch = curl_init('https://reggiodigital.com/api/v1/emails/sched_123');

curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('REGGIO_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
]);

$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['message']);
}
```

Node.js:

```js
const response = await fetch("https://reggiodigital.com/api/v1/emails/sched_123", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.REGGIO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
      "scheduled_at": "2026-09-22T14:30:00-04:00"
  }),
});

const body = await response.json();

if (!response.ok) {
  throw new Error(body.error.message);
}
```

### Cancelling

`DELETE /api/v1/emails/{id}`. This is permanent, and the response has `"status": "canceled"`.

cURL:

```bash
curl -X DELETE https://reggiodigital.com/api/v1/emails/sched_123 \
  -H "Authorization: Bearer $REGGIO_API_KEY"
```

Laravel:

```php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.reggio.key'))
    ->acceptJson()
    ->delete('https://reggiodigital.com/api/v1/emails/sched_123');

if ($response->failed()) {
    throw new RuntimeException($response->json('error.message'));
}

$body = $response->json();
```

PHP:

```php
$ch = curl_init('https://reggiodigital.com/api/v1/emails/sched_123');

curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'DELETE',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('REGGIO_API_KEY'),
    ],
]);

$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['message']);
}
```

Node.js:

```js
const response = await fetch("https://reggiodigital.com/api/v1/emails/sched_123", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.REGGIO_API_KEY}`,
  },
});

const body = await response.json();

if (!response.ok) {
  throw new Error(body.error.message);
}
```

A message that has already gone out, or that we have started sending, can be neither moved nor cancelled. Moving it answers `409` with `not_reschedulable`, and cancelling it answers `409` with `not_cancellable`.

## Sending a batch

`POST /api/v1/emails/batch` accepts up to 100 messages under `emails`. Each takes the same fields as a single send, except `attachments` and `scheduled_at`.

cURL:

```bash
curl -X POST https://reggiodigital.com/api/v1/emails/batch \
  -H "Authorization: Bearer $REGGIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "emails": [
          {
              "from": "hello@yourdomain.com",
              "to": [
                  "success@simulator.amazonses.com"
              ],
              "subject": "Your table is ready",
              "text": "See you soon."
          },
          {
              "from": "hello@yourdomain.com",
              "to": [
                  "bounce@simulator.amazonses.com"
              ],
              "subject": "Your table is ready",
              "text": "See you soon."
          }
      ]
  }'
```

Laravel:

```php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.reggio.key'))
    ->acceptJson()
    ->post('https://reggiodigital.com/api/v1/emails/batch', [
        'emails' => [
            [
                'from' => 'hello@yourdomain.com',
                'to' => [
                    'success@simulator.amazonses.com',
                ],
                'subject' => 'Your table is ready',
                'text' => 'See you soon.',
            ],
            [
                'from' => 'hello@yourdomain.com',
                'to' => [
                    'bounce@simulator.amazonses.com',
                ],
                'subject' => 'Your table is ready',
                'text' => 'See you soon.',
            ],
        ],
    ]);

if ($response->failed()) {
    throw new RuntimeException($response->json('error.message'));
}

$body = $response->json();
```

PHP:

```php
$payload = [
    'emails' => [
        [
            'from' => 'hello@yourdomain.com',
            'to' => [
                'success@simulator.amazonses.com',
            ],
            'subject' => 'Your table is ready',
            'text' => 'See you soon.',
        ],
        [
            'from' => 'hello@yourdomain.com',
            'to' => [
                'bounce@simulator.amazonses.com',
            ],
            'subject' => 'Your table is ready',
            'text' => 'See you soon.',
        ],
    ],
];

$ch = curl_init('https://reggiodigital.com/api/v1/emails/batch');

curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('REGGIO_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
]);

$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['message']);
}
```

Node.js:

```js
const response = await fetch("https://reggiodigital.com/api/v1/emails/batch", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.REGGIO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
      "emails": [
          {
              "from": "hello@yourdomain.com",
              "to": [
                  "success@simulator.amazonses.com"
              ],
              "subject": "Your table is ready",
              "text": "See you soon."
          },
          {
              "from": "hello@yourdomain.com",
              "to": [
                  "bounce@simulator.amazonses.com"
              ],
              "subject": "Your table is ready",
              "text": "See you soon."
          }
      ]
  }),
});

const body = await response.json();

if (!response.ok) {
  throw new Error(body.error.message);
}
```

Every message is checked before any is accepted, so the whole batch is taken or none of it is. A problem names the message by position, as in `emails.1.to`. The response lists one result per message, in the order you sent them:

```json
{
  "data": [
    { "id": "sched_981", "suppressed": [] },
    { "id": "sched_982", "suppressed": [] }
  ]
}
```

Each message goes out within moments. Its id starts as `sched_` and works for lookups straight away.

## Not sending twice

Add an `Idempotency-Key` header to any send you might retry. A repeat within 24 hours gets the first answer back and sends nothing. More in [idempotency](https://reggiodigital.com/developers/conventions#idempotency).

cURL:

```bash
curl -X POST https://reggiodigital.com/api/v1/emails \
  -H "Authorization: Bearer $REGGIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: receipt-1042" \
  -d '{
      "from": "hello@yourdomain.com",
      "to": [
          "success@simulator.amazonses.com"
      ],
      "subject": "Your receipt",
      "html": "<p>Thanks for your order.</p>",
      "text": "Thanks for your order.",
      "tags": {
          "kind": "receipt",
          "order": "1042"
      }
  }'
```

Laravel:

```php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.reggio.key'))
    ->acceptJson()
    ->withHeaders([
        'Idempotency-Key' => 'receipt-1042',
    ])
    ->post('https://reggiodigital.com/api/v1/emails', [
        'from' => 'hello@yourdomain.com',
        'to' => [
            'success@simulator.amazonses.com',
        ],
        'subject' => 'Your receipt',
        'html' => '<p>Thanks for your order.</p>',
        'text' => 'Thanks for your order.',
        'tags' => [
            'kind' => 'receipt',
            'order' => '1042',
        ],
    ]);

if ($response->failed()) {
    throw new RuntimeException($response->json('error.message'));
}

$body = $response->json();
```

PHP:

```php
$payload = [
    'from' => 'hello@yourdomain.com',
    'to' => [
        'success@simulator.amazonses.com',
    ],
    'subject' => 'Your receipt',
    'html' => '<p>Thanks for your order.</p>',
    'text' => 'Thanks for your order.',
    'tags' => [
        'kind' => 'receipt',
        'order' => '1042',
    ],
];

$ch = curl_init('https://reggiodigital.com/api/v1/emails');

curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('REGGIO_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: receipt-1042',
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
]);

$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['message']);
}
```

Node.js:

```js
const response = await fetch("https://reggiodigital.com/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.REGGIO_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "receipt-1042",
  },
  body: JSON.stringify({
      "from": "hello@yourdomain.com",
      "to": [
          "success@simulator.amazonses.com"
      ],
      "subject": "Your receipt",
      "html": "<p>Thanks for your order.</p>",
      "text": "Thanks for your order.",
      "tags": {
          "kind": "receipt",
          "order": "1042"
      }
  }),
});

const body = await response.json();

if (!response.ok) {
  throw new Error(body.error.message);
}
```

## Checking what happened

`GET /api/v1/emails/{id}` returns a message's current state for 30 days after it was sent.

cURL:

```bash
curl https://reggiodigital.com/api/v1/emails/MESSAGE_ID \
  -H "Authorization: Bearer $REGGIO_API_KEY"
```

Laravel:

```php
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.reggio.key'))
    ->acceptJson()
    ->get('https://reggiodigital.com/api/v1/emails/MESSAGE_ID');

if ($response->failed()) {
    throw new RuntimeException($response->json('error.message'));
}

$body = $response->json();
```

PHP:

```php
$ch = curl_init('https://reggiodigital.com/api/v1/emails/MESSAGE_ID');

curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.getenv('REGGIO_API_KEY'),
    ],
]);

$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['message']);
}
```

Node.js:

```js
const response = await fetch("https://reggiodigital.com/api/v1/emails/MESSAGE_ID", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.REGGIO_API_KEY}`,
  },
});

const body = await response.json();

if (!response.ok) {
  throw new Error(body.error.message);
}
```

```json
{
  "id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
  "from": "hello@yourdomain.com",
  "to": ["customer@example.com"],
  "subject": "Your receipt",
  "tags": { "kind": "receipt", "order": "1042" },
  "status": "bounced",
  "status_description": "The receiving server refused this message and sent it back.",
  "scheduled_at": null,
  "sent_at": "2026-09-14T15:04:05+00:00",
  "delivered_at": null,
  "bounced_at": "2026-09-14T15:04:07+00:00",
  "complained_at": null,
  "bounce": {
    "type": "Transient",
    "subtype": "MailboxFull",
    "permanent": false,
    "summary": "Their mailbox is full.",
    "what_to_do": "Nothing is wrong with the address. It may work again once they clear space, so it is worth trying later.",
    "diagnostic": "552 5.2.2 Mailbox full"
  },
  "events": [
    { "type": "sent", "at": "2026-09-14T15:04:05+00:00", "detail": null },
    { "type": "bounced", "at": "2026-09-14T15:04:07+00:00", "detail": "552 5.2.2 Mailbox full" }
  ]
}
```

`status` is one of `scheduled`, `queued`, `sent`, `delivered`, `delayed`, `bounced`, `complained`, `rejected`, `failed` or `canceled`. Delivery is not instant: a message read back straight away usually says `sent`, and `delivered` follows within a minute or two. `bounce` is `null` unless the message bounced.

Rather than polling, most integrations use [webhooks](https://reggiodigital.com/developers/webhooks).

## Testing without emailing anyone

These addresses behave in a fixed way and nothing sent to them leaves the mail network, so you can build bounce and complaint handling before a real customer is involved. They are metered and logged like any other send, and show up on the account's [Sent page](https://reggiodigital.com/email/sent).

| Send to | What happens |
| --- | --- |
| `success@simulator.amazonses.com` | Delivered. |
| `bounce@simulator.amazonses.com` | A hard bounce, as if the mailbox did not exist. |
| `complaint@simulator.amazonses.com` | Delivered, then marked as spam. |
| `suppressionlist@simulator.amazonses.com` | Refused because the address is on a block list. |

## Limits

- 120 requests a minute per account, shared with the newsletter API. See [rate limits](https://reggiodigital.com/developers/conventions#rate-limits).
- Each send and each batch returns `X-Reggio-Monthly-Quota` and `X-Reggio-Monthly-Remaining`. Every recipient counts, including `cc` and `bcc`.
- An account that reaches its sending ceiling gets `429` with `send_rejected` until it is lifted.
- 50 addresses per `to`, `cc` or `bcc`; 100 messages per batch; 20 attachments; 28MB per message; 30 days ahead for scheduling; 30 days of lookups.
