# Python SDK

The `rasket` package: a typed client for Python 3.10+, built on httpx. Its request and response types are generated from the same document the API is validated against, and its methods from the Node client's own route map, so the two cannot drift apart.

> Coming soon: the rasket package is not published yet. It is not on PyPI, so there is nothing to install today. Until it is, call the REST API with `requests` or any HTTP client, as below, or connect an agent over [MCP](https://www.rasket.com/docs/mcp). The rest of this page describes the client as it will ship, so you know what to expect.

## Today: the REST API

Every endpoint in the [API reference](https://www.rasket.com/docs/api-reference/emails) has a Python tab that uses `requests`. This is the first send:

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"]
```

## The first send

Send an email, with the upcoming client:

```text
import os

from rasket import Rasket

rasket = Rasket(
    api_key=os.environ["RASKET_API_KEY"],
    user_agent="acme-billing/1.0",
)

result = rasket.emails.send(
    {
        "from": "Acme <billing@acme.example>",
        "to": ["ronald.williams@example.com"],
        "subject": "Your receipt",
        "html": "<p>Thanks.</p>",
    },
    idempotency_key="receipt-1042",
)

print(result.body["id"])
```

The client sends a `User-Agent` of its own on every request, because a request without one is refused; whatever you pass as `user_agent` is appended to it, never substituted. Keys start with `rk_`, and an OAuth access token works wherever a key does. On a deployment host, pass `base_url="https://<deployment-host>/api/v1"`.

## What every call returns

The result, with the upcoming client:

```text
result = rasket.emails.get(email_id)

result.body                 # the parsed body, typed per route
result.rate_limit           # RateLimit(limit=10, remaining=..., reset_seconds=...)
result.request_id           # the x-request-id to quote
result.idempotent_replayed  # True on a replay of an earlier keyed request
```

One shape for every method. For a list route, `body` is the `{ object, has_more, data }` envelope described under [pagination](https://www.rasket.com/docs/pagination).

## The same methods as the Node SDK

Every method of the [Node SDK](https://www.rasket.com/docs/sdk/node) exists here under the same path, each segment in snake_case. Path parameters come first, then `body` or `query` as a dict; `idempotency_key` is a keyword.

| Node | Python |
| --- | --- |
| `emails.send` | `rasket.emails.send(body, idempotency_key=...)` |
| `apiKeys.create` | `rasket.api_keys.create(body)` |
| `webhooks.rotateSecret` | `rasket.webhooks.rotate_secret(webhook_id)` |
| `templates.get` | `rasket.templates.get(template_id)` |
| `emails.receiving.attachments.list` | `rasket.emails.receiving.attachments.list(email_id, query)` |

## Received mail

Read a received message, with the upcoming client:

```text
page = rasket.emails.receiving.list({"limit": 20}).body
message = rasket.emails.receiving.get(page["data"][0]["id"]).body

files = rasket.emails.receiving.attachments.list(message["id"]).body
for file in files["data"]:
    # A part we would not store is listed without a link; check before you follow it.
    if "download_url" not in file:
        continue
    download(file["download_url"])

rasket.emails.receiving.remove(message["id"])
```

`emails.receiving` is its own resource, not a filter over the mail you sent: a received message is never an `emails` row, and neither read accepts the other's IDs. The two download routes are deliberately not on this client — they answer bytes rather than JSON, so follow `download_url` with any HTTP client.

## Idempotency

Pass `idempotency_key` to `emails.send` or `batch.send` — the two routes that take one. The client sends it verbatim and never generates a key of its own: a key it invented would make a retry look idempotent when you never asked for that.

- 1–256 characters. A key outside that range is refused before any request is sent, as the same `400 invalid_idempotency_key` the API would answer, with `error.source == "client"`.
- Scoped to your team, remembered for 24 hours.
- A replay of a finished request returns the original response with `idempotent_replayed` set to `True`; a different payload under the same key is `409 invalid_idempotent_request`.

## Errors

Handle an error by name, with the upcoming client:

```text
from rasket import RasketApiError, RasketConnectionError

try:
    rasket.emails.send(message)
except RasketApiError as error:
    if error.name == "validation_error":
        reject(error.errors)  # (ValidationIssue(path=..., message=...), ...)
    elif error.name == "rate_limit_exceeded" and error.rate_limit:
        later(error.rate_limit.retry_after_seconds)
    else:
        raise  # error.status_code, error.message, error.request_id
except RasketConnectionError as error:
    ...  # error.reason: "network" or "timeout" -- no answer arrived
```

`error.name` is the API's stable vocabulary, not a class name — the same names the [errors](https://www.rasket.com/docs/errors) page lists. The one value outside it is `unknown_error`: the response carried no error body of ours, and `status_code` is whatever came back.

## Retries

| Request | Retried on a 429 or 5xx? |
| --- | --- |
| `GET` | Always |
| `emails.send` and `batch.send` with an `idempotency_key` | Yes: the key is what makes a repeat a replay |
| `emails.send` and `batch.send` without one | Never: at-most-once matters more than a saved round trip |
| Every other write | Never |

`retry-after` is honoured when present, and a value above `max_delay` is not waited for — the error is raised at once with the header on it. Otherwise the wait is exponential with full jitter. A quota 429 is never retried, and neither is a `501`; a `409` `concurrent_idempotent_requests` on a keyed send is, because it means your earlier attempt is landing.

The retry knobs, with the upcoming client:

```text
from rasket import Rasket, RetryOptions

rasket = Rasket(
    api_key=api_key,
    timeout=30.0,
    retry=RetryOptions(attempts=3, min_delay=0.5, max_delay=8.0),
)
```

`attempts=1` turns retrying off. `timeout` and both delays are in seconds, and the timeout applies per attempt.

## Verifying a webhook

`rasket.webhooks.verify` checks a delivery's signature and returns the parsed event, or raises `WebhookVerificationError` with a `reason`. The same function is reachable from a client as `client.webhooks.verify`.

Verify in a Flask handler, with the upcoming client:

```text
import os

from flask import Flask, request
from rasket import WebhookVerificationError, webhooks

app = Flask(__name__)


@app.post("/webhooks/rasket")
def receive():
    raw_body = request.get_data()  # the bytes, never request.json
    try:
        event = webhooks.verify(raw_body, request.headers, os.environ["RASKET_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        return "invalid signature", 400
    handle(event)
    return "", 200
```

> The signature covers the raw request body. A framework that parses JSON before your handler has already destroyed what was signed — read the bytes first: `request.get_data()` in Flask, `await request.body()` in FastAPI.

## Worth knowing

- The client is synchronous. Close its connection pool with `rasket.close()`, or use it as a context manager; pass `http_client=` to bring your own `httpx.Client`.
- Timestamps are ISO 8601 UTC with milliseconds, everywhere.
- `scheduled_at` takes an ISO 8601 instant or a phrase such as `"in 2 hours"`, read as UTC. Anything the grammar does not cover is a `422`.
- `webhooks.get` returns the signing secret masked. It is in the `create` and `rotate_secret` responses and nowhere else.
