# Emails

Send one message or a hundred, look up what happened to it, reschedule it while it is still waiting, and read what was attached.

## Before you send

- The domain in `from` must be verified on this team. An unverified domain is `403 validation_error`, not a silent drop.
- `to`, `cc` and `bcc` together may not exceed 50 addresses.
- Send `html`, `text`, or both. A message with neither is refused.

## Two deviations worth knowing

- `scheduled_at` takes an ISO 8601 instant or a phrase — `"in 2 hours"`, `"tomorrow at 9am"`, `"next tuesday 9am"` — between 1 minute and 30 days from now. Phrases are read as UTC, and one that names a day but no time means 09:00. Our grammar is narrower than the reference API's: anything outside it is refused with both accepted forms rather than guessed at.
- Custom `headers` cannot override `From`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID` or `Return-Path`. Those belong to the envelope we sign; letting a request set them would break DKIM or forge our own identifiers.

## Reliability

A `200` from `POST /emails` means we have durably recorded your message and taken responsibility for sending it. It does not mean the message has reached the mail provider yet, and it certainly does not mean it has been delivered.

Between our call to the mail provider and its answer there is a window where the function can be torn down or the response lost. When that happens we do not know whether the provider holds the message, and we do not send it again: a duplicate email is worse than a visible gap. Instead we look for our own `Message-ID` in the delivery events that follow. Most such sends resolve within seconds. One that has found no matching event after two hours is marked failed with the reason `ambiguous_submission`, and both the dashboard and your webhook say so plainly.

So the honest contract is: we will not send your message twice on our own, and if we cannot confirm we sent it at all, we will tell you rather than guess. We do not claim exactly-once delivery, because nobody can.

## Finding one again

`GET /emails` takes six optional filters beside the page parameters — `status`, `api_key_id`, `start_date`, `end_date`, `search` and `tags` — and they are the same ones the dashboard's Sending list uses, so a view you filtered there is a question this endpoint answers identically. `search` matches the subject, the sender address and the recipient addresses; it does not read the message body.

Counts and rates for the whole account are a different endpoint, on the [metrics reference](https://www.rasket.com/docs/api-reference/metrics).

## Mail sent to you is elsewhere

Everything on this page is mail you *sent*. Inbound mail is its own resource with its own IDs and its own reads — [Receiving](https://www.rasket.com/docs/api-reference/receiving) — and a received message is never an `emails` row.

> The `received` figure on `GET /emails/metrics` is the count of messages the mail provider accepted from you for **sending**. It is not a count of inbound mail, and it does not move when someone emails you.

## Sharing one

`POST /emails/{email_id}/share` returns a link that opens a read-only view of one sent message, with no sign-in. It is what you paste into a support thread instead of a screenshot.

- **Treat the URL as a secret.** Anyone holding it can read the message, and it is the only credential the page asks for.
- It is returned once. Only a hash of it is stored, so it cannot be retrieved again — create another if you lose it.
- `expires_in` is a duration such as `10m` or `1 day`, defaulting to and capped at 48 hours. An unknown, expired or revoked link all answer the same 404, which says nothing about which of the three it was.

## Endpoints

### `POST /emails`

Queue one message for delivery and get its ID back.

#### Headers

| Field | Type | Description |
| --- | --- | --- |
| `Idempotency-Key` | string | 1–256 characters, unique to this send. Replaying it inside 24 hours returns the original response instead of sending again. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `from` | string | Required unless `template` supplies it. The sender, as an address or `Name <address>`; its domain must be one this team has verified. |
| `to` (required) | string \| string[] | One recipient or a list. `to`, `cc` and `bcc` together may not exceed 50 addresses. |
| `subject` | string | Required unless `template` supplies it. Up to 998 bytes of UTF-8, on one line. |
| `html` | string | The HTML body. Send `html`, `text` or both; at least one is required. |
| `text` | string | The plain-text body. |

#### Body, less common

| Field | Type | Description |
| --- | --- | --- |
| `cc` | string \| string[] | Visible copies. Counts toward the recipient cap. |
| `bcc` | string \| string[] | Blind copies. Counts toward the recipient cap. |
| `reply_to` | string \| string[] | Where replies go. Not counted toward the recipient cap. |
| `scheduled_at` | string | An ISO 8601 instant, or a phrase such as "in 2 hours", "tomorrow at 9am" or "next tuesday 9am" — phrases are read as UTC, and a day with no time means 09:00. Between 1 minute and 30 days from now. |
| `headers` | object | Custom message headers, name to value. Headers we own — `From`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID`, `Return-Path`, the MIME and DKIM headers, trace and authentication headers such as `Received`, `Sender`, `Authentication-Results`, `ARC-*` and `Resent-*`, delivery-control headers, and any `List-Unsubscribe*` — are refused. Values may not contain control characters other than a tab. |
| `attachments` | object[] | Up to 100 items, each with a `filename` and exactly one of `content` (base64) or `path` (an https URL we fetch). Optional `content_type` (a MIME type such as `image/png`) and `content_id` (printable ASCII, no spaces or angle brackets). |
| `tags` | object[] | Up to 50 `{ name, value }` pairs of `A-Z`, `a-z`, `0-9`, `_` and `-`. A name is 1–252 characters and a value 1–256; a name may appear only once. Tags come back on the email and on every event for it, as an object. |
| `template` | object | `{ id, variables }`. `id` is a template ID or alias; the template must be published, and its published version supplies `html`, `text` and, where the request omits them, `subject`, `from` and `reply_to`. A send where neither sets `from` or `subject` is `422 missing_required_field`. Cannot be combined with `html` or `text`. `variables` maps each declared key to a string or number of at most 2000 characters; a missing key takes its fallback. |
| `topic_id` | string | Reserved for subscription topics in a later phase. |

Send an email:

```sh
curl -X POST "https://api.rasket.com/emails" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{
  "from": "Acme <orders@send.acme.example>",
  "to": ["ronald.williams@example.com"],
  "subject": "Your order has shipped",
  "html": "<p>Order 1042 left the warehouse this morning.</p>"
}'
```

```ts
const response = await fetch("https://api.rasket.com/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
    "Idempotency-Key": "order-1042",
  },
  body: JSON.stringify({
    from: "Acme <orders@send.acme.example>",
    to: ["ronald.williams@example.com"],
    subject: "Your order has shipped",
    html: "<p>Order 1042 left the warehouse this morning.</p>"
  }),
});

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

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/emails",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
        "Idempotency-Key": "order-1042",
    },
    json={
    "from": "Acme <orders@send.acme.example>",
    "to": ["ronald.williams@example.com"],
    "subject": "Your order has shipped",
    "html": "<p>Order 1042 left the warehouse this morning.</p>"
  },
)

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

#### Response `200`

```json
{
  "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c"
}
```

- A `200` means we have accepted and durably recorded the message, not that it has been delivered. Delivery is reported by events.
- Bodies are capped at 2 MB of `html` and `text` combined.
- Without an `Idempotency-Key`, a retried request sends a second email.

### `POST /emails/batch`

Up to 100 messages in one request, validated all or nothing.

#### Headers

| Field | Type | Description |
| --- | --- | --- |
| `Idempotency-Key` | string | 1–256 characters, unique to this send. Replaying it inside 24 hours returns the original response instead of sending again. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `[]` (required) | object[] | The body is a JSON array of 100 or fewer send objects, each shaped exactly like `POST /emails`. |

Send a batch:

```sh
curl -X POST "https://api.rasket.com/emails/batch" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nightly-digest-2026-09-09" \
  -d '[
  {
    "from": "Acme <orders@send.acme.example>",
    "to": ["ronald.williams@example.com"],
    "subject": "Your order has shipped",
    "html": "<p>Order 1042 left the warehouse this morning.</p>"
  },
  {
    "from": "Acme <orders@send.acme.example>",
    "to": ["ada@example.com"],
    "subject": "Your order has shipped",
    "html": "<p>Order 1043 left the warehouse this morning.</p>"
  }
]'
```

```ts
const response = await fetch("https://api.rasket.com/emails/batch", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
    "Idempotency-Key": "nightly-digest-2026-09-09",
  },
  body: JSON.stringify([
    {
      from: "Acme <orders@send.acme.example>",
      to: ["ronald.williams@example.com"],
      subject: "Your order has shipped",
      html: "<p>Order 1042 left the warehouse this morning.</p>"
    },
    {
      from: "Acme <orders@send.acme.example>",
      to: ["ada@example.com"],
      subject: "Your order has shipped",
      html: "<p>Order 1043 left the warehouse this morning.</p>"
    }
  ]),
});

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

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/emails/batch",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
        "Idempotency-Key": "nightly-digest-2026-09-09",
    },
    json=[
    {
      "from": "Acme <orders@send.acme.example>",
      "to": ["ronald.williams@example.com"],
      "subject": "Your order has shipped",
      "html": "<p>Order 1042 left the warehouse this morning.</p>"
    },
    {
      "from": "Acme <orders@send.acme.example>",
      "to": ["ada@example.com"],
      "subject": "Your order has shipped",
      "html": "<p>Order 1043 left the warehouse this morning.</p>"
    }
  ],
)

print(response.json())
```

#### Response `200`

```json
{
  "data": [
    {
      "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c"
    },
    {
      "id": "9c1d3f28-6b04-4f77-a5e2-1c8d05b3e9a7"
    }
  ]
}
```

- One invalid element refuses the whole batch and nothing is sent; the error names the element, as in `emails[3].to`.
- One `Idempotency-Key` covers the whole batch, not each message in it.
- IDs come back in the order they were sent.

### `GET /emails`

Newest first, cursor paginated.

#### 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`. |
| `status` | string | The last event the email reached: `queued`, `scheduled`, `sent`, `delivery_delayed`, `delivered`, `opened`, `clicked`, `bounced`, `complained`, `failed`, `suppressed` or `canceled`. |
| `api_key_id` | string | Only emails sent with this API key. A revoked key still filters the emails it sent. |
| `start_date` | string | ISO 8601, inclusive. Emails created before this instant are excluded. |
| `end_date` | string | ISO 8601, inclusive. Emails created after this instant are excluded. |
| `search` | string | Case-insensitive substring of the subject, the sender address or any recipient address. 1–200 characters. |
| `tags` | string | Comma-separated, each written `name:value`. Up to 10 of them, and an email has to carry every one. Repeating the parameter does the same. |

List emails:

```sh
curl -X GET "https://api.rasket.com/emails?limit=20&search=invoice" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails?limit=20&search=invoice", {
  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/emails?limit=20&search=invoice",
    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": true,
  "data": [
    {
      "object": "email",
      "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
      "from": "Acme <orders@send.acme.example>",
      "to": ["ronald.williams@example.com"],
      "cc": [],
      "bcc": [],
      "reply_to": [],
      "subject": "Your order has shipped",
      "html": "<p>Order 1042 left the warehouse this morning.</p>",
      "text": null,
      "message_id": "<01000199a3c4d5e6-7f8a9b0c@send.acme.example>",
      "created_at": "2026-09-09T10:14:02.118Z",
      "last_event": "delivered",
      "scheduled_at": null,
      "tags": {
        "invoice": "1042"
      }
    }
  ]
}
```

- Every filter is optional and additive. Sending none returns the same page it always did.
- Filters are applied to the query, not to the page, so `has_more` describes the filtered list and paging through it never skips a row.
- `search` looks at the subject, the sender address and the recipient addresses — not the message body. `%` and `_` match literally.
- `tags` is written `name:value` and repeats. An email has to carry every tag you name, and a send that carried none is never a match.

### `GET /emails/{email_id}`

The stored message and the last event it reached.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The email's ID. |

Retrieve an email:

```sh
curl -X GET "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c", {
  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/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

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

#### Response `200`

```json
{
  "object": "email",
  "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
  "from": "Acme <orders@send.acme.example>",
  "to": ["ronald.williams@example.com"],
  "cc": [],
  "bcc": [],
  "reply_to": [],
  "subject": "Your order has shipped",
  "html": "<p>Order 1042 left the warehouse this morning.</p>",
  "text": null,
  "message_id": "<01000199a3c4d5e6-7f8a9b0c@send.acme.example>",
  "created_at": "2026-09-09T10:14:02.118Z",
  "last_event": "delivered",
  "scheduled_at": null,
  "tags": {
    "invoice": "1042"
  }
}
```

- `last_event` is the furthest state this email has reached, not a history. The full timeline is on the dashboard and on your webhook.

### `PATCH /emails/{email_id}`

Move a scheduled send to a new time.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The email's ID. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `scheduled_at` (required) | string | An ISO 8601 instant, or a phrase such as "in 2 hours", "tomorrow at 9am" or "next tuesday 9am" — phrases are read as UTC, and a day with no time means 09:00. Between 1 minute and 30 days from now. |

Reschedule an email:

```sh
curl -X PATCH "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "scheduled_at": "2026-09-10T12:00:00.000Z"
}'
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    scheduled_at: "2026-09-10T12:00:00.000Z"
  }),
});

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

```python
import os

import requests

response = requests.patch(
    "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "scheduled_at": "2026-09-10T12:00:00.000Z"
  },
)

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

#### Response `200`

```json
{
  "object": "email",
  "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c"
}
```

- `scheduled_at` is the only field this endpoint changes.
- It works only while `last_event` is `scheduled`. Once the message has been dispatched the answer is `409 resource_locked`.

### `POST /emails/{email_id}/cancel`

Stop a send that has not gone out yet.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The email's ID. |

Cancel a scheduled email:

```sh
curl -X POST "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/cancel" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/cancel", {
  method: "POST",
  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.post(
    "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/cancel",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

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

#### Response `200`

```json
{
  "object": "email",
  "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c"
}
```

- Cancelling an email that has already been dispatched answers `409 resource_locked`; there is no recall.

### `GET /emails/{email_id}/attachments`

What was attached to a sent email, with a signed link for each.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The email's ID. |

#### 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 attachments:

```sh
curl -X GET "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments", {
  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/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments",
    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": "5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73",
      "filename": "invoice-1042.pdf",
      "content_type": "application/pdf",
      "content_disposition": "attachment",
      "size": 48213,
      "download_url": "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments/5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73/download?expires=1789200000&token=1f0c…",
      "expires_at": "2026-09-09T10:29:02.118Z"
    }
  ]
}
```

- Cursors on this list are attachment IDs.

### `GET /emails/{email_id}/attachments/{attachment_id}`

One attachment's metadata and a fresh signed link.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The email's ID. |
| `attachment_id` (required) | string | The attachment's ID. |

Retrieve an attachment:

```sh
curl -X GET "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments/5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments/5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73", {
  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/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments/5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

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

#### Response `200`

```json
{
  "object": "attachment",
  "id": "5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73",
  "filename": "invoice-1042.pdf",
  "content_type": "application/pdf",
  "content_disposition": "attachment",
  "size": 48213,
  "download_url": "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments/5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73/download?expires=1789200000&token=1f0c…",
  "expires_at": "2026-09-09T10:29:02.118Z"
}
```

### `GET /emails/{email_id}/attachments/{attachment_id}/download`

Follow the signed link and get the bytes.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The email's ID. |
| `attachment_id` (required) | string | The attachment's ID. |

#### Query parameters

| Field | Type | Description |
| --- | --- | --- |
| `expires` (required) | integer | Part of the signature. Copy the whole `download_url`; do not build this yourself. |
| `token` (required) | string | The link's signature, valid for fifteen minutes and for this attachment only. |

Download an attachment:

```sh
curl -X GET "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments/5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73/download?expires=1789200000&token=1f0c9d3b8a72e5461c0d" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments/5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73/download?expires=1789200000&token=1f0c9d3b8a72e5461c0d", {
  method: "GET",
  headers: {
    "User-Agent": "acme-billing/1.0",
  },
});

console.log(response.status);
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/attachments/5f2c9a1b-7e3d-4c8a-9b1f-2e6d0a4c8b73/download?expires=1789200000&token=1f0c9d3b8a72e5461c0d",
    headers={
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.status_code)
```

- This is the only route in the public API that takes no `Authorization` header: the link carries its own signed authorization so a browser can follow it.
- A `User-Agent` is still required, as it is on every other route.
- The response is the file itself, not JSON.

### `POST /emails/{email_id}/share`

Create a link that opens a read-only view of one sent email, with no sign-in required.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The ID of the email. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `expires_in` | string | How long the link stays valid, as `<number><unit>` with optional whitespace — `10m`, `2 hours`, `1 day`. Units: `s`/`sec`/`secs`/`second`/`seconds`, `m`/`min`/`mins`/`minute`/`minutes`, `h`/`hr`/`hrs`/`hour`/`hours`, `d`/`day`/`days`. Defaults to `48h` and cannot exceed 48 hours. |

Share an email:

```sh
curl -X POST "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/share" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "expires_in": "24h"
}'
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/share", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    expires_in: "24h"
  }),
});

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

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/share",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "expires_in": "24h"
  },
)

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

#### Response `200`

```json
{
  "object": "email",
  "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
  "url": "https://share.example.com/nQ8vK2xW5yB7dF1hJ4mP6rT9uA3cE0gL2iO",
  "expires_at": "2026-09-11T12:00:00.000Z"
}
```

- Treat the URL as a secret: anyone holding it can read the message, and it is the only credential the page asks for.
- The link is returned once and is not stored — only a hash of it is kept, so it cannot be retrieved again. Create another if you lose it.
- `id` is the ID of the email, not of the share, so it is the same value you would pass to `GET /emails/{email_id}`.
- `expires_at` is additive: the reference spec documents only `object`, `id` and `url`.
- The page is served on a separate origin from the dashboard and renders the message in a sandboxed frame with remote images blocked until the reader loads them.
- An unknown, expired, or revoked link answers the same 404 page, with nothing said about which of the three it was.

### `GET /emails/{email_id}/shares`

Every link ever created for one email, live and withdrawn alike, newest first.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The ID of the email. |

#### 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 shared links:

```sh
curl -X GET "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/shares" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/shares", {
  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/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/shares",
    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": "0198f4c1-0000-7000-8000-000000000000",
      "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
      "status": "active",
      "created_at": "2026-09-09T12:00:00.000Z",
      "expires_at": "2026-09-11T12:00:00.000Z",
      "revoked_at": null,
      "created_by_user_id": null,
      "created_by_api_key_id": "a4d2f0c8-5b31-4e7a-9c62-8f0b1d4e6a75"
    }
  ]
}
```

- **There is no `url` here, and no endpoint can give one back.** Only a hash of each token is stored, so a link that has been lost is revoked and replaced, never re-shown.
- `status` is `active` while the link still opens the page, `expired` once `expires_at` has passed, and `revoked` once it was withdrawn. A link that was withdrawn and has also expired reads `revoked`.
- Exactly one of `created_by_user_id` and `created_by_api_key_id` is set, recording which credential created the link. Both are null once that member or key is gone; neither restricts who may revoke it.
- Cursors are share IDs. A cursor naming a share of a different email is `422 invalid_parameter`, not an empty page.
- Revoked links stay in this list, and are deleted only when the email itself is.

### `DELETE /emails/{email_id}/shares/{share_id}`

Stop a link working, without deleting the record that it existed.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The ID of the email. |
| `share_id` (required) | string | The ID of the share, as `GET /emails/{email_id}/shares` reports it. |

Revoke a shared link:

```sh
curl -X DELETE "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/shares/{share_id}" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/shares/{share_id}", {
  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/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/shares/{share_id}",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

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

#### Response `200`

```json
{
  "object": "email_share",
  "id": "0198f4c1-0000-7000-8000-000000000000",
  "revoked_at": "2026-09-10T09:00:00.000Z"
}
```

- The link stops working on the next request. There is no cache to wait for.
- Idempotent: revoking a link that is already revoked answers the instant it *first* stopped working, not the instant of this call.
- The response says `revoked_at` rather than the `deleted: true` other deletes answer with, because the row is kept — a link that was shared and withdrawn is a fact about this email.
- A share belonging to a different email, or to another team, is `404` — the same answer as one that never existed.
- Any `full_access` key or dashboard session may revoke any of this team's links, whichever credential created it: a customer whose key has leaked needs the dashboard to be able to shut the link off.

### `GET /emails/{email_id}/events`

Everything recorded for one email — sent, delivered, bounced, opened — oldest first.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `email_id` (required) | string | The email's ID. |

List an email's events:

```sh
curl -X GET "https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/events" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/events", {
  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/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/events",
    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": [
    {
      "object": "email_event",
      "id": "0199d2f1-4c4e-7a20-9c31-6f2b8a0e5f01",
      "type": "email.delivered",
      "recipient": "ronald.williams@example.com",
      "occurred_at": "2026-09-09T09:20:33.412Z",
      "data": {}
    }
  ]
}
```

- Ordered by when each event happened, not when it arrived. `recipient` is `null` for an event about the whole message.
- `data` is the event-specific object in the shape a webhook carries it. To ask what went wrong, see Diagnose an email on the AI reference page.
