# Node SDK

The `rasket` package: a typed client for Node 20+ and TypeScript, with no dependencies. Its request and response types are generated from the same document the API is validated against, so they cannot drift from it.

> Coming soon: the rasket package is not published yet. It is not on npm, so there is nothing to install today. Until it is, call the REST API with `fetch`, 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 JavaScript tab that uses plain `fetch`, which Node 20 has. 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 { Rasket } from "rasket";

const rasket = new Rasket({
  apiKey: process.env.RASKET_API_KEY,
  userAgent: "acme-billing/1.0",
});

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

console.log(email.id);
```

The client sends a `User-Agent` of its own on every request, because a request without one is refused; whatever you pass as `userAgent` is appended to it, never substituted. Keys start with `rk_`.

## What every call returns

The result, with the upcoming client:

```text
const result = await rasket.emails.get(id);

result.body;               // the parsed body, typed per route
result.rateLimit;          // { limit: 10, remaining, resetSeconds }
result.requestId;          // the x-request-id to quote
result.idempotentReplayed; // 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).

## Received mail

Read a received message, with the upcoming client:

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

const { body: files } = await rasket.emails.receiving.attachments.list(message.id);
for (const file of files.data) {
  // A part we would not store is listed without a link; check before you follow it.
  if (file.download_url === undefined) continue;
  const bytes = await fetch(file.download_url);
}

await 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 `fetch`, as above.

## Connected apps

List and revoke OAuth grants, with the upcoming client:

```text
const { body: grants } = await rasket.oauth.grants.list({ limit: 20 });

for (const grant of grants.data) {
  if (grant.revoked_at === null && grant.client.name === "Old CRM sync") {
    await rasket.oauth.grants.revoke(grant.id);
  }
}
```

`oauth.grants` lists the apps this team has connected through [OAuth](https://www.rasket.com/docs/oauth) — revoked ones included — and revokes one. It needs a `full_access` key. The flow routes themselves (register, authorize, token, revoke) are not on this client: they are an OAuth library's job, not an API call your server makes.

An OAuth access token works anywhere `apiKey` does — pass it as `apiKey` — within the scopes it was granted. No scope reaches `oauth.grants`, `team.members.list` or a billing write, so those need a `full_access` key.

## Team, billing and AI

The team, a checkout and AI assist, with the upcoming client:

```text
const { body: team } = await rasket.team.get();
await rasket.team.update({ ai_assist_enabled: true });

const { body: checkout } = await rasket.billing.checkout({
  plan_code: "pro",
  return_url: "https://app.example.com/settings/billing",
});
// Send the customer's browser to checkout.url; the card is entered on that hosted page.

const { body: ideas } = await rasket.ai.subjectLines({ subject: "Our November update" });
```

`team` is singular: a key belongs to one team, so there is no ID to pass. `billing.checkout` and `billing.portal` answer a hosted page's URL, never card data. The six billing writes and `team.members.list` need a `full_access` key; see the [billing reference](https://www.rasket.com/docs/api-reference/billing) for who may call what, and the [AI reference](https://www.rasket.com/docs/api-reference/ai) for the order an AI call is refused in. `emails.diagnose` is the third AI helper.

## Everything the dashboard does

Records, a timeline, a preview, an import and a replay, with the upcoming client:

```text
const { body: records } = await rasket.domains.records(domainId);
const { body: timeline } = await rasket.emails.events.list(emailId);
const { body: preview } = await rasket.templates.preview("welcome", { variables: { name: "Ronald" } });

const { body: upload } = await rasket.contacts.imports.create({
  file: new Blob([await readFile("contacts.csv")]),
  options: { column_map: { email: "Email" }, on_conflict: "upsert" },
});

const { replayed, stopped } = await rasket.webhooks.events.replayMany(webhookId, eventIds);
```

Every function of the dashboard has a method: `domains.records`, `domains.claims`, `domains.regenerateDkim` and `domains.autoconfigure`; `emails.events.list`; `templates.preview` and `segments.preview`; `broadcasts.checklist`; `automations.versions.list`; and `webhooks.parked.list` and `.deliver`. `contacts.imports.create` is the one multipart call: pass the CSV as a `Blob`.

`webhooks.events.replayMany` is not an API call. It replays one event at a time and stops at the first refusal, returning it as `stopped` instead of throwing, so the events already replayed are not lost.

## Idempotency

Pass `idempotencyKey` 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 `idempotentReplayed: true`; a different payload under the same key is `409 invalid_idempotent_request`.

## Errors

Handle an error by name, with the upcoming client:

```text
import { isRasketApiError, isRasketConnectionError } from "rasket";

try {
  await rasket.emails.send(message);
} catch (error) {
  if (isRasketApiError(error)) {
    switch (error.name) {
      case "validation_error":
        return reject(error.errors); // [{ path, message }]
      case "rate_limit_exceeded":
        return later(error.rateLimit?.retryAfterSeconds);
      default:
        throw error; // error.statusCode, error.message, error.requestId
    }
  }
  if (isRasketConnectionError(error)) {
    // error.reason: "network" | "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 `statusCode` is whatever came back.

> Branch on `error.kind` or the predicates, not `instanceof`. The package ships both module formats, and a dependency graph that loads both holds two copies of every class.

## Retries

| Request | Retried on a 429 or 5xx? |
| --- | --- |
| `GET` | Always |
| `POST /emails` and `POST /emails/batch` with an `Idempotency-Key` | Yes: the key is what makes a repeat a replay |
| `POST /emails` and `POST /emails/batch` 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 `maxDelayMs` is not waited for — the error is thrown 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
const rasket = new Rasket({
  apiKey,
  timeoutMs: 30_000,
  retry: { attempts: 3, minDelayMs: 500, maxDelayMs: 8_000 },
});
```

`attempts: 1` turns retrying off. `timeoutMs` is per attempt, and every method takes `{ signal }` as its last argument to cancel the call.

## Verifying a webhook

`rasket.webhooks.verify` is the published verifier, bundled, so there is exactly one. It returns the parsed event or throws `WebhookVerificationError` with a `reason`.

Verify in a route handler, with the upcoming client:

```text
export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text(); // text(), never json()
  try {
    const event = rasket.webhooks.verify(rawBody, request.headers, process.env.RASKET_WEBHOOK_SECRET);
    await handle(event);
    return new Response(null, { status: 200 });
  } catch {
    return new Response("invalid signature", { status: 400 });
  }
}
```

> The signature covers the raw request body. A framework that parses JSON before your handler has already destroyed what was signed — read the body as text first.

## Worth knowing

- 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 `rotateSecret` responses and nowhere else.
