# 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`.
