Appearance
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}/topicsReturns 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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.
| Field | Type | Description |
|---|---|---|
id | string | The topic id — the value you pass in the update body. |
name | string | The human-readable name. |
description | string | null | The topic's description, or null if it has none. |
subscription | string | This 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}/topicsPath parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The contact id, or the contact's email address. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
topics | array | Yes | An 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.