Skip to content

TypeScript SDK

@hubtec/talki is the TypeScript client for /v1. It is generated from the same OpenAPI document as the API reference, and adds errors, retries, paging, webhook verification and OAuth on top.

Terminal window
npm install @hubtec/talki@beta

Beta releases are published under the beta tag, and the package’s own interface may still change between them. Source, changelog and the full README are on GitHub.

It has no runtime dependencies: it is built on the global fetch and WebCrypto, runs on Node 20 or later, Bun, Deno and Cloudflare Workers, and ships ES modules and CommonJS with their types.

import { Talki } from "@hubtec/talki";
const talki = new Talki({ apiKey: "talki_test_…" });
const account = await talki.account.get();
console.log(account.business_name, account.livemode);
  • Pass apiKey for an API key, or accessToken for an OAuth access token. With neither, new Talki() reads the key from the TALKI_API_KEY environment variable where process.env exists.
  • A Talki holds no connection of its own and needs no closing: keep one for the life of the process.
  • Every request sends Talki-Version: API_VERSION, the dated version the package was generated from. new Talki({ version: "…" }) pins another; version: null sends none, so the version pinned on the key or app applies.

Talki has one property per tag of the API reference (talki.account, talki.appointments, talki.calls, talki.workingHours, …) and one method per operation, named after the operation ID without the tag’s noun: listCalls is talki.calls.list, getCallTranscript is talki.calls.getTranscript.

const appointment = await talki.appointments.create({
body: { client_name: "Ada", client_surname: "Lovelace", start_at: "2026-11-02T09:30:00+01:00" },
});
await talki.appointments.cancel(appointment.id);
  • Path parameters are positional. Everything else goes in one object: query parameters under the API’s own names (location_id, created_after), body (or form, for an upload), and the options idempotencyKey, ifMatch, talkiVersion and signal.
  • A method resolves to the operation’s value, or undefined for 204 No Content, and rejects for an error. A file the API returns (an export, a voice sample, a document’s original) is a Blob.
  • body is a plain object with the API’s keys, typed from the spec. Dates are ISO strings; a Date is sent as its ISO string. The API validates the body and answers a wrong one with 400.
  • Every model and request type is exported (Call, AppointmentCreate, …), and per method ListCallsParams, GetCallResponse, ListCallsRow.

For the status or a header (ETag, Idempotency-Replayed, the rate-limit headers, X-Request-Id), call the same method on withRawResponse. It resolves to a RawResponse with status, headers, requestId and data:

const raw = await talki.account.withRawResponse.get();
// Refused with 412 if the account changed since that read.
await talki.account.update({ body: { greeting: "Buongiorno" }, ifMatch: raw.headers.get("ETag") ?? undefined });

talki.withOptions({ timeout: 5000, maxRetries: 0 }) returns the same client with other settings (timeout in milliseconds) for the calls made on it. Pass signal to any method to abort it, its retries and the waits between them.

A method rejects with a subclass of ApiError, chosen by the error envelope’s type (see Errors and rate limits): InvalidRequestError, AuthenticationError, PermissionDeniedError, ConflictError, RateLimitError (with retryAfter, in seconds) and ServerError. Each carries status, type, code, apiMessage, requestId, headers and body. Branch on code, and quote requestId to support.

import { ConflictError } from "@hubtec/talki";
try {
await talki.appointments.create({ body: booking });
} catch (error) {
if (error instanceof ConflictError && error.code === "slot_unavailable") {
// …
} else {
throw error;
}
}

An answer with no envelope, such as a proxy’s, is classed by its HTTP status, with code set to null. A request that got no answer rejects with ConnectionError, or RequestTimeoutError after timeout; its idempotencyKey is the key it carried.

Talki retries up to maxRetries times (default 2):

  • a request that never left (connection refused, address not resolved);
  • a request answered 429 or 409 request_in_progress;
  • a read answered 500, 502, 503 or 504, or whose connection broke or timed out after sending;
  • a write with an Idempotency-Key answered 502, 503 or 504 by a gateway.

A write the API itself answered with a 5xx, or whose connection broke or timed out after sending, is never retried, since it may have been saved: read the resource before writing again. Waits follow Retry-After up to 60 seconds; a longer one rejects instead (RateLimitError for a 429). timeout applies to each attempt. Redirects are never followed.

Every write without an Idempotency-Key gets a new one, so the client’s own retries are never applied twice. To retry a write yourself, pass your own as idempotencyKey (newIdempotencyKey() makes one); a write you may abort through your own signal needs one to be sent again as the same request. autoIdempotency: false turns this off.

A method whose operation takes cursor returns a PagePromise. Await it for the first Page, with data, hasMore and nextCursor; for await it for every row of it and of the pages after it, fetched as the loop reaches them. A list without a cursor (talki.locations.list(), talki.tags.list(), …) resolves to its model whole.

import { position } from "@hubtec/talki";
for await (const call of talki.calls.list({ limit: 100 })) {
// …
}
for await (const page of talki.appointments.list({ cursor: stored }).iterPages()) {
await save(page.data);
stored = position(page, stored); // store it with the rows
}

A change feed returns next_cursor on every non-empty page, the last one included: store every non-null one with the rows and resume from it, as Core concepts describes. position(page, stored) returns the cursor to keep. Nothing is sent until the list is awaited or iterated.

An upload (talki.documents.create, talki.documents.replace) takes form instead of body, and is sent as multipart/form-data. The file is a File, whose name becomes the document’s title; toFile(source, name) makes one from a Buffer, bytes, text, a Blob or a stream.

import { readFile, writeFile } from "node:fs/promises";
import { toFile } from "@hubtec/talki";
const document = await talki.documents.create({
form: { file: await toFile(await readFile("price-list.pdf"), "price-list.pdf") },
});
const file = await talki.documents.getFile(document.id);
const name = file instanceof File ? file.name : "download";
await writeFile(`./downloads/${name}`, new Uint8Array(await file.arrayBuffer()));

A downloaded file’s name comes from whoever uploaded it: check it before using it in a path. See the knowledge base guide for formats, limits and processing.

@hubtec/talki/webhooks checks X-Talki-Signature on the raw body, before parsing, as Webhooks describes. It uses WebCrypto only and loads nothing else, so it runs on edge runtimes.

import { constructEvent, signatureOf, SignatureVerificationError } from "@hubtec/talki/webhooks";
const body = new Uint8Array(await request.arrayBuffer());
try {
const event = await constructEvent(body, signatureOf(request.headers), secret);
// event.id, event.type, event.data
} catch (error) {
if (error instanceof SignatureVerificationError) return new Response(null, { status: 400 });
throw error;
}
  • constructEvent resolves to the envelope; verifySignature only checks. Both take the body as received, as bytes or as its text decoded unchanged: a body parsed and serialised again fails. Both refuse a signature more than 300 seconds from now.
  • signatureOf(headers) finds the header in a Headers or a plain object, whatever its case.
  • During a secret rotation either secret verifies; pass an array when you hold more than one.

@hubtec/talki/oauth covers an app installed on Talki accounts: the consent URL with PKCE, the callback, the code exchange, and a token pair that renews itself.

import { Talki } from "@hubtec/talki";
import { OAuthApp, authorizationUrl, createPkce, readCallback } from "@hubtec/talki/oauth";
const app = new OAuthApp(clientId, clientSecret);
const pkce = await createPkce();
const state = crypto.randomUUID();
const url = authorizationUrl(clientId, redirectUri, ["calls:read"], { state, pkce });
// Send the customer's browser to url; then, on the callback:
const code = readCallback(callbackUrl, { state });
const tokens = await app.exchangeCode(code, { redirectUri, codeVerifier: pkce.verifier });
await save(tokens);
const talki = new Talki({ tokens, oauthApp: app, onTokenRefresh: save });
  • A TokenSet is a plain object: store it as JSON and pass it back as tokens. It holds secrets, so never log it.
  • With oauthApp, Talki renews the pair a minute before it expires, and on a 401 with error="invalid_token", then sends the request again.
  • The refresh token rotates on every renewal, and presenting a rotated one again revokes the installation. Keep one Talki per installation per process, and store the new pair in onTokenRefresh, which is awaited before the pair is used. Never make a request on the same Talki inside it.
  • After invalid_grant (the customer disconnected, or the pair was revoked) or invalid_client, every request rejects with that OAuthError without asking again: build a new Talki once fixed.
  • A code works once: never retry exchangeCode with the same code.

The client is silent until you give it a level or a logger:

const talki = new Talki({ apiKey, logLevel: "debug" }); // to console
const quiet = new Talki({ apiKey, logger: pino() }); // to your logger, from "info" up

debug logs every answer with its request ID; info logs every retry and token renewal. A request is written by method and path only, never its query, headers, body or credentials. new Talki({ …, userAgent: "acme-crm/2.1" }) appends your integration’s name to the User-Agent.

  • The partner routes (/oauth/app/*, /oauth/managed-accounts, /oauth/enrollments) are not in the OpenAPI document, so not in the package. See Partners and Managed billing.
  • Webhook payloads have no generated types: an event is typed by its id and type only. Event payloads lists their shapes.