# Node.js

Integrate with the Kirora API using the preview Node.js library: typed requests, pagination, retries and webhook verification.

Use `@kirora/node` to call the Kirora `/v1` API from a Node.js server with typed requests, pagination, retry handling and webhook verification. The TypeScript library is the reference implementation for other server clients. It is in preview and not yet published to npm.

## Install

The package is `@kirora/node`. It is not published to npm yet, so the install command below works once it is:

```bash
npm install @kirora/node
```

The client uses the platform `fetch`, `AbortSignal.timeout` and `AbortSignal.any`, and `node:crypto` for webhook verification, so it needs Node.js 22 or later.

## Create a client

```ts
import { Kirora } from '@kirora/node';

const kirora = new Kirora({ apiKey: process.env.KIRORA_API_KEY! });
```

| Option | Type | Default | |
| --- | --- | --- | --- |
| `apiKey` | `string` | Required | Your API key (`kirora_…`). The constructor throws if it is missing. |
| `baseUrl` | `string` | `https://api.kirora.io` | For tests and mocks. The host only, without a version: the client adds `/v1` to each path. |
| `timeoutMs` | `number` | `30000` | Per-attempt timeout. |
| `retry.maxRetries` | `number` | `3` | Attempts after the first. `0` disables retrying. |
| `retry.maxDelayMs` | `number` | `30000` | Ceiling for a single backoff wait. A longer `Retry-After` is returned to you instead of waited out. |
| `fetch` | `typeof fetch` | `globalThis.fetch` | Inject a proxy-aware implementation, or a stub in tests. |
| `workspace` | `string` | None | For a partner key: the tenant workspace's `externalId`, sent as the `Kirora-Workspace` header on every call. A call's own `workspace` option overrides it. Leave it out to act in the key's own workspace. |

Method names are the `operationId` with the resource dropped, so `identifyContact` is `kirora.contacts.identify()` and `sendCampaign` is `kirora.campaigns.send()`. Methods return the response's `data`, already unwrapped.

| Namespace | Methods |
| --- | --- |
| `contacts` | `list`, `listPage`, `get`, `update`, `identify`, `listGroups` |
| `groups` | `list`, `listPage`, `get`, `create`, `delete`, `addMembers` |
| `campaigns` | `list`, `listPage`, `get`, `create`, `send`, `trigger`, `schedule`, `eligibility` |
| `transactional` | `send`, `sendBatch` |
| `events` | `track`, `trackBatch`, `cancel` |

Coverage is partial during the preview. For any other operation, call the transport directly with `kirora.client.request(method, path, options)` or `kirora.client.paginate(path, options)`; both use the same auth, retries and error mapping.

## Identify a contact

`contacts.identify()` calls [`identifyContact`](/docs/api/reference/identifyContact): an upsert resolved by `externalId`, then `email`, then `phone`. It is safe to repeat; it converges rather than creating duplicates.

```ts
const contact = await kirora.contacts.identify({
  externalId: 'cust_1042',
  email: 'amaka@juniperkitchen.com',
  firstName: 'Amaka',
  attributes: { plan: 'weekly-box', city: 'Toronto' },
});

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

## Track an event

`events.track(idempotencyKey, body)` calls [`trackEvent`](/docs/api/reference/trackEvent). The API requires an idempotency key on every event; derive it from your own event or order id so a retried request is recorded once.

```ts
const event = await kirora.events.track('order-1042-completed', {
  externalId: 'cust_1042',
  eventName: 'Order Completed',
  properties: { orderId: '1042', total: 42.5, currency: 'USD' },
});

console.log(event.id, event.occurredAt);
```

`events.cancel(eventId)` calls [`cancelEvent`](/docs/api/reference/cancelEvent), ending the journey runs the event started that are still in progress.

## Send a transactional message

`transactional.send(idempotencyKey, body)` calls [`sendTransactional`](/docs/api/reference/sendTransactional). Generate the key once per logical send, before the first attempt and outside any retry loop, so a retry can never send twice. See [Idempotency](/docs/api/idempotency).

```ts
const orderId = '1042';

const { messages } = await kirora.transactional.send(`receipt-${orderId}`, {
  to: { email: 'amaka@juniperkitchen.com', firstName: 'Amaka' },
  email: {
    templateId: 'order-receipt', // a template id or its alias
    data: { orderId },
    subject: 'Your Juniper Kitchen receipt',
    from: 'Juniper Kitchen <orders@mail.juniperkitchen.com>',
  },
});

for (const message of messages) {
  console.log(message.id, message.channel, message.status);
}
```

`transactional.sendBatch(body)` calls [`sendTransactionalBatch`](/docs/api/reference/sendTransactionalBatch): up to 100 sends, each with its own `idempotencyKey` in the body.

## Paginate

Top-level lists come in pairs. `list()` returns an async iterator that follows the cursor for you, yielding items one at a time without buffering the collection; it stops when `nextCursor` is `null`.

```ts
for await (const contact of kirora.contacts.list({ limit: 100 })) {
  console.log(contact.email);
}
```

`listPage()` returns one page when you want to hold the cursor yourself. Treat the cursor as opaque.

```ts
const page = await kirora.contacts.listPage({ limit: 100 });
console.log(page.data.length, page.meta.pagination.nextCursor);

if (page.meta.pagination.nextCursor !== null) {
  const next = await kirora.contacts.listPage({
    limit: 100,
    cursor: page.meta.pagination.nextCursor,
  });
}
```

Nested lists, such as `contacts.listGroups(contactId)`, expose the iterator only. See [Pagination](/docs/api/pagination).

## Errors

Every error the API returns is a `KiroraError`. Branch on `code`, never on `message`.

```ts
import { KiroraError, KiroraConnectionError } from '@kirora/node';

try {
  await kirora.campaigns.send(campaignId);
} catch (error) {
  if (error instanceof KiroraError && error.code === 'SENDING_DOMAIN_UNVERIFIED') {
    // Recoverable: publish the DNS records and check the domain.
  } else if (error instanceof KiroraConnectionError) {
    // No response at all: network failure or timeout, after retries.
  }
  throw error;
}
```

| Property | |
| --- | --- |
| `code` | The API's error code, typed as the published catalogue or any string, so a code added after your build never crashes the client. |
| `message` | Human-readable; for logs, not for branching. |
| `status` | The HTTP status. |
| `details` | Raw detail; field errors for `VALIDATION_ERROR` live here. |
| `requestId` | The `X-Request-Id` response header, for support. |
| `retryAfterSeconds` | The `Retry-After` header, when the server sent one. |
| `isRetryable` | `true` for 429 and 5xx. |
| `isKnownCode()` | `false` for a code published after this build. |

`KiroraConnectionError` is raised when there is no response to read a code from, such as a connection reset or a timeout. Its message names the reason, as in `GET /v1/groups failed: connect ECONNREFUSED …`, and the original error is on `cause`. The full code catalogue is exported as `PUBLIC_ERROR_CODES`. See [Errors](/docs/api/errors).

## Retries and timeouts

Every request goes through one retry loop, which never repeats a call that might already have happened:

- **429 is always retried:** the server refused it before doing anything.
- **5xx and connection failures (including timeouts) are retried only when a repeat can't act twice:**
  - reads, `PUT` and `DELETE`
  - any call with an idempotency key (`events.track`, `transactional.send`, `campaigns.trigger`)
  - the batch calls, `transactional.sendBatch` and `events.trackBatch`, since every entry carries its own idempotency key, so a repeat replays rather than sending twice
  - calls that converge on the same result (`contacts.identify`, `contacts.update`, `groups.addMembers`, `campaigns.send`, `campaigns.schedule`, `events.cancel`)

  A create without a key, such as `groups.create` or `campaigns.create`, is never retried: the error goes to you, so check whether the record exists before trying again.
- **Backoff:** exponential (1 s, 2 s, 4 s…) with ±25% jitter, capped at `retry.maxDelayMs`. When the server sends `Retry-After`, the retry never comes sooner. If `Retry-After` is longer than `retry.maxDelayMs`, the client doesn't wait: the error goes to you, with `retryAfterSeconds` set.
- **Bounded:** at most `retry.maxRetries` retries (default 3), then the last error is thrown.
- **Timeout:** `timeoutMs` (default 30 s) applies to each attempt.

## Verify webhooks

`verifyWebhookSignature(secret, rawBody, signature, timestamp)` checks the `X-Webhook-Signature` header (`sha256=` plus the HMAC-SHA256 of `${timestamp}.${rawBody}`) in constant time, rejects a timestamp more than 300 seconds from now, and accepts any of the comma-separated signatures sent while a secret rotation overlaps. `readWebhookHeaders(headers)` pulls out the `X-Webhook-*` headers, or returns `undefined` if they are missing.

Verify against the **raw** body, before parsing. Parsing JSON and re-serializing it changes the bytes and the signature will not match.

```ts
import express from 'express';
import { readWebhookHeaders, verifyWebhookSignature } from '@kirora/node';

const app = express();

app.post('/webhooks/kirora', express.raw({ type: 'application/json' }), (req, res) => {
  const headers = readWebhookHeaders(req.headers);
  if (
    !headers ||
    !verifyWebhookSignature(
      process.env.KIRORA_WEBHOOK_SECRET!,
      req.body, // a Buffer, because of express.raw()
      headers.signature,
      headers.timestamp,
    )
  ) {
    return res.status(401).end();
  }

  // Delivery is at-least-once: deduplicate on headers.eventId.
  const payload = JSON.parse(req.body.toString('utf8'));
  console.log(headers.event, headers.eventId, headers.attempt, payload);
  res.status(200).end();
});
```

Pass `{ toleranceSeconds }` as a fifth argument to change the replay window. `WEBHOOK_EVENTS` lists the event types known at build time; treat the type as open, because a `*` subscription receives types added later. See [Webhooks](/docs/api/webhooks).

## Sign tokens for the app and web SDKs

The [web SDK](/docs/sdks/web) and the app SDKs sign users in with a token from your server. `createUserToken(secret, userId)` signs one:

```ts
import { createUserToken } from '@kirora/node';

const token = createUserToken(process.env.KIRORA_SDK_TOKEN_SECRET!, user.id);
```

`secret` is the workspace's SDK token secret, from **Developer → SDK** in the dashboard; it never leaves your server. `userId` is your own id for the user, their contact's `externalId`. The token is valid for an hour; pass `{ expiresInSeconds }` as a third argument to change that, up to Kirora's limit of 24 hours. It needs no client and no API key. The web SDK's [setup](/docs/sdks/web#2-add-a-token-route-to-your-server) has the whole route, for Express and Next.js.

## Conformance

This client is the reference for the [SDK conformance](/docs/api/sdk-conformance) checklist; where the checklist and this client disagree, the client is right.