# Emails

> 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`. -->

These routes handle email sent from your own code and public email-address verification. The `emails.send` route is the first-party shape of the same send the [Resend facade](/resend/) serves, authenticating with an org API key and answering in the envelope with the delivery id. Unlike the facade it can address a profile instead of an address, which is how a message its recipient can turn off is sent. The public verification routes require no Cowliss credentials and answer with HTML pages rather than the envelope. Verification links authorize through a single-use `?token=` that expires after 48 hours, and an invalid, expired, or already-spent token is rejected. The `GET` request only offers the verification and the `POST` request performs it, so a link scanner that fetches the URL does not verify the address.

## POST /v1/emails

Send one email to an address or a profile

The recipient is an address, a list of addresses, or one identifier naming a profile in the source's app, such as `{ "userId": "shop_1" }`. An `email` identifier no profile holds yet creates that profile first, exactly as an identify carrying that address as identifier and trait would. A send to a profile goes to its `email` trait, else its newest `email` identifier, asks that profile's consent for the template's purpose, and carries the unsubscribe headers and footer when the recipient can turn the purpose off. A template the recipient can turn off cannot go to a bare address. With `test: true` the send runs every check and refuses exactly as a real one would, then records a dry-run delivery (`would_send` or `would_skip_*`) and answers with the rendered message, sending nothing, creating no profile and spending neither the `Idempotency-Key` nor credit. An address at `test.cowliss.com` (`delivered@`, `bounced@`, `complained@`, `suppressed@`) is a real send whose outcome the address picks.

**Auth:** Org API key (`Authorization: Bearer <key>`).

**Request body**

```json
{
  "data": {
    "sourceId": "string",
    "to": "string",
    "cc": "string",
    "bcc": "string",
    "replyTo": "string",
    "from": "string",
    "subject": "string",
    "html": "string",
    "text": "string",
    "headers": {
      "key": "string"
    },
    "template": {
      "key": "string",
      "props": {
        "key": {}
      }
    },
    "test": true
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.sourceId` | string | yes | length 0–200 |
| `data.to` | string \| string[] \| record\<anonymousId \| userId \| clerkId \| email, string> | yes |  |
| `data.cc` | string \| string[] | no |  |
| `data.bcc` | string \| string[] | no |  |
| `data.replyTo` | string \| string[] | no |  |
| `data.from` | string | yes | length 0–200 |
| `data.subject` | string | no | length 0–1000 |
| `data.html` | string | no |  |
| `data.text` | string | no |  |
| `data.headers` | record\<string, string> | no |  |
| `data.template` | object | no |  |
| `data.template.key` | string | yes | length 0–64 |
| `data.template.props` | record\<string, any> | no |  |
| `data.test` | boolean | no |  |

**Response 201**

```json
{
  "data": {
    "deliveryId": "string",
    "status": "sent",
    "wouldCreateProfile": true,
    "rendered": {
      "subject": "string",
      "html": "string",
      "text": "string"
    }
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.deliveryId` | string | yes |  |
| `data.status` | "sent" \| "delivered" \| "bounced" \| "complained" \| "failed" \| "skipped_disabled" \| "skipped_paused" \| "skipped_suspended" \| "skipped_suppressed" \| "skipped_quota" \| "skipped_unknown_purpose" \| "skipped_frequency_cap" \| "skipped_consent" \| "skipped_domain" \| "skipped_ssrf" \| "skipped_spam" \| "would_send" \| "would_skip_disabled" \| "would_skip_paused" \| "would_skip_suspended" \| "would_skip_suppressed" \| "would_skip_quota" \| "would_skip_unknown_purpose" \| "would_skip_frequency_cap" \| "would_skip_consent" \| "would_skip_domain" \| "would_skip_ssrf" \| "would_skip_spam" | yes |  |
| `data.wouldCreateProfile` | boolean | no |  |
| `data.rendered` | object | no |  |
| `data.rendered.subject` | string | yes |  |
| `data.rendered.html` | string | yes |  |
| `data.rendered.text` | string | 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).

## POST /v1/email-previews

Render a template with your own props

What a send of this template to an address would carry, rendered with the `props` you pass, in the source's app. Nothing is sent and no profile is read, so a template its recipients can turn off renders without its unsubscribe footer.

**Auth:** Org API key (`Authorization: Bearer <key>`).

**Request body**

```json
{
  "data": {
    "key": "string",
    "props": {
      "key": {}
    },
    "sourceId": "string"
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.key` | string | yes | length 0–64 |
| `data.props` | record\<string, any> | no |  |
| `data.sourceId` | string | yes | length 0–200 |

**Response 200**

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

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.subject` | string | yes |  |
| `data.html` | string | yes |  |
| `data.text` | string | yes |  |
| `data.versionId` | string | 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).

## POST /v1/preferences-links

Link one profile to its email preferences page

For your app's own "Manage email preferences" button: `to` is one identifier naming a profile in the source's app, resolved exactly as a send to a profile resolves it. The `url` opens the hosted preferences page for that profile and works for one hour, so mint it when the person clicks.

**Auth:** Org API key (`Authorization: Bearer <key>`).

**Request body**

```json
{
  "data": {
    "sourceId": "string",
    "to": {
      "anonymousId": "string"
    }
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.sourceId` | string | yes | length 0–200 |
| `data.to` | record\<anonymousId \| userId \| clerkId \| email, string> | yes |  |

**Response 201**

```json
{
  "data": {
    "url": "https://example.com/hooks/cowliss",
    "expiresAt": "2026-01-15T09:30:00.000Z"
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.url` | string (uri) | yes |  |
| `data.expiresAt` | string (date-time) | 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).

## POST /v1/profile-preferences/find

Read one profile's email preferences

For your app's own preferences screen: `to` is one identifier naming a profile in the source's app, as for a preferences link. Answers one row for `marketing` and one for each purpose the app declares. `enabled` is whether a send under that purpose would reach them now, and `answered` is whether they hold their own answer for it rather than the default (marketing off, transactional on).

**Auth:** Org API key (`Authorization: Bearer <key>`).

**Request body**

```json
{
  "data": {
    "sourceId": "string",
    "to": {
      "anonymousId": "string"
    }
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.sourceId` | string | yes | length 0–200 |
| `data.to` | record\<anonymousId \| userId \| clerkId \| email, string> | yes |  |

**Response 200**

```json
{
  "data": {
    "purposes": [
      {
        "key": "string",
        "label": "string",
        "description": "string",
        "enabled": true,
        "answered": true
      }
    ]
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.purposes` | object[] | yes |  |
| `data.purposes[]` | object | yes |  |
| `data.purposes[].key` | string | yes |  |
| `data.purposes[].label` | string | yes |  |
| `data.purposes[].description` | string \| null | yes |  |
| `data.purposes[].enabled` | boolean | yes |  |
| `data.purposes[].answered` | boolean | 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).

## POST /v1/profile-preferences

Save one profile's email preferences

Saves what the person chose on your app's own preferences screen, exactly as the hosted page saves it. `consent` names only the switches to change: `marketing` or a purpose the app declares. Turning a `marketing.` purpose on turns `marketing` on, and turning `marketing` off turns every `marketing.` purpose off. Each switch that changes writes `system.consent_granted` or `system.consent_revoked` with reason `preferences`. A key the app does not declare is refused and nothing is saved. Answers with the rows as they now stand.

**Auth:** Org API key (`Authorization: Bearer <key>`).

**Request body**

```json
{
  "data": {
    "sourceId": "string",
    "to": {
      "anonymousId": "string"
    },
    "consent": {
      "marketing.newsletter": true
    }
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.sourceId` | string | yes | length 0–200 |
| `data.to` | record\<anonymousId \| userId \| clerkId \| email, string> | yes |  |
| `data.consent` | record\<string, boolean> | yes |  |

**Response 200**

```json
{
  "data": {
    "purposes": [
      {
        "key": "string",
        "label": "string",
        "description": "string",
        "enabled": true,
        "answered": true
      }
    ]
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.purposes` | object[] | yes |  |
| `data.purposes[]` | object | yes |  |
| `data.purposes[].key` | string | yes |  |
| `data.purposes[].label` | string | yes |  |
| `data.purposes[].description` | string \| null | yes |  |
| `data.purposes[].enabled` | boolean | yes |  |
| `data.purposes[].answered` | boolean | 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).

## GET /v1/public/verify

Verification confirmation page (public)

**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/public/verify

Verify an email address (public)

Marks the address verified. If the profile associated with the link still carries that address, sets `emailVerified` to `true` and records a `system.email_verified` event.

**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).
