TypeScript SDK
The Invoa TypeScript SDK: a small, dependency-free client for the REST API with typed methods, error handling and webhook signature verification.
The Invoa TypeScript SDK wraps the REST API in typed methods and includes a webhook signature verifier. It has no dependencies and runs anywhere with fetch and Web Crypto: Node.js 20 or later, Deno, Bun and Cloudflare Workers.
Get the SDK
The SDK package is named @invoa/api-sdk. It is not published on the public npm registry; it is available on request from hello@invoa.io. Everything it does is plain HTTPS calls, so you can also call the REST API directly from any language.
Important: Use the SDK on your server only. Never ship an API key to a browser or mobile app.
Set up a client
import { Invoa } from "@invoa/api-sdk";
const invoa = new Invoa({
apiKey: process.env.INVOA_API_KEY!, // inv_live_… from Settings → API keys
});| Option | Type | Description |
|---|---|---|
apiKey | string | Required. Your API key. |
baseUrl | string | Default https://app.invoa.io. The SDK adds /api/v1. |
timeoutMs | number | Request timeout. Default 30000 (30 seconds). |
headers | object | Extra headers on every request, for example your own x-request-id. |
fetch | function | A custom fetch implementation. |
Example: booking to invoice
Lembah Bayu Travel Sdn. Bhd. confirms a group booking in its own system and wants the deposit invoice issued and emailed at once:
import { Invoa, toSen } from "@invoa/api-sdk";
const invoa = new Invoa({ apiKey: process.env.INVOA_API_KEY! });
export async function onBookingConfirmed(booking: {
ref: string;
email: string;
contactName: string;
company: string;
deposit: string; // "1250.00"
}) {
// 1. Find the customer by email, or create them.
const found = await invoa.contacts.list({ search: booking.email, limit: 1 });
const contact =
found.data[0] ??
(await invoa.contacts.create({
name: booking.contactName,
companyName: booking.company,
email: booking.email,
}));
// 2. Draft the invoice. The idempotency key makes retries safe.
const draft = await invoa.documents.createDraft({
type: "invoice",
contactId: contact.id,
reference: booking.ref,
idempotencyKey: `booking-${booking.ref}-deposit`,
lines: [
{
description: `Deposit for booking ${booking.ref}`,
quantity: 1,
unitPrice: toSen(booking.deposit),
taxType: "SST_SERVICE",
},
],
});
// 3. Issue it (a retried draft may already be issued), then email it.
const invoice = draft.status === "draft" ? await invoa.documents.issue(draft.id) : draft;
const { sent } = await invoa.documents.send(invoice.id);
return { number: invoice.number, hostedUrl: invoice.hostedUrl, whatsappLink: sent.whatsappLink };
}Methods
| Namespace | Method | Endpoint | Returns |
|---|---|---|---|
contacts | list(query?) | GET /contacts | Page |
get(id) | GET /contacts/{id} | Contact with balances | |
create(input) | POST /contacts | Contact | |
update(id, patch) | PATCH /contacts/{id} | Contact | |
items | list(query?) | GET /items | Page |
create(input) | POST /items | Item | |
documents | list(query?) | GET /documents | Page plus sums |
get(id) | GET /documents/{id} | Document | |
createDraft(input) | POST /documents | Document | |
issue(id, options?) | POST /documents/{id}/issue | Document | |
send(id, options?) | POST /documents/{id}/send | { data, sent } | |
pdf(id) | GET /documents/{id}/pdf | ArrayBuffer, or null while rendering | |
void(id, reason) | POST /documents/{id}/void | Document | |
payments | list(query?) | GET /payments | Page |
record(input) | POST /payments | { payment, receipt, document } | |
schedules | list(query?) | GET /schedules | Page |
get(id) | GET /schedules/{id} | Schedule | |
create(input) | POST /schedules | Schedule | |
trigger(itemId, options?) | POST /schedules/items/{id}/trigger | { item, issued, invoice } | |
einvoice | submit(documentId) | POST /einvoice/{documentId}/submit | { data, jobId, status } |
status(documentId) | GET /einvoice/{documentId}/status | Status | |
webhooks | list() | GET /webhooks | { data, events } |
get(id) | GET /webhooks/{id} | { data, deliveries } | |
create(input) | POST /webhooks | { data, secret } | |
update(id, patch) | PATCH /webhooks/{id} | Endpoint | |
delete(id) | DELETE /webhooks/{id} | nothing |
Most methods return the API's data directly. List methods return the full page, { data, pagination }. Inputs and outputs use the same camelCase fields and integer sen as the API; see each resource page for the fields. documents.list takes status as an array and joins it for you.
documents.pdf returns null when the API answers 202 because the PDF is still rendering. Wait a few seconds and call it again.
Errors
Every non-2xx response throws an InvoaError:
import { InvoaError } from "@invoa/api-sdk";
try {
await invoa.documents.issue(draftId);
} catch (err) {
if (err instanceof InvoaError && err.code === "plan_limit") {
// err.status === 422, err.details has used / limit / plan
}
throw err;
}| Property | Description |
|---|---|
status | HTTP status code. |
code | The API's error.code, for example validation or insufficient_scope. |
message | The API's error.message. |
details | The API's error.details, if any. |
requestId | The x-request-id response header. Quote it when you contact support. |
See Errors, limits and pagination for the codes.
Money helpers
| Function | Example |
|---|---|
toSen(rm) | toSen("1,250.00") returns 125000. Accepts a number or a string. |
fromSen(sen) | fromSen(125000) returns "1250.00". |
Verify webhooks
verifyWebhookRequest checks the signature and timestamp (5 minutes tolerance by default) and returns the parsed event. It throws if the request is not genuine.
import { verifyWebhookRequest } from "@invoa/api-sdk";
export async function POST(request: Request) {
const rawBody = await request.text();
let event;
try {
event = await verifyWebhookRequest(rawBody, request.headers, process.env.INVOA_WEBHOOK_SECRET!);
} catch {
return new Response("invalid signature", { status: 400 });
}
if (event.event === "payment.confirmed") {
// event.data.paymentId, event.data.documentId, event.data.amount (sen)
}
return new Response("ok");
}verifyWebhook(rawBody, signatureHeader, secret, timestampHeader, options?) does the same with the header values passed separately. Both accept { toleranceSeconds } as options. See Webhooks for the events and payloads.
Related
Something unclear or out of date? Tell us at hello@invoa.io and we'll fix the page.