Skip to content

Contact topics

A topic is an opt-in or opt-out category — "Product updates", "Weekly digest" — that a contact can subscribe to independently of the global unsubscribe. See Audiences for how topics fit together with segments.

Both endpoints require a Full access API key, and both accept the contact's id or its email address in the path.

List a contact's topics

http
GET /v1/contacts/{id}/topics

Returns every topic on your team, each with this contact's effective subscription state — not just the ones they've touched. A contact who has never responded to a topic still has a state: an opt_out topic has them in by default, an opt_in topic has them out. Listing only the rows that exist would hide that, and you'd have to know each topic's default mode to work out the truth.

Path parameters

ParameterTypeRequiredDescription
idstringYesThe contact id, or the contact's email address.

Example

bash
curl "https://api.sendgrail.com/v1/contacts/ada@example.com/topics" \
  -H "Authorization: Bearer $SENDGRAIL_API_KEY"
js
const res = await fetch(
  `https://api.sendgrail.com/v1/contacts/${encodeURIComponent('ada@example.com')}/topics`,
  { headers: { Authorization: `Bearer ${process.env.SENDGRAIL_API_KEY}` } },
);

const { data } = await res.json();

Response

json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "b1e7c3d9-42a6-4c0f-8b5e-9d3f1a7c6e20",
      "name": "Product updates",
      "description": "Changelog and feature announcements.",
      "subscription": "opt_in"
    },
    {
      "id": "e5c8f2a1-70b4-4d19-9c3a-6f2e8b0d1a74",
      "name": "Weekly digest",
      "description": "A Monday roundup of the week ahead.",
      "subscription": "opt_out"
    }
  ]
}

The list envelope matches every other list, but this one is not paginated — has_more is always false and you always get the full set, sorted by name. A team has a handful of topics, not thousands, so there is nothing to page through.

FieldTypeDescription
idstringThe topic id — the value you pass in the update body.
namestringThe human-readable name.
descriptionstring | nullThe topic's description, or null if it has none.
subscriptionstringThis contact's effective state as a subscription action — opt_in or opt_out — their own choice if they've made one, otherwise the topic's default.

Update a contact's topics

http
PATCH /v1/contacts/{id}/topics

Path parameters

ParameterTypeRequiredDescription
idstringYesThe contact id, or the contact's email address.

Body parameters

ParameterTypeRequiredDescription
topicsarrayYesAn array of { "id", "subscription" } objects, at least one. id is a topic on your team; subscription is opt_in (subscribe) or opt_out (unsubscribe).

Only the topics you name are touched. Everything else is left exactly as it was, so a preference-centre form can submit the switches it owns without needing to know about topics it doesn't render.

Example

bash
curl -X PATCH "https://api.sendgrail.com/v1/contacts/ada@example.com/topics" \
  -H "Authorization: Bearer $SENDGRAIL_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "topics": [
      { "id": "b1e7c3d9-42a6-4c0f-8b5e-9d3f1a7c6e20", "subscription": "opt_in" },
      { "id": "e5c8f2a1-70b4-4d19-9c3a-6f2e8b0d1a74", "subscription": "opt_out" }
    ]
  }'
js
const res = await fetch(
  `https://api.sendgrail.com/v1/contacts/${encodeURIComponent('ada@example.com')}/topics`,
  {
    method: 'PATCH',
    headers: {
      Authorization: `Bearer ${process.env.SENDGRAIL_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      topics: [
        { id: 'b1e7c3d9-42a6-4c0f-8b5e-9d3f1a7c6e20', subscription: 'opt_in' },
        { id: 'e5c8f2a1-70b4-4d19-9c3a-6f2e8b0d1a74', subscription: 'opt_out' },
      ],
    }),
  },
);

const { data } = await res.json();

Response

The same list the GET returns — every topic with the contact's state, so you can render the result without a second request:

json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "b1e7c3d9-42a6-4c0f-8b5e-9d3f1a7c6e20",
      "name": "Product updates",
      "description": "Changelog and feature announcements.",
      "subscription": "opt_in"
    },
    {
      "id": "e5c8f2a1-70b4-4d19-9c3a-6f2e8b0d1a74",
      "name": "Weekly digest",
      "description": "A Monday roundup of the week ahead.",
      "subscription": "opt_out"
    }
  ]
}

A topic id that isn't one of your team's topics is a 404 — a typo'd id would otherwise be a silent no-op, and the caller would believe a preference had been saved when nothing was:

json
{
  "message": "No topic with the id e5c8f2a1-70b4-4d19-9c3a-6f2e8b0d1a74.",
  "code": "not_found",
  "request_id": "8f2c1e64-1b17-4f8c-9f0e-3b6c1e0f5c31"
}

The call is all or nothing: it runs in a transaction, so an unknown id rolls the whole update back and a 404 means nothing changed — not even the topics listed before the bad one. Correct the id and re-send.

Topics versus the global unsubscribe

unsubscribed on the contact object is the global switch: true and the contact receives nothing at all, whatever their topics say. A topic subscription only decides whether they receive mail for that category while they remain globally subscribed.

Set the global flag with Update a contact. See Errors for the error envelope.

Transactional email on your own domain.