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.

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 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 https://reggiodigital.com/api/v1/newsletter/lists \
  -H "Authorization: Bearer $REGGIO_API_KEY"
{
  "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 https://reggiodigital.com/api/v1/newsletter/fields \
  -H "Authorization: Bearer $REGGIO_API_KEY"
{
  "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. 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 -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"
      }
  }'

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:

{
  "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.

Looking someone up

GET /api/v1/newsletter/contacts/{email}, with the address URL encoded.

curl https://reggiodigital.com/api/v1/newsletter/contacts/sam%40example.com \
  -H "Authorization: Bearer $REGGIO_API_KEY"
{
  "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 -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"
  }'
  • 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.

Errors

The usual error shape. The ones this API returns are unauthorized, forbidden, no_plan, invalid_request, not_found and idempotency_conflict.