Menu

Node.js

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

Preview

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:

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

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: an upsert resolved by externalId, then email, then phone. It is safe to repeat; it converges rather than creating duplicates.

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. 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.

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, ending the journey runs the event started that are still in progress.

Send a transactional message

transactional.send(idempotencyKey, body) calls 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.

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: 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.

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.

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.

Errors

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

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.

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.

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.

Sign tokens for the app and web SDKs

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

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 has the whole route, for Express and Next.js.

Conformance

This client is the reference for the SDK conformance checklist; where the checklist and this client disagree, the client is right.