# Topics

The categories contacts subscribe to. A broadcast scoped to a topic reaches only the contacts opted in to it, and the hosted preference page is where they choose.

## How a topic decides

- Each contact has an effective subscription to every topic: their own choice where one was recorded, and the topic's `default_subscription` otherwise. A broadcast with a `topic_id` goes only to contacts whose effective subscription is `opt_in`.
- `default_subscription` is fixed at creation. Changing it would silently flip everyone who never chose, so the API refuses rather than asking you to be careful.
- A `public` topic is listed on every contact's preference page. A `private` one appears only for contacts already opted in — for a list people join somewhere else and should be able to leave here.
- A global unsubscribe wins over every topic: a contact with `unsubscribed: true` receives nothing, whatever their choices say.

## Consent

Every choice — from the API, an import, the preference page or a one-click unsubscribe — writes a consent record with its source, time and, where there was one, the IP address and user agent. Records are kept for three years and survive the contact's erasure with a hash of the address, which is what lets an opt-out be proven later.

## Endpoints

### `POST /topics`

A category contacts subscribe to, with a default for those who never chose.

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `name` (required) | string | Up to 50 characters, unique among live topics. |
| `default_subscription` (required) | string | `opt_in` or `opt_out`: what a contact with no explicit choice is treated as. Fixed after creation. |
| `description` | string | Up to 200 characters, shown on the preference page. |
| `visibility` | string | `public` lists the topic on every contact's preference page; `private` (the default) only for contacts already opted in. |

Create a topic:

```sh
curl -X POST "https://api.rasket.com/topics" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Product updates",
  "description": "What shipped this month.",
  "default_subscription": "opt_in",
  "visibility": "public"
}'
```

```ts
const response = await fetch("https://api.rasket.com/topics", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Product updates",
    description: "What shipped this month.",
    default_subscription: "opt_in",
    visibility: "public"
  }),
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/topics",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "name": "Product updates",
    "description": "What shipped this month.",
    "default_subscription": "opt_in",
    "visibility": "public"
  },
)

id = response.json()["id"]
```

#### Response `201`

```json
{
  "object": "topic",
  "id": "0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17"
}
```

- A live topic with the same name is `422 invalid_parameter`.
- A broadcast scoped to a topic goes only to contacts whose effective subscription to it is `opt_in`.

### `GET /topics`

Every live topic, newest first.

#### Query parameters

| Field | Type | Description |
| --- | --- | --- |
| `limit` | integer | How many items to return, 1–100. Defaults to 20. |
| `after` | string | Return the page that follows this item ID. Mutually exclusive with `before`. |
| `before` | string | Return the page that precedes this item ID. Mutually exclusive with `after`. |

List topics:

```sh
curl -X GET "https://api.rasket.com/topics" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/topics", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const data = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/topics",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.json())
```

#### Response `200`

```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17",
      "name": "Product updates",
      "description": "What shipped this month.",
      "default_subscription": "opt_in",
      "visibility": "public",
      "created_at": "2026-09-08T22:22:17.595Z"
    }
  ]
}
```

### `GET /topics/{id}`

One topic by ID.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The topic's ID. |

Retrieve a topic:

```sh
curl -X GET "https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "topic",
  "id": "0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17",
  "name": "Product updates",
  "description": "What shipped this month.",
  "default_subscription": "opt_in",
  "visibility": "public",
  "created_at": "2026-09-08T22:22:17.595Z"
}
```

### `PATCH /topics/{id}`

Change the name, description or visibility.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The topic's ID. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Up to 50 characters. |
| `description` | string \| null | `null` clears it. |
| `visibility` | string | `public` or `private`. |

Update a topic:

```sh
curl -X PATCH "https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "visibility": "private"
}'
```

```ts
const response = await fetch("https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    visibility: "private"
  }),
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.patch(
    "https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "visibility": "private"
  },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "topic",
  "id": "0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17"
}
```

- `default_subscription` cannot change: a body that carries it is `422 invalid_parameter`. Changing it would silently resubscribe or unsubscribe everyone who never chose.

### `DELETE /topics/{id}`

Retire the topic and free its name.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `id` (required) | string | The topic's ID. |

Delete a topic:

```sh
curl -X DELETE "https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.delete(
    "https://api.rasket.com/topics/0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "topic",
  "id": "0199d2f1-4c4e-7a20-9c31-6f2b8a0e5d17",
  "deleted": true
}
```

- The topic is `404` from that moment. Emails and consent records that name it keep doing so.
