# @isnap/sdk

Official TypeScript SDK for the [iSnap](https://isnap.ai) P2P telephony API — real iMessage / SMS / RCS lines from real handsets, exposed as a single typed client.

> Pre-1.0. Targets contract `docs/api-contract.md` v1.0.0. Public surface is stable across the v0.x line; breaking changes will bump 0.x → 0.(x+1) until 1.0.0.

## Install

```bash
bun add @isnap/sdk
# or
npm install @isnap/sdk
```

Requires Node 20+ (or Bun 1.2+). The SDK uses native `fetch`, `node:crypto`, and ES2022 `Error.cause` — no polyfills.

## Quick start

```ts
import { ISnapClient } from '@isnap/sdk'

const client = new ISnapClient({ apiKey: process.env.ISNAP_API_KEY! })

const message = await client.messages.send({
  line_id: 'line_01HX...',
  to: '+15551234567',
  body: 'Hello from iSnap'
})

console.log(message.id, message.status)
```

## Configuration

```ts
new ISnapClient({
  apiKey: 'sk_live_...',          // required
  baseUrl: 'https://api.isnap.ai', // default
  maxRetries: 3,                  // default 3 — retries on 429/5xx with jittered backoff
  maxRetryDelayMs: 60_000,        // ceiling on a honored Retry-After; a longer 429 is rethrown instead of slept
  timeoutMs: 30_000,              // per-attempt timeout
  idempotencyKey: 'auto',         // 'auto' generates UUIDs for POST/PATCH/PUT/DELETE; 'manual' = pass via opts
  fetch: undefined,               // override (e.g. an instrumented fetch)
  onRetry: (info) => console.warn('retry', info)
})
```

Every resource method accepts an optional `RequestOptions` final argument: `{ signal, idempotencyKey, headers, query, maxRetries }`.

## Resources

| Resource | What it does |
|---|---|
| `client.messages` | Send / get / cancel / list messages, add reactions |
| `client.chats` | List / get conversations (incl. shared-line `outbound_number`), mark read, send typing indicators, share a contact card |
| `client.lines` | List, configure, and inspect rented + BYOD lines, plus quota and queue snapshots; mint wholesale shared lines and flip a line to/from test |
| `client.lookup` | Test if a number is iMessage-capable before sending |
| `client.attachments` | Upload images / video / files (transparent 3-step flow) |
| `client.webhooks` | CRUD subscriptions, rotate secrets, list deliveries, replay events |
| `client.byod` | Pair a customer-owned Mac+iPhone or Android device |
| `client.trial` | Start, check, and convert trial sessions |
| `client.version` / `client.health` | Read the backend API version and system health — global probes on the client itself, distinct from the per-line `client.lines.health(id)` |

## Sending a message

```ts
const msg = await client.messages.send({
  line_id: 'line_01HX...',
  to: '+15551234567',
  body: 'Order #1242 is on its way',
  service: 'iMessage' // optional — falls back to SMS/RCS via the line's degradation chain
})
```

`messages.send` is **idempotent by default** — the SDK auto-generates an `Idempotency-Key` for every POST. Retrying the same call with the same key returns the original message without sending a duplicate.

## Listing with cursor pagination

`list()` returns an `AsyncIterable` that threads cursors automatically:

```ts
for await (const msg of client.messages.list({ line_id: 'line_01HX...', limit: 100 })) {
  console.log(msg.id, msg.created_at)
}
```

To stop early, pass an `AbortSignal`. Aborting surfaces as a thrown `APIConnectionError` on the next page boundary, so wrap the loop:

```ts
import { APIConnectionError } from '@isnap/sdk'

const ac = new AbortController()
setTimeout(() => ac.abort(), 5_000)

try {
  for await (const msg of client.messages.list({}, { signal: ac.signal })) {
    // ...
  }
} catch (err) {
  if (!(err instanceof APIConnectionError)) throw err
  // expected — signal aborted the iterator
}
```

## Uploading an attachment

`attachments.upload()` wraps the 3-step contract (register → presigned PUT → poll) into one call:

```ts
import { readFile } from 'node:fs/promises'

const file = await readFile('./photo.jpg')

const attachment = await client.attachments.upload(file, {
  filename: 'photo.jpg',
  contentType: 'image/jpeg'
})

await client.messages.send({
  line_id: 'line_01HX...',
  to: '+15551234567',
  body: 'Here you go',
  attachment_ids: [attachment.id]
})
```

5 GB cap. Four documented failure modes — note that the R2 PUT and the server-side `failed` transition share the same `upload_failed` code:

| Failure | `code` |
|---|---|
| `size_bytes` exceeds the 5 GB cap | `attachment_exceeds_storage_limit` |
| R2 presigned PUT returns non-2xx | `upload_failed` |
| Server transitions to `status: failed` | `upload_failed` |
| Polling exhausted before `status: ready` | `upload_timeout` |

## Verifying webhooks

```ts
import { verifyWebhook, isMessageEvent } from '@isnap/sdk'

// Express / Hono / native handler — give us the RAW body bytes (string).
async function handle(rawBody: string, headers: Record<string, string>) {
  const event = verifyWebhook(rawBody, headers, process.env.ISNAP_WEBHOOK_SECRET!)

  // Dedupe by event_id — delivery is at-least-once.
  if (await alreadyProcessed(event.event_id)) return

  if (isMessageEvent(event)) {
    console.log(event.event_type, event.data.message.id)
  }
}
```

During a `rotate-secret` grace window pass both secrets:

```ts
verifyWebhook(rawBody, headers, [newSecret, oldSecret])
```

Throws `WebhookSignatureError` on missing headers, expired timestamp (5min tolerance), or signature mismatch.

If verification happens behind a trusted gateway, use `parseWebhookEvent(rawBody)` to skip HMAC and just type the envelope.

One guard per event family, each narrowing `event.data` and matching on the wire prefix: `isMessageEvent`, `isReactionEvent`, `isLineEvent`, `isTypingIndicatorEvent`, `isTrialEvent`, `isPreOrderEvent`, `isBindingEvent` (`binding.*`) and `isAdminEvent` (`webhook.*`, the `webhook.test` probe). They prefix-match, so a future event in a known family still narrows without an SDK upgrade. The matching `*EventType` aliases (`MessageEventType`, …) enumerate today's catalog for exhaustive `switch` statements and are kept in lockstep with the backend by a compile-time parity check. A full worked handler lives in [`examples/handle-webhook.ts`](./examples/handle-webhook.ts).

## System probes

Two probes live on the client itself, not on a resource:

```ts
const { version } = await client.version()   // GET /v1/version — backend API version string

const health = await client.health()          // GET /v1/health — { status, checks }
if (health.status !== 'ok') {
  // A degraded backend answers 503 with a body; health() resolves it as data
  // rather than throwing, so you branch on health.status without a try/catch.
  console.warn('iSnap degraded:', health.checks)
}
```

`client.health()` is the **global** backend probe. It is deliberately named apart from `client.lines.health(lineId)`, which reports one line's health — calling the wrong one returns a plausible answer about the wrong object, so reach for `client.health()` only when you mean the whole backend.

## Wholesale + test lines

Wholesale partners mint their own billable shared lines and can park a line in a non-billable test state:

```ts
const line = await client.lines.mintShared()            // POST /v1/lines/shared — a NEW billable line every call
await client.lines.convertToTest(line.id)               // stop billing — park as a test line
await client.lines.convertFromTest(line.id)             // resume billing
```

`mintShared()` is wholesale-partner only in v1: a direct (non-wholesale) customer is rejected with `PaymentRequiredError` (402) and a scoped API key with `PermissionDeniedError` (403). Each call mints a distinct line — the SDK's auto `Idempotency-Key` only dedupes that one call's internal retries, so a caller-level retry must reuse the same `opts.idempotencyKey` to avoid minting twice.

## Errors

All API errors extend `APIError` and carry `status`, `code`, `message`, `traceId`, `requestId`. Subclasses match the §6 contract codes:

```ts
import {
  APIError,
  APIConnectionError,
  AuthenticationError,
  BadRequestError,
  ConflictError,
  GoneError,
  InternalServerError,
  NotFoundError,
  PaymentRequiredError,
  PermissionDeniedError,
  QuotaExceededError,
  RateLimitError,
  ServiceUnavailableError,
  UnprocessableEntityError,
  WebhookSignatureError
} from '@isnap/sdk'

try {
  await client.messages.send({
    line_id: 'line_01HX...',
    to: '+15551234567',
    body: 'hello'
  })
} catch (err) {
  if (err instanceof RateLimitError) {
    // retry-after handled automatically by the retry loop, but visible here too
  } else if (err instanceof QuotaExceededError) {
    // pause sending until next quota window
  } else if (err instanceof APIError) {
    console.error(err.status, err.code, err.traceId)
  } else if (err instanceof APIConnectionError) {
    // network issue / timeout — already retried per `maxRetries`
  } else {
    throw err
  }
}
```

Retries: 429 + 5xx are retried automatically with jittered exponential backoff up to `maxRetries`. The retry budget is per-call.

A server-sent `retry_after` is honored, but only up to `maxRetryDelayMs` (default 60s). A 429 asking for longer is **not** slept through — the `RateLimitError` is thrown straight to you, with `err.retryAfter` (seconds) intact, so you can schedule the wait yourself instead of parking the promise. The concrete case is the trial rebind cap: revoking with `reason: 'rebind'` past the per-owner limit answers with `retry_after ≈ 86400` (24h), which the SDK surfaces immediately rather than hanging for a day. `onRetry` does not fire for these — nothing is being retried. If you genuinely want the SDK to sleep that long, raise the ceiling to a large finite value (`maxRetryDelayMs: 86_400_000`); `Infinity` is rejected.

## Examples

Runnable scripts in [`examples/`](https://github.com/WhatSnap/iSnap-App/tree/main/packages/sdk/examples) (browse on GitHub — they live in the repo, not the published tarball):

- `send-message.ts` — minimal send
- `paginate-messages.ts` — async iterator + abort
- `upload-attachment.ts` — file upload + send with attachment
- `handle-webhook.ts` — `node:http` server that verifies and dispatches events

Run them against a local backend:

```bash
ISNAP_API_KEY=sk_test_... bun run examples/send-message.ts
```

## Development

```bash
bun install
bun run --filter '@isnap/sdk' test
bun run --filter '@isnap/sdk' typecheck
bun run --filter '@isnap/sdk' lint
bun run --filter '@isnap/sdk' build
```

To regenerate types from the backend's frozen OpenAPI spec:

```bash
bun run --filter '@isnap/backend' dump-openapi   # writes packages/backend/openapi.json
bun run --filter '@isnap/sdk' generate           # regenerates src/generated/openapi.d.ts
```

## License

MIT
