# @unitpay/sdk

Official UnitPay **server SDK** for JavaScript — Node, edge, and any runtime with
`fetch`. Authenticates with a **secret key** (`upay_sk_…`) for full server-to-server
access.

> Building a customer-facing UI? Use [`@unitpay/react`](../react) instead — it holds a
> customer-scoped **portal token** inside `<UnitPayProvider>`. Portal tokens are not a
> valid option for this server SDK.

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

## Quick start

```ts
import { UnitPay } from '@unitpay/sdk'

const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_SK! })
// apiKey falls back to the UNITPAY_API_KEY env var if omitted.

// 1. Identify a customer
const customer = await unitpay.customers.create({
  name: 'Acme, Inc.',
  email: 'billing@acme.com',
  externalId: 'acme',           // your internal id
})

// 2. Gate a feature (the entitlement hot-path)
const { access, remaining } = await unitpay.check({
  customerId: customer.id,
  featureSlug: 'api_calls',
})
if (!access) throw new Error('Upgrade required')

// 3. Record usage
await unitpay.track({ customerId: customer.id, eventName: 'api_call', quantity: 1 })
```

## Resources

All methods require a secret key.

| Resource | Methods |
|---|---|
| _client_ | `check(params)` · `track(event \| event[])` · `verifyWebhook` _(static)_ |
| `customers` | `create` · `get` · `list` · `update` · `archive` · `check` · `checkBatch` · `entitlements` · `listInvoices` · `listPaymentMethods` · `listCreditAccounts` |
| `subscriptions` | `create` · `get` · `list` · `change` · `cancel` · `uncancel` |
| `portalSessions` | `create` · `revoke` |
| `usage` | `track` |

To list a customer's subscriptions, use `subscriptions.list({ customerId })`.

### Entitlement checks

```ts
// Flat check — most common.
const res = await unitpay.check({ customerId, featureSlug: 'seats', requestedUsage: 3 })
// res.access, res.remaining, res.limit, res.feature.type, …

// Customer-scoped, and a batch variant for rendering an access matrix.
await unitpay.customers.check(customerId, { featureSlug: 'seats' })
const { results } = await unitpay.customers.checkBatch(customerId, ['seats', 'api_calls'])
```

### Subscriptions & payment outcomes

`create` and `change` return a **`PaymentOutcome`** — discriminate on `kind`:

```ts
const outcome = await unitpay.subscriptions.create({ customerId, planId })

switch (outcome.kind) {
  case 'created_no_charge':          // free plan — nothing to collect
  case 'charged_inline':             // paid off-session with a card on file
    break
  case 'requires_form':              // no card on file — collect payment
    // Ship outcome.client to the browser and mount <PaymentForm> (@unitpay/react)
    break
  case 'invoice_sent':               // hosted invoice emailed (send_invoice)
    break
}
```

### Customer identify (no upsert)

There is no upsert endpoint. `create` on a duplicate `externalId` throws a
`ConflictError`. For an idempotent identify, catch it and look up:

```ts
import { ConflictError } from '@unitpay/sdk'

async function identify(params) {
  try {
    return await unitpay.customers.create(params)
  } catch (err) {
    if (err instanceof ConflictError && params.externalId) {
      const { data } = await unitpay.customers.list({ externalId: params.externalId })
      if (data[0]) return data[0]
    }
    throw err
  }
}
```

## Usage tracking

Send one event, or an array of up to 100 in a single call:

```ts
await unitpay.track({ customerId, eventName: 'api_call', quantity: 1 })
await unitpay.track([
  { customerId, eventName: 'api_call', quantity: 5 },
  { customerId, eventName: 'tokens', quantity: 1200 },
])
```

Pass an `idempotencyKey` per event to make retries safe.

## Pagination

`list*` methods return a thenable, async-iterable page:

```ts
const page = await unitpay.customers.list()   // { data, hasMore, nextCursor }

for await (const customer of unitpay.customers.list()) {  // auto-follows cursors
  console.log(customer.id)
}

const all = await unitpay.customers.list().toArray()
```

## Errors

Typed hierarchy — switch on class or on `.code`:

```ts
import { UnitPay, NotFoundError, RateLimitError, ApiError } from '@unitpay/sdk'

try {
  await unitpay.customers.get('cus_bad')
} catch (err) {
  if (err instanceof NotFoundError) { /* … */ }
  if (err instanceof RateLimitError) { console.log('retry after', err.retryAfter) }
  if (err instanceof ApiError) { console.log(err.code, err.requestId) }
}
```

Exported: `ApiError`, `AuthenticationError`, `BadRequestError`, `ConflictError`,
`ConnectionError`, `InternalServerError`, `NotFoundError`, `RateLimitError`,
`TimeoutError`, `ValidationError`.

## Behavior

- **Retries** on 429 / 409 / 5xx / transient network errors. Exponential backoff
  (500ms × 2ⁿ), jittered, capped 10s. Respects `Retry-After` on 429.
- **Idempotency keys** auto-generated on every POST/DELETE. Override with
  `{ idempotencyKey }` in request options.
- **Timeout** 30s default. Override per-client (`timeout: 10_000`) or per-request.
- **AbortController** pass `{ signal }` to cancel.
- **Events** `unitpay.on('request', …)` / `on('response', …)` for tracing.
- **Custom fetch** inject via `{ fetch: myFetch }` (undici in Node, MSW in tests, etc.).

## Configuration

```ts
new UnitPay({
  apiKey: process.env.UNITPAY_SK!,   // or UNITPAY_API_KEY env var
  baseUrl: 'https://api.useunitpay.com/v1',  // default
  timeout: 30_000,
  retries: 2,
  idempotencyKeyPrefix: '',
  fetch: globalThis.fetch,
})
```

## Webhook verification (Node only)

```ts
const event = await UnitPay.verifyWebhook(rawBody, request.headers, process.env.WEBHOOK_SECRET!)
```

Requires the optional `svix` peer dep: `npm i svix`.

## Compatibility

- Node ≥ 18 (native `fetch`, `crypto.randomUUID`)
- Edge runtimes (Cloudflare Workers, Vercel Edge, Deno)

## License

MIT
