# Subscribe

> Part of the Cowliss docs. Markdown for every page: append `index.md` to its URL. Site map: /llms.txt

<!-- GENERATED by apps/docs/scripts/generate-api-reference.ts. Do not edit. Regenerate with `pnpm -F cowliss-docs generate`. -->

Every list has a public signup page at its `signupUrl` (see [lists](/reference/api/lists/)), and an HTML form on your own site can post to the same URL. Signing up is always double opt-in: the endpoint emails a confirm link, and the person joins the list only when they click it, with `system.consent_granted` recorded with reason `form`. The endpoint answers the same way whether or not the address is new, and sends nothing for an address that already subscribes. A form posts two fields: `email`, and `website`, a hidden field a person leaves empty (a post that fills it is taken as a bot's and sends nothing). The signup `POST` answers any origin, so your site can also post it with `fetch()`; every other route keeps its CORS allowlist. It takes 60 requests a minute per client IP and as many per list. The client IP is the address that connects to Cowliss, so posting from your own server puts every visitor in that server's one window: post from the visitor's browser instead. The confirm email and every page (the signup form, check your inbox, confirm, subscribed, and the unsubscribe and preferences pages) use the app's [Brand](/tracking/apps/#brand): its logo, colours, font, business name and website, and its language (English, French, Romanian or Spanish). The confirm email leaves from the app's default sender; while the app has none, a signup sends nothing and the page says the list cannot take signups right now (a JSON post gets `dependency_unavailable`). The confirm email counts toward your usage like any send. These routes need no credential and answer with HTML pages; a JSON post gets the envelope. Like the verification link, the confirm link's `GET` only offers the confirmation and the `POST` performs it.

## GET /v1/subscribe/confirm

Subscription confirm page (public)

Changes nothing: it offers a Confirm button that POSTs to the same URL, so a link scanner that fetches the link does not subscribe anyone. An expired link shows a form to send a new one.

**Auth:** None: verifies the caller's own signature/token.

**Query parameters**

```json
{
  "token": "string"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `token` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## POST /v1/subscribe/confirm

Confirm a subscription (public)

Finds or creates the profile in the list's app by its `email` identifier and grants the list, recording `system.consent_granted` with reason `form`. The link is single-use and expires after 48 hours; an expired or used link shows a form to send a new one.

**Auth:** None: verifies the caller's own signature/token.

**Query parameters**

```json
{
  "token": "string"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `token` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## GET /v1/subscribe/{listToken}

A list's signup page (public)

**Auth:** None: verifies the caller's own signature/token.

Errors follow the [shared envelope and code table](/api-conventions/#errors).

## POST /v1/subscribe/{listToken}

Ask to join a list (public)

Sends a confirm email to the address, which joins the list only once its link is clicked. Answers 202 whether or not the address is new, and sends nothing when it already subscribes, a link sent to it for this list still works, the organization has sending paused, or the hidden `website` field is filled. A browser form gets the check-your-inbox page, a JSON call the envelope. Answers any origin (CORS), so a site can post with `fetch()`. Limited to 60 requests a minute per client IP and per list. Answers 503 while the list's app has no default sender, since there is nothing to send the confirm email from.

**Auth:** None: verifies the caller's own signature/token.

**Request body**

```json
{
  "data": {
    "email": "ada@example.com",
    "website": "string"
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.email` | string (email) | yes | length 0–320 |
| `data.website` | string | no |  |

**Response 202**

```json
{
  "data": {
    "accepted": true
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.accepted` | true | yes |  |
| `meta` | object | no |  |
| `meta.requestId` | string | no |  |
| `meta.nextCursor` | string \| null | no |  |
| `meta.hasMore` | boolean | no |  |
| `meta.warnings` | object[] | no |  |
| `meta.warnings[]` | object | no |  |
| `meta.warnings[].code` | string | yes |  |
| `meta.warnings[].message` | string | yes |  |
| `meta.warnings[].suggestion` | string | no |  |

Errors follow the [shared envelope and code table](/api-conventions/#errors).
