# Templates

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

Templates are the email templates an automation sends, each addressed by the key its author gave the file. A key is unique per app, so two apps may each have a `welcome`, and a send only ever uses its own app's. Every route that names a key also takes `appId`, and a request without it is a **422** naming the parameter. A template is Liquid HTML (a subject and a body rendered with the automation's props as the scope) or a doc; both are edited in the dashboard as well as pushed from a repo, and a push, create and update here write either; every version records who wrote it, so a push over a template edited here is a conflict until the repo pulls. A template has versions and nothing else, no switch, because a send resolves the latest ready version at the moment it sends, so fixing copy reaches executions already in flight.

## GET /v1/templates

List the organization's email templates

**Auth:** Session token.

**Query parameters**

```json
{
  "cursor": "string",
  "limit": 50,
  "appId": "string",
  "q": "string"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `cursor` | string | no |  |
| `limit` | integer | no | default `50`; 1 ≤ x ≤ 100 |
| `appId` | string | no | length 0–200 |
| `q` | string | no | length 0–200 |

**Response 200**

```json
{
  "data": [
    {
      "key": "string",
      "appId": "string",
      "tags": {},
      "purpose": "string",
      "kind": "liquid",
      "latestVersion": {
        "id": "string",
        "status": "compiling",
        "appId": "string",
        "error": "string",
        "protocol": 42,
        "source": "push",
        "pushedAt": "2026-01-15T09:30:00.000Z",
        "bundle": "string"
      },
      "automations": [
        "string"
      ],
      "template": {
        "kind": "liquid",
        "subject": "string",
        "purpose": "string",
        "tags": [],
        "preview": "string",
        "html": "string"
      }
    }
  ],
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object[] | yes |  |
| `data[]` | object | yes |  |
| `data[].key` | string | yes | length 0–64 |
| `data[].appId` | string | yes |  |
| `data[].tags` | any | yes |  |
| `data[].purpose` | string \| null | yes |  |
| `data[].kind` | "liquid" \| "doc" \| "module" | yes |  |
| `data[].latestVersion` | object | yes |  |
| `data[].latestVersion.id` | string | yes |  |
| `data[].latestVersion.status` | "compiling" \| "ready" \| "failed" | yes |  |
| `data[].latestVersion.appId` | string | yes |  |
| `data[].latestVersion.error` | string \| null | yes |  |
| `data[].latestVersion.protocol` | integer | yes | -2147483648 ≤ x ≤ 2147483647 |
| `data[].latestVersion.source` | "push" \| "api" | yes |  |
| `data[].latestVersion.pushedAt` | string (date-time) | yes |  |
| `data[].latestVersion.bundle` | string | yes |  |
| `data[].automations` | string[] | yes |  |
| `data[].template` | object \| object \| null | 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/templates

Create a template

Admin only. Writes the same version a push of `emails/<key>.html` or `emails/<key>.md` would: Liquid HTML (`kind: "liquid"`, a subject and an html body rendered with the automation's props as the scope) or a Maily doc (`kind: "doc"`, whose `{{variables}}` are filled from the person's traits and the automation's props, props winning). The version records that the API wrote it, so a later push of the same key is a conflict until the repo pulls. Refused with `conflict` when the app already has a template of that key.

**Auth:** Session token.

**Request body**

```json
{
  "data": {
    "appId": "string",
    "key": "string",
    "template": {
      "kind": "liquid",
      "subject": "string",
      "purpose": "string",
      "tags": [],
      "preview": "string",
      "html": "string"
    }
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.appId` | string | yes | length 0–200 |
| `data.key` | string | yes | length 0–64 |
| `data.template` | object \| object | yes |  |

**Response 201**

```json
{
  "data": {
    "key": "string",
    "appId": "string",
    "tags": {},
    "purpose": "string",
    "kind": "liquid",
    "latestVersion": {
      "id": "string",
      "status": "compiling",
      "appId": "string",
      "error": "string",
      "protocol": 42,
      "source": "push",
      "pushedAt": "2026-01-15T09:30:00.000Z",
      "bundle": "string"
    },
    "automations": [
      "string"
    ],
    "template": {
      "kind": "liquid",
      "subject": "string",
      "purpose": "string",
      "tags": [],
      "preview": "string",
      "html": "string"
    }
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.key` | string | yes | length 0–64 |
| `data.appId` | string | yes |  |
| `data.tags` | any | yes |  |
| `data.purpose` | string \| null | yes |  |
| `data.kind` | "liquid" \| "doc" \| "module" | yes |  |
| `data.latestVersion` | object | yes |  |
| `data.latestVersion.id` | string | yes |  |
| `data.latestVersion.status` | "compiling" \| "ready" \| "failed" | yes |  |
| `data.latestVersion.appId` | string | yes |  |
| `data.latestVersion.error` | string \| null | yes |  |
| `data.latestVersion.protocol` | integer | yes | -2147483648 ≤ x ≤ 2147483647 |
| `data.latestVersion.source` | "push" \| "api" | yes |  |
| `data.latestVersion.pushedAt` | string (date-time) | yes |  |
| `data.latestVersion.bundle` | string | yes |  |
| `data.automations` | string[] | yes |  |
| `data.template` | object \| object \| null | 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).

## PATCH /v1/templates/{key}

Save a template's next version

Admin only. The template, Liquid HTML or a doc, becomes the key's newest version; running automations send it from their next email on. A later push that carries a different file of the same key is a conflict it has to confirm or force.

**Auth:** Session token.

**Query parameters**

```json
{
  "appId": "string"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `appId` | string | yes |  |

**Request body**

```json
{
  "data": {
    "template": {
      "kind": "liquid",
      "subject": "string",
      "purpose": "string",
      "tags": [],
      "preview": "string",
      "html": "string"
    }
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.template` | object \| object | yes |  |

**Response 200**

```json
{
  "data": {
    "key": "string",
    "appId": "string",
    "tags": {},
    "purpose": "string",
    "kind": "liquid",
    "latestVersion": {
      "id": "string",
      "status": "compiling",
      "appId": "string",
      "error": "string",
      "protocol": 42,
      "source": "push",
      "pushedAt": "2026-01-15T09:30:00.000Z",
      "bundle": "string"
    },
    "automations": [
      "string"
    ],
    "template": {
      "kind": "liquid",
      "subject": "string",
      "purpose": "string",
      "tags": [],
      "preview": "string",
      "html": "string"
    }
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.key` | string | yes | length 0–64 |
| `data.appId` | string | yes |  |
| `data.tags` | any | yes |  |
| `data.purpose` | string \| null | yes |  |
| `data.kind` | "liquid" \| "doc" \| "module" | yes |  |
| `data.latestVersion` | object | yes |  |
| `data.latestVersion.id` | string | yes |  |
| `data.latestVersion.status` | "compiling" \| "ready" \| "failed" | yes |  |
| `data.latestVersion.appId` | string | yes |  |
| `data.latestVersion.error` | string \| null | yes |  |
| `data.latestVersion.protocol` | integer | yes | -2147483648 ≤ x ≤ 2147483647 |
| `data.latestVersion.source` | "push" \| "api" | yes |  |
| `data.latestVersion.pushedAt` | string (date-time) | yes |  |
| `data.latestVersion.bundle` | string | yes |  |
| `data.automations` | string[] | yes |  |
| `data.template` | object \| object \| null | 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/templates/{key}

Inspect one email template

**Auth:** Session token.

**Query parameters**

```json
{
  "appId": "string"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `appId` | string | yes |  |

**Response 200**

```json
{
  "data": {
    "key": "string",
    "appId": "string",
    "tags": {},
    "purpose": "string",
    "kind": "liquid",
    "latestVersion": {
      "id": "string",
      "status": "compiling",
      "appId": "string",
      "error": "string",
      "protocol": 42,
      "source": "push",
      "pushedAt": "2026-01-15T09:30:00.000Z",
      "bundle": "string"
    },
    "automations": [
      "string"
    ],
    "template": {
      "kind": "liquid",
      "subject": "string",
      "purpose": "string",
      "tags": [],
      "preview": "string",
      "html": "string"
    }
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.key` | string | yes | length 0–64 |
| `data.appId` | string | yes |  |
| `data.tags` | any | yes |  |
| `data.purpose` | string \| null | yes |  |
| `data.kind` | "liquid" \| "doc" \| "module" | yes |  |
| `data.latestVersion` | object | yes |  |
| `data.latestVersion.id` | string | yes |  |
| `data.latestVersion.status` | "compiling" \| "ready" \| "failed" | yes |  |
| `data.latestVersion.appId` | string | yes |  |
| `data.latestVersion.error` | string \| null | yes |  |
| `data.latestVersion.protocol` | integer | yes | -2147483648 ≤ x ≤ 2147483647 |
| `data.latestVersion.source` | "push" \| "api" | yes |  |
| `data.latestVersion.pushedAt` | string (date-time) | yes |  |
| `data.latestVersion.bundle` | string | yes |  |
| `data.automations` | string[] | yes |  |
| `data.template` | object \| object \| null | 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/templates/{key}/preview

Render a template with the props you pass

**Auth:** Session token.

**Query parameters**

```json
{
  "appId": "string"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `appId` | string | yes |  |

**Request body**

```json
{
  "data": {
    "props": {
      "key": {}
    }
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.props` | record\<string, any> | no |  |

**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/templates/{key}/test-send

Send a rendered template to your own address

Sends through your organization's default sender. The test goes to you regardless of your consent settings, costs nothing, and shows as a test. Its subject starts with `[test] `.

**Auth:** Session token.

**Query parameters**

```json
{
  "appId": "string"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `appId` | string | yes |  |

**Request body**

```json
{
  "data": {
    "props": {}
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.props` | record\<string, any> | no | default `{}` |

**Response 201**

```json
{
  "data": {
    "id": "string",
    "orgId": "string",
    "profileId": "string",
    "automation": "string",
    "executionId": "string",
    "appId": "string",
    "broadcastId": "string",
    "kind": "automation",
    "step": "string",
    "channel": "email",
    "status": "sent",
    "dryRun": true,
    "recipient": "string",
    "providerId": "string",
    "error": "string",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "updatedAt": "2026-01-15T09:30:00.000Z",
    "sentAt": "2026-01-15T09:30:00.000Z",
    "settledAt": "2026-01-15T09:30:00.000Z",
    "openedAt": "2026-01-15T09:30:00.000Z",
    "openCount": 42,
    "spamScore": 42,
    "spamSymbols": [
      "string"
    ]
  },
  "meta": {
    "requestId": "string",
    "nextCursor": "string",
    "hasMore": true,
    "warnings": [
      {
        "code": "string",
        "message": "string",
        "suggestion": "string"
      }
    ]
  }
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.id` | string | yes |  |
| `data.orgId` | string | yes |  |
| `data.profileId` | string \| null | yes |  |
| `data.automation` | string \| null | yes |  |
| `data.executionId` | string \| null | yes |  |
| `data.appId` | string \| null | yes |  |
| `data.broadcastId` | string \| null | yes |  |
| `data.kind` | "automation" \| "test" \| "api" \| "broadcast" | yes |  |
| `data.step` | string | yes |  |
| `data.channel` | "email" \| "webhook" | 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.dryRun` | boolean | yes |  |
| `data.recipient` | string \| null | yes |  |
| `data.providerId` | string \| null | yes |  |
| `data.error` | string \| null | yes |  |
| `data.createdAt` | string (date-time) | yes |  |
| `data.updatedAt` | string (date-time) | yes |  |
| `data.sentAt` | string (date-time) \| null | yes |  |
| `data.settledAt` | string (date-time) \| null | yes |  |
| `data.openedAt` | string (date-time) \| null | yes |  |
| `data.openCount` | integer | yes | -2147483648 ≤ x ≤ 2147483647 |
| `data.spamScore` | number \| null | yes |  |
| `data.spamSymbols` | string[] \| null | 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).

## DELETE /v1/templates/{key}

Delete an email template

Admin only. Deleting a key deletes every version of it in its app, and is refused while an automation still sends it, naming the automations.

**Auth:** Session token.

**Query parameters**

```json
{
  "appId": "string"
}
```

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `appId` | string | yes |  |

**Response 200**

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

| Field | Type | Required | Constraints |
| --- | --- | --- | --- |
| `data` | object | yes |  |
| `data.deleted` | 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).
