# Overview > What your own software can do with Reggio Digital: send email, keep a newsletter list in step, and hear about what happened. Source: https://reggiodigital.com/developers If someone who runs a business with us has sent you this link, this is where their software meets our platform. Everything here is plain HTTPS and JSON, with a key that belongs to their account. ## What you can build **Send email from your own application.** Receipts, password resets, booking confirmations and anything else your software sends one person at a time. You post a message, we send it from the business's own verified domain, and you can ask afterwards what happened to it. Start with the [transactional email API](https://reggiodigital.com/developers/email). If the software you are connecting cannot make HTTP calls but has an SMTP setting, [send over SMTP](https://reggiodigital.com/developers/smtp) instead. **Keep a newsletter list in step.** Add someone the moment they sign up in your shop or booking system, update their details, and take them off when they leave. Consent and confirmation emails work exactly as they do for the business's own signup forms. See the [subscriber API](https://reggiodigital.com/developers/newsletter), or [embed a signup form](https://reggiodigital.com/developers/signup-forms) if you only need the form. **Hear about what happened.** Instead of asking over and over whether a message arrived, give us an address and we will tell your application when an email is delivered or bounces, when someone joins or leaves a list, and when mail arrives in the business's inbox. See [webhooks](https://reggiodigital.com/developers/webhooks). **Make a WordPress site faster.** The [image optimizer plugin](https://reggiodigital.com/developers/image-optimizer) makes smaller copies of every image and serves them to browsers that can read them. There are also step by step guides for sending email from [Laravel](https://reggiodigital.com/developers/guides/laravel), [Node.js](https://reggiodigital.com/developers/guides/node) and [WordPress](https://reggiodigital.com/developers/guides/wordpress). **Reading this with an AI coding tool?** Every page is also available as plain Markdown with its code written out. Start from [llms.txt](https://reggiodigital.com/llms.txt), or load [the whole set in one file](https://reggiodigital.com/llms-full.txt). ## For the business owner You do not need to read the rest of this page. Forward the link to whoever builds or looks after your software, and let them know they will need a key from your dashboard. If you would rather we did the work, [get in touch](https://reggiodigital.com/contact) and we will make the change for you. ## Getting a key 1. Log in to the account the integration is for. If you do not have access, ask the account owner to invite you. 2. Open [Connected apps](https://reggiodigital.com/email/api/keys) and connect your application. Give it only the abilities it needs, and limit it to one sending domain if it only sends from one. 3. Copy the key into your application's environment, never into code that runs in a browser. Each page here uses `rgo_your_key_here` and `hello@yourdomain.com` as stand-ins. The dashboard versions of these pages show the same examples filled in with the account's own key and verified sender: - [Send API](https://reggiodigital.com/email/api) in the dashboard - [Subscriber API](https://reggiodigital.com/newsletter/api) in the dashboard - [Notify my app](https://reggiodigital.com/email/webhooks), where webhook addresses and signing secrets live ## The shortest possible example This sends one email to a test address that never reaches a real inbox, so it is safe to run as it is once the from address is on a verified domain. 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": "

Thanks for your order.

", "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' => '

Thanks for your order.

', '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' => '

Thanks for your order.

', '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": "

Thanks for your order.

", "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 `202` with an `id` means we accepted it. Read [errors, limits and headers](https://reggiodigital.com/developers/conventions) before you ship, so your code handles the answers that are not a `202`. ## Base address Every call in these docs goes to: ``` https://reggiodigital.com/api/v1 ``` The `v1` in the path is a promise: nothing under it will change in a way that breaks an integration written against these docs. New fields and new event types can appear, so write code that ignores what it does not recognise. A change that would break you ships under a new version instead of changing `v1`. Changes are listed in the [changelog](https://reggiodigital.com/developers/changelog). --- # Authentication > API keys for Reggio Digital: how to get one, what each ability allows, limiting a key to a domain, and replacing a key without downtime. Source: https://reggiodigital.com/developers/authentication ## API keys Every call carries a key in the `Authorization` header: ``` Authorization: Bearer rgo_your_key_here ``` Keys start with `rgo_`. A key belongs to the business's account, not to the person who created it, so it keeps working when that person leaves the account. Anyone on the account who has been given access to Email can see and manage keys under [Connected apps](https://reggiodigital.com/email/api/keys). A missing, malformed or revoked key gets a `401` with the error type `unauthorized`. ## Keep the key on your server A key can send email as the business. Treat it like a password: - Store it in an environment variable or your platform's secret store. - Never put it in JavaScript that runs in a browser, in a mobile app bundle, or in a public repository. - A signup form on a website should post to your own server, and your server calls us. If a key does leak, replace it straight away (see below). The old one can be stopped the same second. ## Abilities A key can only do what it was given when it was connected. Give each application the least it needs. | Ability | Lets the key | | --- | --- | | `email:send` | Send, schedule, reschedule, cancel and look up transactional email. | | `newsletter:read` | Look up lists, custom fields and contacts. Cannot change anything. | | `newsletter:manage` | Everything `newsletter:read` can, plus add, update and unsubscribe contacts. | A key without the ability a call needs gets a `403` with the error type `forbidden`. A newsletter key cannot send email, and an email key cannot touch the subscriber list. The business also needs the product the call belongs to. A valid key on an account with no transactional email plan gets a `402` with `no_plan` from the email endpoints, and the same goes for the newsletter endpoints. ## Limiting a key to sending domains When you connect an application you can limit its key to one or more of the account's verified sending domains. A limited key: - can only send from addresses on those domains, and gets a `403` with `send_rejected` for any other from address; - can only look up, reschedule or cancel messages sent from those domains. Anything else reads as a `404`. Use this when one account runs several brands, or when a contractor's application should only ever send as one of them. ## One key per application Connect each application separately rather than sharing one key. Each gets its own name in the dashboard, its own last-used time and its own entries in the [API log](https://reggiodigital.com/email/api/log), and you can replace or switch off one without touching the others. ## Replacing a key Replacing a key in [Connected apps](https://reggiodigital.com/email/api/keys) creates a new key with the same name, abilities and domain limits, and lets you choose how long the old one keeps working: - stop straight away, for a key you believe has leaked; - keep working for an hour, a day or a week, so you can deploy the new key without a gap. During the overlap both keys are accepted. Scheduled messages sent with the old key move to the new one, so they still go out on time after the old key stops. --- # Errors, limits and headers > The error shape every Reggio Digital API call shares, the status codes, rate limits, idempotency keys, response headers and the versioning promise. Source: https://reggiodigital.com/developers/conventions ## Requests - Send JSON with `Content-Type: application/json`, and ask for JSON back with `Accept: application/json`. - Call us over `https`. - Times are ISO 8601 with a timezone, for example `2026-10-05T09:00:00-04:00`. Times we send back are in UTC. - An id in a path is used exactly as we gave it to you. An email address in a path is URL encoded, so `sam@example.com` becomes `sam%40example.com`. ## Errors Every error we return from `/api/v1` has the same shape, so your code only needs one handler: ```json { "error": { "type": "invalid_request", "message": "The to field must be an array.", "fields": { "to": ["The to field must be an array."] }, "request_id": "0f5b6c1e-2d3a-4b8c-9e7f-1a2b3c4d5e6f" } } ``` - `type` is stable and safe to branch on. `message` is written for a person and can change wording, so log it rather than matching on it. - `fields` appears when a rule on a named field failed, one entry per field. In a batch the name includes the position, as in `emails.3.to`. - `request_id` identifies the call. See [finding a call again](#finding-a-call-again). | Status | error.type | When | | --- | --- | --- | | 401 | unauthorized | No key was sent, it is not a Reggio key, or it has been revoked. | | 402 | no_plan | The account does not have the product this call belongs to. | | 403 | forbidden | The key does not have the ability this call needs. | | 403 | send_rejected | The from address is on a domain this key or account cannot send from. | | 404 | not_found | No message, list or contact with that id on this account. | | 409 | send_rejected | The sending domain has not finished verifying, or sending from it is stopped. | | 409 | idempotency_conflict | The Idempotency-Key was already used for a different request. | | 409 | not_cancellable | The message has already gone out, so it cannot be cancelled. | | 409 | not_reschedulable | The message has already gone out, so its time cannot change. | | 413 | send_rejected | The attachments add up to more than 28MB. | | 422 | invalid_request | A field is missing or malformed. error.fields names each one. | | 422 | send_rejected | The request is well formed but cannot be sent: no sending domain is set up for the from address, or every recipient is suppressed. | | 429 | rate_limited | More than 120 requests in a minute. Wait for Retry-After seconds. | | 429 | send_rejected | The account has reached its sending ceiling for now. | A `500`, `502` or `503` means something went wrong on our side or with the mail network, and a `500` may arrive without the body above. Retry those with backoff. With an `Idempotency-Key`, a retry of a request that did go through gets the original answer instead of a second send. ## Rate limits Each account can make 120 requests a minute across the email and newsletter APIs together. Every key on the account shares that budget. Past the limit you get a `429` with the type `rate_limited` and a `Retry-After` header saying how many seconds to wait. Successful responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`, so a busy integration can slow down before it hits the ceiling. If you need to send a large number of similar messages, use [a batch](https://reggiodigital.com/developers/email#sending-a-batch): up to 100 messages count as one request. ## Idempotency Networks drop. When your code retries a request it is not sure went through, send an `Idempotency-Key` header with a value unique to that one piece of work, such as an order number: ``` Idempotency-Key: receipt-1042 ``` For 24 hours, the same key with the same request body gets the original answer back, marked with `Idempotent-Replayed: true`, and nothing is sent or added twice. The same key with a different body gets a `409` with `idempotency_conflict`, because that is almost always a bug in the caller. Keys are at most 256 characters and are scoped to the API key that sent them. Idempotency keys work on `POST /emails`, `POST /emails/batch` and `POST /newsletter/contacts`. ## Response headers | Header | What it tells you | | --- | --- | | X-Reggio-Request-Id | A unique id for this call. Also inside error.request_id on a failure. Quote it to support. | | X-Reggio-Monthly-Quota | Your monthly email allowance. On email sends only. | | X-Reggio-Monthly-Remaining | How much of that allowance is left this month. On email sends only. | | Idempotent-Replayed | Set to true when the answer is a replay of an earlier request with the same Idempotency-Key. | | X-RateLimit-Limit | Requests allowed per minute for your account. | | X-RateLimit-Remaining | Requests left in the current minute. | | Retry-After | On a 429, the number of seconds to wait before trying again. | ## Finding a call again Every response carries `X-Reggio-Request-Id`, and every error repeats it as `error.request_id`. Keep it in your logs. Paste one into the [API log](https://reggiodigital.com/email/api/log) in the dashboard and you get that call back: the address you called, the status we answered with, and the fields we could not accept. It works for calls that never became a message, which is exactly when you need it. We keep this for 30 days. We never keep request bodies, attachments or keys in it. If you contact support about a call, quoting the request id is the fastest way for us to find it. ## Versioning Everything in these docs is under `/api/v1`. Within `v1`: - we can add endpoints, optional request fields, response fields, headers, error types and webhook event types; - we will not remove or rename a field, change its type, or make an optional field required. Write code that ignores fields it does not recognise and treats an unknown error type like a generic failure for its status code. A change that would break that code ships under a new version instead. Anything an integration would notice is in the [changelog](https://reggiodigital.com/developers/changelog). --- # 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": "

Thanks for your order.

", "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' => '

Thanks for your order.

', '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' => '

Thanks for your order.

', '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": "

Thanks for your order.

", "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 `, 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": "

Thanks for your order.

\"\"", "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' => '

Thanks for your order.

', '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' => '

Thanks for your order.

', '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": "

Thanks for your order.

\"\"", "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 ``. - 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": "

Thanks for your order.

", "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' => '

Thanks for your order.

', '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' => '

Thanks for your order.

', '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": "

Thanks for your order.

", "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": "

Thanks for your order.

", "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' => '

Thanks for your order.

', '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' => '

Thanks for your order.

', '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": "

Thanks for your order.

", "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. --- # Templates > Send a saved email template through the Reggio Digital API and fill in its variables from your own data. Source: https://reggiodigital.com/developers/templates A template keeps the wording and design of a message in the dashboard, so the business can change a receipt without anyone touching code. Your application sends the template's identifier and the values to fill in. ## Creating one Templates are built and published in [Email templates](https://reggiodigital.com/email/templates) in the dashboard. Each template has an identifier, a UUID, which you copy from its page. Only the published version sends: saving a draft changes nothing for live sends until someone chooses Publish. ## Sending a template Send `template_id` and `variables` in place of `subject`, `html` and `text`: 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" ], "template_id": "COPY_YOUR_TEMPLATE_IDENTIFIER", "variables": { "customer_name": "Alex", "order_number": "1042", "total": "$42.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', ], 'template_id' => 'COPY_YOUR_TEMPLATE_IDENTIFIER', 'variables' => [ 'customer_name' => 'Alex', 'order_number' => '1042', 'total' => '$42.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', ], 'template_id' => 'COPY_YOUR_TEMPLATE_IDENTIFIER', 'variables' => [ 'customer_name' => 'Alex', 'order_number' => '1042', 'total' => '$42.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" ], "template_id": "COPY_YOUR_TEMPLATE_IDENTIFIER", "variables": { "customer_name": "Alex", "order_number": "1042", "total": "$42.00" } }), }); const body = await response.json(); if (!response.ok) { throw new Error(body.error.message); } ``` The template supplies the subject and both bodies. Sending `subject`, `html` or `text` alongside `template_id` is refused with a `422`, and so is sending `variables` without a template. Templates work in [batches](https://reggiodigital.com/developers/email#sending-a-batch) and [scheduled sends](https://reggiodigital.com/developers/email#sending-later) too. When we accept a scheduled or batch send, we fill the template in and keep that finished message, so publishing a new version afterwards does not change mail you have already handed us. ## Variables In the template, a variable is written as `{{ customer_name }}`. In the request, it is a key in `variables`: ```json "variables": { "customer_name": "Alex", "order_number": "1042", "total": "$42.00" } ``` - Names are case sensitive. Up to 50 per message. - Each variable has a type set in the template. **Text** takes text or a number, **Number** takes a number, and **Web address** takes a complete `http://` or `https://` address. - A variable can be required, or optional with a default. A missing optional value uses its default. The preview values in the editor are never used for real sends. - Values are always treated as text, never as markup, so a value cannot inject HTML into the message. - The finished subject is limited to 255 characters and cannot contain a line break. A missing required value, a name the template does not define, or a value of the wrong type is refused with a `422`. `error.fields` names the variable, as in `variables.order_number`, or `emails.0.variables.order_number` in a batch. ## Errors you are likely to see | Status | Why | | --- | --- | | `422` | The template does not exist on this account, has never been published, or a variable is missing or invalid. | | `402` | The account no longer has a transactional email plan. | Everything else behaves as for any send. See [errors, limits and headers](https://reggiodigital.com/developers/conventions). --- # Sending over SMTP > Send email through Reggio Digital from anything that speaks SMTP: a shop, a CRM, a site hosted elsewhere. Credentials, ports and the DNS a sending domain needs. Source: https://reggiodigital.com/developers/smtp Plenty of software that sends email cannot call an API but does have an SMTP setting: an online shop, a booking system, a CRM, a website hosted somewhere else. Point that setting at us and the mail goes out from the business's own verified domain. If you are writing the code yourself, the [transactional email API](https://reggiodigital.com/developers/email) gives you more: scheduling, batches, templates and a lookup for every message. ## Where the settings are SMTP credentials belong to one verified sending domain. In the dashboard, open [Sending domains](https://reggiodigital.com/domains/sending), pick the domain and choose to connect an app. That page lists everything the software will ask for, ready to copy: | Setting | What to enter | | --- | --- | | Server or host | Copy it from the domain's page. | | Port | `587` | | Security or encryption | `STARTTLS`, sometimes labelled TLS | | Username | Copy it from the domain's page. | | Password | Copy it from the domain's page. | | From address | Any address on that domain, such as `orders@yourdomain.com`. | Only people with access to Email on the account can open that page. The credentials are created the first time someone opens it, so it can take a moment the first time. ## What the credentials can do They send as any address on the one domain they were issued for, and nothing else. If the software needs to send from two domains, verify both and use each domain's own credentials. Keep the password in the software's settings and nowhere else. If it leaks, [open a ticket](https://reggiodigital.com/tickets/create) and we will replace it; the old password stops working as soon as we do. ## The DNS a sending domain needs A domain has to be verified before it can send, over SMTP or the API. When the business adds a domain in [Sending domains](https://reggiodigital.com/domains/sending), the dashboard lists the exact records to add at their DNS provider, and checks them for you. There are three kinds: - **Signing records** (`CNAME` or `TXT` under `_domainkey`), which let receiving servers check a message really came from the domain and was not changed on the way. - **A return-path subdomain** with an `MX` record and an SPF `TXT` record, where bounce reports come back to us. - **An SPF `TXT` record on the domain itself**, which some receivers, including Apple's Hide My Email relay, check separately. Copy the values from the dashboard rather than from here. They are specific to each domain. If the domain already sends through another provider, both can be set up at once. Nothing has to be switched off first. If a name already has an SPF record, merge ours into it rather than adding a second one: two SPF records on one name make both fail. A DMARC record is recommended on the main domain but not required. The dashboard reports what it finds. ## When a message does not arrive Check the from address first: it has to be on the domain the credentials belong to. Then check the software is set to STARTTLS on port 587. If both are right and mail still does not arrive, open a ticket with the time it was sent and the recipient, and we will trace it. --- # Webhooks > Every event Reggio Digital can send your application, an example payload for each, signature verification in PHP and Node, retries and deduplication. Source: https://reggiodigital.com/developers/webhooks Give us an https address in your application and we will post to it when something happens on the account, instead of your code asking over and over. Webhook addresses, the events each one hears about and signing secrets are managed on the [Notify my app](https://reggiodigital.com/email/webhooks) page in the dashboard, which also lists every delivery from the last 30 days. ## Setting up an endpoint 1. Add an address on [Notify my app](https://reggiodigital.com/email/webhooks). It has to start with `https` and be reachable from the public internet. Private and internal addresses are refused. 2. Pick the events it should hear about. An endpoint only ever receives the types it chose, so a new event type never starts arriving unannounced. 3. Copy its signing secret into your application. 4. Use **Send a test** to post a real, signed `email.delivered` event with `"test": true` in its data. Events are recorded only while an endpoint is listening for them. Turning one on does not send history from before it existed. ## What we send A `POST` with a JSON body in this envelope: ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "email.bounced", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { } } ``` - `id` is unique to the event and stays the same on every retry and replay. - `version` is the shape of `data`. It only changes if a change would break an existing consumer. - `replayed` is `true` when someone sent the event again by hand from the dashboard. And these headers: | Header | What it carries | | --- | --- | | X-Reggio-Webhook-Signature | Signed so your application can prove the message came from us. See below. | | X-Reggio-Webhook-Id | The event id, the same as id in the body. It stays the same on every retry. | | X-Reggio-Webhook-Type | The event type, the same as type in the body, so you can route before parsing. | | Content-Type | Always application/json. | ## Answering Answer with any `2xx` status within 10 seconds and we count the event as delivered. Do the real work after you answer, or on a queue, so a slow job does not look like a failure. Anything else, including a timeout or a `3xx`, and we try again. Seven attempts in all, waiting 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours and 4 hours between them, about six and a half hours from first to last. After that the delivery is marked as failed, and it can be sent again from the dashboard once your endpoint is fixed. Redirects are never followed. Give us the final address. ## Handling duplicates and order - **At least once.** An event can arrive more than once, for example when your application was slow to answer the first time. Record the `id` of every event you handle and ignore one you have seen. - **Not in order.** Events are delivered independently. A bounce for one message can arrive before the delivery of another, and an `email.delivered` can occasionally arrive after an `email.opened` for the same message. Use `occurred_at` and treat each event on its own. ## Verifying the signature Every request carries `X-Reggio-Webhook-Signature`, which looks like: ``` X-Reggio-Webhook-Signature: t=1789398245,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` To check it: 1. Take the timestamp from `t` and the signature from `v1`. 2. Join the timestamp, a full stop and the raw request body, exactly as the bytes arrived, before any JSON parsing. 3. Compute HMAC SHA-256 of that string with your signing secret, as lowercase hex. 4. Compare it to `v1` with a constant-time comparison. 5. Reject the request if the timestamp is more than five minutes from your clock. The timestamp is part of what is signed, so it cannot be changed without breaking the signature. PHP: ```php function verifyReggioWebhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool { $parts = []; foreach (explode(',', $signatureHeader) as $piece) { [$name, $value] = array_pad(explode('=', trim($piece), 2), 2, ''); $parts[$name] = $value; } $timestamp = (int) ($parts['t'] ?? 0); $signature = $parts['v1'] ?? ''; if ($timestamp <= 0 || $signature === '' || abs(time() - $timestamp) > $toleranceSeconds) { return false; } $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret); return hash_equals($expected, $signature); } // $rawBody = file_get_contents('php://input'); // $ok = verifyReggioWebhook($rawBody, $_SERVER['HTTP_X_REGGIO_WEBHOOK_SIGNATURE'] ?? '', getenv('REGGIO_WEBHOOK_SECRET')); ``` Node.js: ```js import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyReggioWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( String(signatureHeader ?? "") .split(",") .map((piece) => piece.trim().split("=", 2)) .filter((pair) => pair.length === 2), ); const timestamp = Number.parseInt(parts.t ?? "0", 10); const signature = parts.v1 ?? ""; if (!timestamp || !signature || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) { return false; } const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } // Express: read the body as text before any JSON parsing, so the bytes match what we signed. // app.post("/reggio", express.text({ type: "application/json" }), (req, res) => { // if (!verifyReggioWebhook(req.body, req.get("X-Reggio-Webhook-Signature"), process.env.REGGIO_WEBHOOK_SECRET)) { // return res.sendStatus(400); // } // const event = JSON.parse(req.body); // res.sendStatus(200); // }); ``` If the secret is right and the check still fails, make sure you are hashing the raw body. A framework that parses JSON before your handler runs gives you a re-encoded copy with different whitespace. Replacing a secret in the dashboard takes effect on the next event, so update your application straight after. ## Event types | Event type | Sent when | | --- | --- | | Email you send | | email.delivered | An email arrived | | email.bounced | An email bounced | | email.complained | Someone marked an email as spam | | email.delayed | An email is taking longer than usual | | email.opened | Someone opened an email | | email.clicked | Someone clicked a link in an email | | email.failed | An email could not be sent | | Your newsletter | | newsletter.subscriber.subscribed | Someone joined a list | | newsletter.subscriber.confirmed | Someone confirmed their subscription | | newsletter.subscriber.unsubscribed | Someone left a list | | newsletter.preferences.updated | Someone changed what they want to hear about | | Mail you receive | | inbox.message.received | Mail arrived in your inbox | Every example below is the full body your endpoint receives. ### email.delivered The receiving mail server accepted the message. `detail.detail` is its reply. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "email.delivered", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000", "to": "customer@example.com", "from": "hello@yourdomain.com", "subject": "Your receipt", "status": "delivered", "tags": { "kind": "receipt", "order": "1042" }, "detail": { "detail": "250 2.0.0 OK" } } } ``` ### email.bounced The receiving server refused the message. `detail.bounce_type` is `Permanent` for an address that will never work and `Transient` for one worth trying later; `detail.bounce_subtype` says why. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "email.bounced", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000", "to": "customer@example.com", "from": "hello@yourdomain.com", "subject": "Your receipt", "status": "bounced", "tags": { "kind": "receipt", "order": "1042" }, "detail": { "detail": "smtp; 550 5.1.1 user unknown", "bounce_type": "Permanent", "bounce_subtype": "General" } } } ``` ### email.complained The recipient marked the message as spam. Stop sending them anything that is not strictly necessary. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "email.complained", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000", "to": "customer@example.com", "from": "hello@yourdomain.com", "subject": "Your receipt", "status": "complained", "tags": { "kind": "receipt", "order": "1042" }, "detail": { "detail": "abuse" } } } ``` ### email.delayed The receiving server is refusing mail for now. We keep trying, and a later `email.delivered` or `email.bounced` settles it. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "email.delayed", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000", "to": "customer@example.com", "from": "hello@yourdomain.com", "subject": "Your receipt", "status": "delayed", "tags": { "kind": "receipt", "order": "1042" }, "detail": { "detail": "MailboxFull" } } } ``` ### email.opened The recipient opened the message. Opens are approximate: privacy features in some mail apps open every message automatically. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "email.opened", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000", "to": "customer@example.com", "from": "hello@yourdomain.com", "subject": "Your receipt", "status": "opened", "tags": { "kind": "receipt", "order": "1042" }, "detail": [] } } ``` ### email.clicked The recipient clicked a link. `detail.detail` is the link. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "email.clicked", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000", "to": "customer@example.com", "from": "hello@yourdomain.com", "subject": "Your receipt", "status": "clicked", "tags": { "kind": "receipt", "order": "1042" }, "detail": { "detail": "https://yourdomain.com/orders/1042" } } } ``` ### email.failed The message never left, because it could not be built or was rejected before sending. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "email.failed", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000", "to": "customer@example.com", "from": "hello@yourdomain.com", "subject": "Your receipt", "status": "failed", "tags": { "kind": "receipt", "order": "1042" }, "detail": { "detail": "The template could not be rendered." } } } ``` ### newsletter.subscriber.subscribed Someone joined a list, from a form, the API, an import or by hand. On a list that asks people to confirm, `status` is `pending` until they do. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "newsletter.subscriber.subscribed", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "email": "sam@example.com", "list_id": 42, "list_name": "News", "status": "subscribed", "first_name": "Sam", "source": "form" } } ``` ### newsletter.subscriber.confirmed Someone clicked the confirmation link on a list that asks people to confirm. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "newsletter.subscriber.confirmed", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "email": "sam@example.com", "list_id": 42, "list_name": "News", "status": "subscribed", "first_name": "Sam", "source": "form" } } ``` ### newsletter.subscriber.unsubscribed Someone left a list. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "newsletter.subscriber.unsubscribed", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "email": "sam@example.com", "list_id": 42, "list_name": "News", "status": "unsubscribed", "first_name": "Sam", "source": "form" } } ``` ### newsletter.preferences.updated Someone changed which topics they want to hear about on their preferences page. `wanted` is their choice for each topic. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "newsletter.preferences.updated", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "email": "sam@example.com", "topics": [ { "id": 7, "name": "New releases", "wanted": true }, { "id": 8, "name": "Events", "wanted": false } ] } } ``` ### inbox.message.received Mail arrived at one of the business's receiving addresses. `attachment_count` says how many files came with it, and `spam` is our spam verdict. The message itself is in the dashboard inbox. ```json { "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33", "version": "2026-09-01", "type": "inbox.message.received", "occurred_at": "2026-09-14T15:04:05+00:00", "replayed": false, "data": { "message_id": "b7d1e0c2-8f3a-4c5e-9d6b-2a1f0e9c8b7a", "to": "orders@yourdomain.com", "from": "customer@example.com", "subject": "Question about order 1042", "received_at": "2026-09-14T15:04:05+00:00", "attachment_count": 1, "spam": false } } ``` --- # Subscriber API > Add, update, look up and unsubscribe newsletter contacts from your own software, with consent and double opt-in handled the same way as a signup form. Source: https://reggiodigital.com/developers/newsletter The subscriber API keeps the business's newsletter list in step with your own software: add someone the moment they sign up in your shop or booking system, keep their details current, and take them off when they ask. Reading needs a key with `newsletter:read`. Adding, updating and unsubscribing need `newsletter:manage`. The account needs the newsletter, but not a transactional email plan. The dashboard's [Subscriber API page](https://reggiodigital.com/newsletter/api) lists the account's own lists and fields. Everything here goes through the same rules as a signup form on the business's website: consent is recorded, lists that ask people to confirm send a confirmation email, and nobody who left is ever put back. ## Lists `GET /api/v1/newsletter/lists` cURL: ```bash curl https://reggiodigital.com/api/v1/newsletter/lists \ -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/newsletter/lists'); if ($response->failed()) { throw new RuntimeException($response->json('error.message')); } $body = $response->json(); ``` PHP: ```php $ch = curl_init('https://reggiodigital.com/api/v1/newsletter/lists'); 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/newsletter/lists", { 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 { "lists": [ { "id": 42, "name": "News", "double_opt_in": true } ] } ``` Every write names a list by its `id`. `double_opt_in` tells you whether a new subscriber has to confirm by email first. ## Custom fields `GET /api/v1/newsletter/fields` cURL: ```bash curl https://reggiodigital.com/api/v1/newsletter/fields \ -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/newsletter/fields'); if ($response->failed()) { throw new RuntimeException($response->json('error.message')); } $body = $response->json(); ``` PHP: ```php $ch = curl_init('https://reggiodigital.com/api/v1/newsletter/fields'); 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/newsletter/fields", { 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 { "fields": [ { "key": "tier", "label": "Membership tier", "type": "text", "fallback": "member" }, { "key": "renews_on", "label": "Renews on", "type": "date", "fallback": null } ] } ``` Fields are defined by the business under [Newsletter settings](https://reggiodigital.com/newsletter/settings). You send values by `key`. Types are `text`, `number`, `date` (as in `2026-04-30`) and `url`. ## Adding or updating someone `POST /api/v1/newsletter/contacts` cURL: ```bash curl -X POST https://reggiodigital.com/api/v1/newsletter/contacts \ -H "Authorization: Bearer $REGGIO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signup-8841" \ -d '{ "email": "sam@example.com", "list_id": 42, "first_name": "Sam", "tags": [ "Bought" ], "fields": { "tier": "Gold" }, "consent": { "detail": "Ticked the newsletter box at checkout", "ip": "203.0.113.4" } }' ``` Laravel: ```php use Illuminate\Support\Facades\Http; $response = Http::withToken(config('services.reggio.key')) ->acceptJson() ->withHeaders([ 'Idempotency-Key' => 'signup-8841', ]) ->post('https://reggiodigital.com/api/v1/newsletter/contacts', [ 'email' => 'sam@example.com', 'list_id' => 42, 'first_name' => 'Sam', 'tags' => [ 'Bought', ], 'fields' => [ 'tier' => 'Gold', ], 'consent' => [ 'detail' => 'Ticked the newsletter box at checkout', 'ip' => '203.0.113.4', ], ]); if ($response->failed()) { throw new RuntimeException($response->json('error.message')); } $body = $response->json(); ``` PHP: ```php $payload = [ 'email' => 'sam@example.com', 'list_id' => 42, 'first_name' => 'Sam', 'tags' => [ 'Bought', ], 'fields' => [ 'tier' => 'Gold', ], 'consent' => [ 'detail' => 'Ticked the newsletter box at checkout', 'ip' => '203.0.113.4', ], ]; $ch = curl_init('https://reggiodigital.com/api/v1/newsletter/contacts'); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer '.getenv('REGGIO_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: signup-8841', ], 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/newsletter/contacts", { method: "POST", headers: { Authorization: `Bearer ${process.env.REGGIO_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "signup-8841", }, body: JSON.stringify({ "email": "sam@example.com", "list_id": 42, "first_name": "Sam", "tags": [ "Bought" ], "fields": { "tier": "Gold" }, "consent": { "detail": "Ticked the newsletter box at checkout", "ip": "203.0.113.4" } }), }); const body = await response.json(); if (!response.ok) { throw new Error(body.error.message); } ``` One call does both: - **A new address** joins the way a form signup does. On a list with `double_opt_in`, they are emailed a confirmation and sit at `pending` until they click it. Otherwise they are `subscribed` straight away, and any welcome sequence starts. Answers `201`. - **An address already on the list** has its names, fields and tags updated, and its status left exactly as it was. Answers `200`. - **Someone who unsubscribed or marked the business's mail as spam** is never put back. Their details can be updated, and the response shows their current status so you can tell. | Field | Required | What it is | | --- | --- | --- | | `email` | Yes | The address. Stored in lower case. | | `list_id` | Yes | A list on this account. Another account's list is a `404`. | | `first_name`, `last_name` | No | Up to 120 characters each. | | `fields` | No | Up to 20 custom field values by key. An unknown key is a `422`, and so is a value that does not fit the field's type. `null` clears a value. | | `tags` | No | Up to 20 tags, each up to 60 characters. Added to the tags already there, never removed, so a sync cannot strip a tag someone added by hand. | | `consent.detail` | No, but send it | Where this address came from, in words a person would understand later. Kept on the record. Without it we record that the address was added through the API. | | `consent.ip`, `consent.user_agent` | No | The signup's IP address and browser, when you have them. | The response is the membership: ```json { "list_id": 42, "email": "sam@example.com", "status": "pending", "status_description": "Waiting to confirm", "first_name": "Sam", "last_name": null, "fields": { "tier": "Gold" }, "tags": ["Bought"], "subscribed_at": null, "unsubscribed_at": null } ``` `status` is one of `pending`, `subscribed`, `unsubscribed`, `cleaned` (rested after a long stretch without engaging) or `complained`. `subscribed_at` is when they confirmed, or joined a list that does not ask. This call accepts an [`Idempotency-Key`](https://reggiodigital.com/developers/conventions#idempotency). ## Looking someone up `GET /api/v1/newsletter/contacts/{email}`, with the address URL encoded. cURL: ```bash curl https://reggiodigital.com/api/v1/newsletter/contacts/sam%40example.com \ -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/newsletter/contacts/sam%40example.com'); if ($response->failed()) { throw new RuntimeException($response->json('error.message')); } $body = $response->json(); ``` PHP: ```php $ch = curl_init('https://reggiodigital.com/api/v1/newsletter/contacts/sam%40example.com'); 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/newsletter/contacts/sam%40example.com", { 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 { "email": "sam@example.com", "marketing_blocked": false, "lists": [ { "list_id": 42, "email": "sam@example.com", "status": "subscribed", "status_description": "Subscribed", "first_name": "Sam", "last_name": null, "fields": { "tier": "Gold" }, "tags": ["Bought"], "subscribed_at": "2026-09-14T15:04:05+00:00", "unsubscribed_at": null } ] } ``` `lists` has one entry for every list they are on. `marketing_blocked` is `true` when no marketing email from the business can reach them at all, for example after they unsubscribed from everything. An address we have never seen is a `404`. ## Taking someone off `POST /api/v1/newsletter/contacts/{email}/unsubscribe` cURL: ```bash curl -X POST https://reggiodigital.com/api/v1/newsletter/contacts/sam%40example.com/unsubscribe \ -H "Authorization: Bearer $REGGIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "Closed their account" }' ``` Laravel: ```php use Illuminate\Support\Facades\Http; $response = Http::withToken(config('services.reggio.key')) ->acceptJson() ->post('https://reggiodigital.com/api/v1/newsletter/contacts/sam%40example.com/unsubscribe', [ 'reason' => 'Closed their account', ]); if ($response->failed()) { throw new RuntimeException($response->json('error.message')); } $body = $response->json(); ``` PHP: ```php $payload = [ 'reason' => 'Closed their account', ]; $ch = curl_init('https://reggiodigital.com/api/v1/newsletter/contacts/sam%40example.com/unsubscribe'); 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/newsletter/contacts/sam%40example.com/unsubscribe", { method: "POST", headers: { Authorization: `Bearer ${process.env.REGGIO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "reason": "Closed their account" }), }); const body = await response.json(); if (!response.ok) { throw new Error(body.error.message); } ``` - **Without `list_id`**, they stop getting every marketing email from the business. That is what a person means when they say unsubscribe. Their receipts and other transactional email are not affected. - **With `list_id`**, they leave that one list and stay on the others. `reason` is optional and kept on the record. The response is the same shape as a lookup. Neither can be undone through the API: rejoining is the person's own decision, from their preferences page. ## Bringing over a whole list To bring an existing list across in one go, use the CSV import under Subscribers in the dashboard instead of looping over this API. The import checks the whole file before it writes anything, and it does not count against the account's [rate limit](https://reggiodigital.com/developers/conventions#rate-limits). ## Errors The usual [error shape](https://reggiodigital.com/developers/conventions#errors). The ones this API returns are `unauthorized`, `forbidden`, `no_plan`, `invalid_request`, `not_found` and `idempotency_conflict`. --- # Embedding a signup form > Put a Reggio Digital newsletter signup form on any website with one line of HTML, or share it as a link. Source: https://reggiodigital.com/developers/signup-forms If all you need is a way for visitors to join a list, you do not need the API or a key. Every signup form built in the dashboard can be dropped onto any website with one line of HTML, or shared as a link. ## Getting the code The business creates and styles the form under [Newsletter forms](https://reggiodigital.com/newsletter/forms): which list it adds people to, the heading, the button label, whether it asks for a first name, and the message people see afterwards. The form's page shows two things to copy. **The embed code**, an iframe to paste wherever the form should appear: ```html ``` **The link**, a full page with the same form, for a link in a bio, a QR code or an email signature: ``` https://reggiodigital.com/n/f/YOUR_FORM_TOKEN ``` The token in both is unique to the form. Copy them from the dashboard rather than building them by hand. ## Fitting it to your page - The form fills the width it is given, so set the width on a container around the iframe. - `height:320px` fits a form that asks for an email address only. Raise it if the form asks for a first name or has a longer description, so there is no inner scrollbar. - The form carries the business's own branding from the dashboard. Change it there, and every page it is embedded on updates. ## What happens when someone signs up A signup through the form follows exactly the same rules as the [subscriber API](https://reggiodigital.com/developers/newsletter): - On a list that asks people to confirm, they get a confirmation email and are `pending` until they click it. Otherwise they are subscribed straight away. - Where the address came from, and when, is recorded as their consent. - Someone already on the list is not added twice, and someone who unsubscribed is not put back. - If you have [webhooks](https://reggiodigital.com/developers/webhooks) set up, `newsletter.subscriber.subscribed` fires, then `newsletter.subscriber.confirmed` when they confirm. The form protects itself against bots with Cloudflare Turnstile and a hidden field, and limits how often one visitor can submit. You do not need to add anything for that. ## When to use the API instead Use the [subscriber API](https://reggiodigital.com/developers/newsletter) when the signup happens inside your own software, such as a checkbox at checkout, when you need to set custom fields or tags, or when you want the form to match your own site's code exactly. Post your own form to your server, and have your server call the API. Never call the API from the visitor's browser. --- # Image optimizer plugin > Install the Reggio Image Optimizer on a WordPress site, connect it with a key, and control what it converts, how visitors get the smaller images and how to undo it. Source: https://reggiodigital.com/developers/image-optimizer The Reggio Image Optimizer is a WordPress plugin that makes a smaller WebP copy of each image in the media library, and AVIF as well if you turn it on, then gives that copy to visitors whose browser can read it. The file that was uploaded stays where it is, so every link to it keeps working and taking the plugin off leaves the site as it was. It needs WordPress 6.0 and PHP 8.1 or later. ## Which setup you need - **The site is on our hosting.** There is nothing to set up. The account's [Image Optimizer page](https://reggiodigital.com/images) lists each site and installs the plugin with one click. Images are converted on the server the site runs on, the server hands out the smaller copy before WordPress is even reached, and no key is needed. That page says which sites are included. - **The site is hosted anywhere else.** Install the plugin yourself and connect it with the account's key. The images are converted on our servers and written back to the site. ## Installing it yourself 1. Download the plugin from `https://reggiodigital.com/plugins/reggio-image-optimizer/download`. The same link is on the Image Optimizer page. 2. In WordPress, go to **Plugins, Add New Plugin, Upload Plugin**, choose the zip and activate it. 3. Copy the key from the [Image Optimizer page](https://reggiodigital.com/images). The key appears once image optimization has been added to the account, which a site we do not host needs. 4. In WordPress, go to **Media, Image Optimizer**, paste the key into **Reggio key** at the bottom and save. 5. Press **Optimize everything** at the top of the same screen to work through the images already in the library. New uploads are converted as they arrive. Updates come from us and appear in WordPress's own updates screen like any other plugin. ### One key, several sites One key covers every site on the account that is not on our hosting, up to 10. A site joins the list the first time it sends us an image, and the Image Optimizer page shows the list. When it is full, the next site shows an error saying so: remove a site you no longer use from that page to make room. Removing a site never takes away images it already converted. If a key leaks, replace it on the Image Optimizer page. The old key stops working straight away, so paste the new one into every site that uses it. ## What it converts Everything is on the **Media, Image Optimizer** screen. | Setting | Default | Notes | | --- | --- | --- | | Photographs (JPEG) | On | | | Graphics (PNG) | Off | Converting can soften the hard edges in logos and screenshots. Turn it on if the PNGs are photographs. | | Animations and graphics (GIF) | Off | A moving GIF is always left exactly as it is. | | AVIF | Off | Adds a second, smaller copy for browsers that support it. | | New uploads | On | Converts images as they are uploaded. | | Quality | 82 | Looks the same as the original to the eye. Higher keeps more detail and saves less. | | Sizes to skip | None | Leave particular WordPress image sizes alone. | A single image can be up to 32MB. Conversion runs in the background on WordPress's scheduler, which only runs when someone visits the site. On a quiet site with a big library, the progress on the settings screen can stall. Visiting the site moves it on, and so does the command line below. ## Where the files go Each copy sits beside its original with the new extension added to the end: `photo.jpg` gets `photo.jpg.webp`, and `photo.jpg.avif` when AVIF is on. That is the same naming Imagify uses, so a site moving from Imagify keeps the copies it already has and the plugin picks them up as they are. The media library gets an **Optimized** column showing what was saved for each image, with an **Undo** link that deletes that image's copies. ## How visitors get the smaller image The **Delivery** setting decides it. - **Decide for me**, the default, rewrites the page unless the server already serves the smaller copy itself, which is how sites on our hosting work. - **In the page** wraps each image in a `picture` element offering the smaller copies, with the original as the fallback. A browser that cannot read WebP or AVIF quietly takes the original. An image is only wrapped when every size in its `srcset` has a copy, so a half-converted image is never shown broken. - **My server already handles it** leaves the page alone. Choose it only if the web server swaps `photo.jpg` for `photo.jpg.webp` on its own. Some images never appear in an `img` tag: a WooCommerce gallery zoom, a lazy loader or a slider keeps the real address in an attribute and swaps it in with a script. The plugin also rewrites these attributes when a copy exists: `data-large_image`, `data-thumb`, `data-src`, `data-lazy-src`, `data-lazy`, `data-original`, `data-full` and `data-large`. Only WebP is used there, because nothing falls back to the original if a browser cannot read the file. A theme that uses a different attribute can add it: ```php add_filter('reggio_img_url_attributes', fn (array $attributes) => [...$attributes, 'data-hero']); ``` To keep the plugin away from one image entirely, add `data-reggio-skip` to its tag: ```html Logo ``` ## From the command line With WP-CLI on the server: ```bash wp reggio-images bulk --now wp reggio-images status wp reggio-images stats --format=json wp reggio-images optimize 123 --force wp reggio-images restore 123 wp reggio-images reclaim --dry-run ``` `bulk` queues the whole library, and `--now` works through it straight away instead of waiting for visitors. `stats` reports what the library has saved. `optimize` converts one attachment by its id, and `--force` rewrites copies that already exist. `restore` deletes one attachment's copies. `reclaim` is described next. ## Freeing up disk space Off by default, and the one thing here that cannot be undone. With **Delete the original once we have made the smaller copy** turned on, the plugin deletes originals it has already replaced, which on a photo-heavy site is most of the uploads folder. Visitors see no difference and every old address keeps working, because the plugin answers a request for the deleted file with its copy. It only removes an original when all of these are true: - Every size of that image has a copy that opens as a real WebP image and is no older than the original. - A full-size backup of the image exists in `wp-content/uploads/backup`, in the layout Imagify writes. An image with no backup is left alone. What you give up: the image can no longer be cropped or edited in WordPress, its Undo link goes away, and removing the plugin leaves only the smaller copies. Run `wp reggio-images reclaim --dry-run` first to see how much it would free. ## Taking it off Deactivating the plugin stops the page rewriting straight away. Deleting it leaves the converted copies on disk, harmless and unused. If originals were never freed up, the site underneath is exactly as it was before. --- # Sending from Laravel > Send email through Reggio Digital from a Laravel application, either through the API or by pointing Laravel's own mailer at our SMTP service. Source: https://reggiodigital.com/developers/guides/laravel A Laravel application can send through us in two ways. Pick the one that fits the code you already have. - **Over SMTP**, if the application already sends with `Mail::to()` and Mailables. Nothing in your code changes: you point Laravel's own mailer at us in `.env`. - **Through the API**, if you want what SMTP cannot give you: a message id back straight away, scheduling, batches, saved templates, and a [lookup](https://reggiodigital.com/developers/email#checking-what-happened) for every message. Both send from the business's own verified domain, count toward the same monthly allowance, and show up on the account's [Sent page](https://reggiodigital.com/email/sent). Plenty of applications use both: SMTP for the framework's own mail, such as password resets, and the API for receipts they want to track. ## Before you start The from address has to be on a sending domain the account has verified. If the business has not added one yet, they do it under [Sending domains](https://reggiodigital.com/domains/sending), and the dashboard lists the DNS records and checks them. See [the DNS a sending domain needs](https://reggiodigital.com/developers/smtp#the-dns-a-sending-domain-needs). ## Over SMTP Open the domain under [Sending domains](https://reggiodigital.com/domains/sending) and choose to connect an app. Copy the host, username and password from that page into your `.env`: ```dotenv MAIL_MAILER=smtp MAIL_SCHEME=smtp MAIL_HOST=YOUR_SMTP_HOST MAIL_PORT=587 MAIL_USERNAME=YOUR_SMTP_USERNAME MAIL_PASSWORD=YOUR_SMTP_PASSWORD MAIL_FROM_ADDRESS=hello@yourdomain.com MAIL_FROM_NAME="${APP_NAME}" ``` Port `587` with `MAIL_SCHEME=smtp` connects in plain text and upgrades to an encrypted connection before anything is sent, which is what our service expects. Do not set `MAIL_SCHEME=smtps`: that expects encryption from the first byte and the connection will hang until it times out. Older applications may still have `MAIL_ENCRYPTION=tls` in their `.env` and an `encryption` key in `config/mail.php`. Recent versions of Laravel ignore both, so leaving them does no harm, but `MAIL_SCHEME` is the setting that counts. Then send the way you already do, with `Mail::to()` and your Mailables. Run `php artisan config:clear` after changing `.env` on a server that caches its config, or the old mailer keeps being used. The credentials send as any address on the domain they were issued for and nothing else. `MAIL_FROM_ADDRESS`, and any `from()` in a Mailable, has to be on that domain. ## Through the API Get a key from [Connected apps](https://reggiodigital.com/email/api/keys) with the `email:send` ability. Keep it in `.env` and read it through a config file rather than calling `env()` in your code, so it still works once the config is cached: .env: ```dotenv REGGIO_API_KEY=rgo_your_key_here ``` config/services.php: ```php return [ 'reggio' => [ 'key' => env('REGGIO_API_KEY'), ], ]; ``` Sending is then one call with Laravel's HTTP client: ```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' => '

Thanks for your order.

', 'text' => 'Thanks for your order.', 'tags' => [ 'kind' => 'receipt', 'order' => '1042', ], ]); if ($response->failed()) { throw new RuntimeException($response->json('error.message')); } $body = $response->json(); ``` A `202` with an `id` means we accepted it. Store the id if you want to look the message up later or match it against a [webhook](https://reggiodigital.com/developers/webhooks). Anything else carries an [error](https://reggiodigital.com/developers/conventions#errors) whose `type` says what to do next. Sending from a queued job keeps a slow network call out of the request. If a job can be retried, send an [Idempotency-Key](https://reggiodigital.com/developers/conventions#idempotency) so a retry after a timeout never sends the message twice: ```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' => '

Thanks for your order.

', 'text' => 'Thanks for your order.', 'tags' => [ 'kind' => 'receipt', 'order' => '1042', ], ]); if ($response->failed()) { throw new RuntimeException($response->json('error.message')); } $body = $response->json(); ``` ## Testing without emailing anyone Send to the [test addresses](https://reggiodigital.com/developers/email#testing-without-emailing-anyone). They work over SMTP and the API alike, produce a real delivery, bounce or complaint, and never reach an inbox. --- # Sending from Node.js > Send email through Reggio Digital from Node.js, with a plain fetch call to the API or with Nodemailer over SMTP. Source: https://reggiodigital.com/developers/guides/node From Node.js there are two ways to send through us. - **Through the API** with `fetch`, which is built into Node 18 and later. No library to install, and you get a message id back, scheduling, batches, templates and a [lookup](https://reggiodigital.com/developers/email#checking-what-happened) for every message. - **Over SMTP** with Nodemailer, if the application already uses it or a framework that sits on top of it. You change the transport and nothing else. Both send from the business's own verified domain and show up on the account's [Sent page](https://reggiodigital.com/email/sent). ## Before you start The from address has to be on a sending domain the account has verified. If the business has not added one yet, they do it under [Sending domains](https://reggiodigital.com/domains/sending). See [the DNS a sending domain needs](https://reggiodigital.com/developers/smtp#the-dns-a-sending-domain-needs). ## Through the API Get a key from [Connected apps](https://reggiodigital.com/email/api/keys) with the `email:send` ability and give it to the process as `REGGIO_API_KEY`. Keep it on the server: never put it in code that runs in a browser, where anyone can read it. ```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": "

Thanks for your order.

", "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 `202` with an `id` means we accepted it. Anything else carries an [error](https://reggiodigital.com/developers/conventions#errors) in the same shape every time, so one handler covers every call. If the send happens inside something that retries, such as a queue worker, send an `Idempotency-Key` so a retry after a timeout never sends twice: ```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": "

Thanks for your order.

", "text": "Thanks for your order.", "tags": { "kind": "receipt", "order": "1042" } }), }); const body = await response.json(); if (!response.ok) { throw new Error(body.error.message); } ``` ## Over SMTP with Nodemailer Open the domain under [Sending domains](https://reggiodigital.com/domains/sending) and choose to connect an app. Put the host, username and password it shows into the environment as `REGGIO_SMTP_HOST`, `REGGIO_SMTP_USERNAME` and `REGGIO_SMTP_PASSWORD`, then create one transporter and reuse it: ```js import nodemailer from "nodemailer"; export const transporter = nodemailer.createTransport({ host: process.env.REGGIO_SMTP_HOST, port: 587, secure: false, requireTLS: true, auth: { user: process.env.REGGIO_SMTP_USERNAME, pass: process.env.REGGIO_SMTP_PASSWORD, }, }); export async function sendReceipt(to) { return transporter.sendMail({ from: "Your Shop ", to, subject: "Your receipt", text: "Thanks for your order.", html: "

Thanks for your order.

", }); } ``` `secure: false` with `requireTLS: true` on port `587` is the combination our service expects: the connection starts in plain text and must upgrade to an encrypted one before anything is sent. `secure: true` expects encryption from the first byte and will time out on this port. A display name in front of the address, as in `Your Shop `, is fine over SMTP. The address itself has to be on the domain the credentials were issued for. `sendMail` resolving means we accepted the message, not that it arrived. To hear about deliveries and bounces, set up [webhooks](https://reggiodigital.com/developers/webhooks). ## Testing without emailing anyone Send to the [test addresses](https://reggiodigital.com/developers/email#testing-without-emailing-anyone). They work over SMTP and the API alike, and never reach a real inbox. --- # Sending from WordPress over SMTP > Make a WordPress site hosted anywhere send its email through Reggio Digital over SMTP, with a small must-use plugin or an SMTP plugin you already run. Source: https://reggiodigital.com/developers/guides/wordpress WordPress sends its email, from password resets to shop orders and contact forms, through one function, and out of the box that function hands mail to whatever the web server has. On most hosts that mail arrives late, lands in spam, or never arrives. Pointing WordPress at us over SMTP sends it from the business's own verified domain instead. **If we host the site, stop here.** We connect sites on our hosting for you, with nothing to install. [Open a ticket](https://reggiodigital.com/tickets/create) and say which site. ## Before you start - The from address has to be on a sending domain the account has verified. If the business has not added one yet, they do it under [Sending domains](https://reggiodigital.com/domains/sending). See [the DNS a sending domain needs](https://reggiodigital.com/developers/smtp#the-dns-a-sending-domain-needs). - Open that domain and choose to connect an app. The page shows the host, username and password you need below. ## With a small must-use plugin This is the version with nothing to update and no settings screen for someone to change by accident. Put the credentials in `wp-config.php`, above the line that says to stop editing: wp-config.php: ```php define('REGGIO_SMTP_HOST', 'YOUR_SMTP_HOST'); define('REGGIO_SMTP_USERNAME', 'YOUR_SMTP_USERNAME'); define('REGGIO_SMTP_PASSWORD', 'YOUR_SMTP_PASSWORD'); define('REGGIO_SMTP_FROM', 'hello@yourdomain.com'); ``` wp-content/mu-plugins/reggio-smtp.php: ```php isSMTP(); $phpmailer->Host = REGGIO_SMTP_HOST; $phpmailer->Port = 587; $phpmailer->SMTPSecure = 'tls'; $phpmailer->SMTPAutoTLS = true; $phpmailer->SMTPAuth = true; $phpmailer->Username = REGGIO_SMTP_USERNAME; $phpmailer->Password = REGGIO_SMTP_PASSWORD; }); add_filter('wp_mail_from', fn () => REGGIO_SMTP_FROM); ``` Save the second block as its own file in `wp-content/mu-plugins/`, creating the folder if it is not there. WordPress loads everything in that folder on every request and it cannot be switched off from the admin screens. Port `587` with `SMTPSecure` set to `tls` connects in plain text and upgrades to an encrypted connection before anything is sent, which is what our service expects. `ssl` expects encryption from the first byte and will time out on this port. The `wp_mail_from` line matters. WordPress sends as `wordpress@` the site's own hostname unless told otherwise, and if the site's address is not on the verified domain every message is refused. ## With an SMTP plugin you already run If the site already has an SMTP plugin, such as WP Mail SMTP, FluentSMTP or Post SMTP, use it instead of the code above. Do not run both. In its settings choose the generic or "other SMTP" option, not a named provider, and enter: | Setting | What to enter | | --- | --- | | SMTP host | Copy it from the domain's page. | | Port | `587` | | Encryption | `TLS` (sometimes shown as STARTTLS) | | Authentication | On | | Username and password | Copy them from the domain's page. | | From email | An address on the verified domain, such as `hello@yourdomain.com`. Tick "force from email" if the plugin offers it, so other plugins cannot send as a different address. | Most of these plugins can keep the password in `wp-config.php` rather than in the database. Use that where it is offered. ## Checking it works Send the plugin's test email, or reset a password, to one of the [test addresses](https://reggiodigital.com/developers/email#testing-without-emailing-anyone). The message appears on the account's [Sent page](https://reggiodigital.com/email/sent) within a minute. If nothing shows up there, WordPress never reached us: check the host and port, and look in the site's error log for the SMTP error. Some contact form and shop plugins set their own from address per form or per email. Check each one is on the verified domain, or those messages alone will be refused. --- # Moving from Resend > What changes when you move an integration from Resend to the Reggio Digital email API, and the order to do it in. Source: https://reggiodigital.com/developers/guides/resend If your software already sends through Resend, this is what has to change to point it at us instead. In most projects it is the address you post to and a handful of field shapes. ## Read this first - **We are not a drop-in replacement.** The Resend libraries post to Resend's own address and shape several fields differently, so swapping the key is not enough. Take the library out and call us over plain HTTP. The [transactional email API](https://reggiodigital.com/developers/email) has worked examples in cURL, PHP, Laravel and Node. - **The ideas carry across.** You still post a message, get an id back, and look that id up to see what happened. - **Nothing has to be switched off to start.** Both providers can be set up on the same domain at once, so you can move one kind of message and leave the rest where it is. ## What changes in your code | Part | With Resend | With us | | --- | --- | --- | | Where you post | `api.resend.com/emails` | `https://reggiodigital.com/api/v1/emails` | | Your key | `Authorization: Bearer` with a Resend key | The same header with a Reggio key from [Connected apps](https://reggiodigital.com/email/api/keys). | | A name on the sender | `Acme ` works. | Send the address on its own. A name in front is refused with a `422`. | | One recipient | A string or a list. | Always a list. | | Tags | A list of `{name, value}` entries. | One object of name and value pairs: `{"kind": "receipt"}`. Names use letters, numbers, dashes, underscores, colons and dots. | | A batch | The list of messages is the whole body. | The list goes under `emails`, up to 100. One bad message rejects the whole batch. | | A send that worked | `200` | `202`. Code that checks for exactly `200` needs changing. | | Cancelling a scheduled message | `POST` to a cancel address. | `DELETE /emails/{id}`. | | Moving a scheduled message | Supported. | `PATCH /emails/{id}` with a new `scheduled_at`, up to 30 days out. It keeps its id. | | Errors | A message and a name at the top level. | Always `{"error": {"type", "message"}}`, with `fields` when a field is the problem. See [errors](https://reggiodigital.com/developers/conventions#errors). | | The id you get back | One consistent shape. | A message sent now and a message held for later have different shapes. Store whatever you are given. | | Looking a message up | Returns the message, content included. | Returns what happened to it: status, times and a plain explanation of any bounce. Kept 30 days. | | Blocked addresses | Handled on their side. | We skip an address we have stopped sending to and return it in `suppressed`. That is not an error. | | Rate limit | A few requests a second. | 120 requests a minute per account. Every send also returns what is left of the monthly allowance. | | Libraries | Official SDKs. | None yet. Plain HTTP is the supported way in. | ## The order to do it in Nothing here needs a maintenance window, and every step is reversible until the last. 1. **Verify your domain with us.** Add it under [Sending domains](https://reggiodigital.com/domains/sending) and put the records it gives you in your DNS. Resend's records stay where they are. Two providers signing mail for one domain is normal. If the domain already has an SPF record, merge ours into it rather than adding a second. 2. **Get a key.** Connect your application under [Connected apps](https://reggiodigital.com/email/api/keys). Put the key in your environment beside the Resend one, not over it, so going back is a one line change. 3. **Change the code.** Work down the table above. 4. **Test without emailing anyone.** Send to the [test addresses](https://reggiodigital.com/developers/email#testing-without-emailing-anyone). They produce a real delivery, bounce and spam complaint on demand, and nothing reaches a real inbox. Check your bounce handling against them, and confirm each one on the account's [Sent page](https://reggiodigital.com/email/sent). 5. **Bring your blocked addresses across.** Export the addresses Resend has stopped sending to and [open a ticket](https://reggiodigital.com/tickets/create) with the file. We load them before you move real traffic. This is the step that matters most: an address that bounced or complained on Resend starts fresh on our side, and mailing it again costs you reputation you already paid for. 6. **Move one kind of message first.** Something low volume that you would notice going wrong, such as password resets. Watch the Sent page for a few days. 7. **Move the rest, then stop.** Keep the Resend key working for a week or two after the last message. Once you are sure, close the Resend account and remove its DNS records last. Removing them while anything still sends through Resend bounces that mail. ## If you would rather we did it We do this work. Open a ticket and we will change the code, test it against the test addresses, and move the traffic across with you watching. If the site is WordPress there is usually no code at all: we connect the site to send through us directly. --- # Changelog > Changes to the Reggio Digital API that an integration would notice, newest first. Source: https://reggiodigital.com/developers/changelog Changes an integration would notice, newest first. Additions within `v1` never break existing code; see [versioning](https://reggiodigital.com/developers/conventions#versioning). ## 2026-09-14 - Published these developer docs. - A `429` from the rate limit now uses the standard error shape, with the type `rate_limited`, instead of a bare message. ## 2026-09-11 - Connect several applications to one account, each with its own key, abilities and optional sending-domain limits. - Replace a key with an overlap of up to a week, so the old one keeps working while you deploy the new one. - New `newsletter:read` ability, for a key that can look subscribers up but not change them. ## 2026-09-10 - Webhooks: signed notifications for email delivery events, newsletter subscribers, preference changes and received mail. - Every response carries `X-Reggio-Request-Id`, and every error repeats it as `error.request_id`. - `PATCH /api/v1/emails/{id}` moves a scheduled message to a new time. ## 2026-09-09 - The newsletter subscriber API: lists, fields, add or update, look up and unsubscribe, with the `newsletter:manage` ability. ## 2026-09-08 - Send a published email template with `template_id` and `variables`. - Validation failures use the standard error shape, with `error.fields`. - `suppressed` is returned on every kind of send, including scheduled sends and each message in a batch. ## 2026-09-01 - Scheduled sends with `scheduled_at`, cancelled with `DELETE /api/v1/emails/{id}`. - `POST /api/v1/emails/batch` for up to 100 messages at once. - `tags`, custom `headers` and the `Idempotency-Key` header. - Attachments, by content or by link, and inline images. - `GET /api/v1/emails/{id}` to see what happened to a message. ## 2026-08-24 - The transactional email API: `POST /api/v1/emails`. - SMTP credentials for any verified sending domain.