@ulvio/client
v0.4.0
Published
Official TypeScript client for the Ulvio platform, html-to-pdf, and utilities services.
Readme
@ulvio/client
Official TypeScript client for the Ulvio platform.
A single Ulvio class exposes domain-scoped sub-clients (mail, mailbox, sms, whatsapp, voice, files, ai, html-to-pdf, utilities), all reachable via the same baseUrl + apiKey. The platform proxies html-to-pdf and utilities to their internal services transparently, so consumers only need one set of credentials.
Install
npm install @ulvio/clientRequires Node.js 24+.
Configuration
import { Ulvio } from '@ulvio/client';
const client = new Ulvio({
baseUrl: process.env.ULVIO_BASE_URL,
apiKey: process.env.ULVIO_API_KEY,
});Both baseUrl and apiKey are required for every sub-client. If either is missing when a method is called, the call throws an UlvioError with code not_configured — distinct from runtime/network errors, so consumers can detect misconfiguration explicitly:
import { UlvioError, NOT_CONFIGURED_CODE } from '@ulvio/client';
try {
await client.mail.sendTransactional({ ... });
} catch (err) {
if (err instanceof UlvioError && err.code === NOT_CONFIGURED_CODE) {
// surface a deployment-time configuration error
}
throw err;
}Rate-limit handling
When the platform returns a 429 with code: "RATE_LIMITED", the client transparently waits for the window indicated by Retry-After (falling back to x-ratelimit-reset, then the message hint) and retries the request. Concurrent in-flight calls coalesce on a single pause timer per Ulvio instance, so a single rate-limit response doesn't fan out into a retry storm.
A 403 with the same RATE_LIMITED code is the platform's ban response and is not retried — it surfaces as an UlvioError with code rate_limited_banned.
Bounds and opt-out are configurable:
const client = new Ulvio({
baseUrl: process.env.ULVIO_BASE_URL,
apiKey: process.env.ULVIO_API_KEY,
rateLimit: {
maxRetries: 3, // default 3
maxWaitMs: 60_000, // default 60s cumulative
disabled: false, // set true to throw immediately on a 429
},
});If retries are exhausted or the cumulative wait would exceed maxWaitMs, the call throws an UlvioError with code rate_limited. Use isRateLimitedError to handle both:
import { isRateLimitedError } from '@ulvio/client';
try {
await client.mail.sendTransactional({ ... });
} catch (err) {
if (isRateLimitedError(err)) {
// back off or surface a 503 to your caller
}
throw err;
}Usage
await client.mail.sendTransactional({
from: '[email protected]',
to: ['[email protected]'],
subject: 'Welcome',
body_html: '<p>Hi!</p>',
});Mailbox
const { messages } = await client.mailbox.list(20);
const msg = await client.mailbox.get(messages[0].id);
await client.mailbox.markProcessed(msg.id);getConnectorStatus() reports whether the upstream connector is healthy. When unhealthy, the listing/reading methods throw an UlvioError whose code is CONNECTOR_UNHEALTHY — use the isConnectorUnhealthyError helper to detect it:
import { isConnectorUnhealthyError } from '@ulvio/client';
try {
await client.mailbox.list();
} catch (err) {
if (isConnectorUnhealthyError(err)) {
// back off polling
} else {
throw err;
}
}SMS / WhatsApp / Voice
await client.sms.send({ from: '+1...', to: '+1...', body: 'hello' });
await client.whatsapp.send({ to: '+1...', template_name: 'reminder', language_code: 'en' });
// Transcribe (speech-to-text) — from an inline base64 buffer…
await client.voice.transcribe({ file: base64Audio, file_name: 'call.webm' });
// …or by URL the platform fetches server-side (provide one of file / url).
await client.voice.transcribe({ url: 'https://cdn.example.com/call.webm' });
// Synthesize (text-to-speech) — returns base64-encoded audio in `data`.
const { data } = await client.voice.synthesizeSpeech({ voiceId: 'voice_123', text: 'Hello there' });WhatsApp: templates, session messages, and webhooks
All WhatsApp sending goes through the platform proxy — no Meta access token, phone number id, or app secret lives on the consumer.
// List the templates configured on the Business Account. Defaults to APPROVED
// (the ones sendable without an open 24h session); widen with { status }.
const { templates } = await client.whatsapp.getTemplates();
const pending = await client.whatsapp.getTemplates({ status: 'PENDING' });
// Business-initiated template carrying a quick-reply payload. The payload is
// echoed back on the inbound webhook so you can correlate the reply.
await client.whatsapp.send({
to: '+1...',
template_name: 'consent',
language_code: 'nl',
components: [
{
type: 'button',
sub_type: 'quick_reply',
index: 0,
parameters: [{ type: 'payload', payload: workOrderId }],
},
],
});
// Inside the 24h customer-service window opened by a button tap, send
// non-template messages. Meta's error is passed through if the window is closed.
await client.whatsapp.sendDocument({
to: '+1...',
document: { link: 'https://.../work-order.pdf', filename: 'work-order.pdf', caption: 'Your work order' },
});
await client.whatsapp.sendText({ to: '+1...', text: 'Thanks — your work order is on its way.' });Meta delivers webhook POSTs directly to your endpoint. Verify their signature via the platform (which holds the app secret) and check the registered callback URL on startup:
// In your webhook route — `rawBody` must be the unparsed bytes Meta signed.
const ok = await client.whatsapp.verifyWebhookSignature({
rawBody,
signature: req.headers['x-hub-signature-256'] ?? '',
}); // -> false when the signature is missing or invalid
// On startup: confirm Meta points at the URL this instance expects.
const sub = await client.whatsapp.getWebhookSubscription();
if (sub.callback_url !== expectedUrl) log.warn('WhatsApp webhook URL mismatch', sub);Files
await client.files.upload('reports/2026/q1.pdf', buffer, 'application/pdf');
const res = await client.files.get('reports/2026/q1.pdf');
const { url } = await client.files.presignedDownloadUrl('reports/2026/q1.pdf', 600);Proxy
Forward any third-party API call through the Ulvio server instead of calling it
directly. The server holds the upstream base URL and credentials for each named
target (e.g. "hubspot"), so consumer apps only ever talk to Ulvio and the
production firewall can be closed to "only the Ulvio server reaches the internet".
proxy.fetch(target, path, init?) is fetch-shaped: you pass a relative
path (the client never names the upstream host) and get the raw Response back.
Authentication is your normal Ulvio API key; upstream credentials are injected
server-side and never touch the client.
const res = await client.proxy.fetch('hubspot', '/crm/v3/objects/contacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ properties: { email: '[email protected]' } }),
});
// fetch semantics: an upstream non-2xx is returned on the Response, not thrown.
if (!res.ok) console.error('upstream error', res.status);
const contact = await res.json();Targets are configured on the server (ulvio-dev/server#53).
AI proxy
import { z } from 'zod';
const Person = z.object({ name: z.string(), age: z.number() });
const data = await client.ai.parse({
model: 'claude-opus-4-7',
input: 'Extract the person from: "Ada Lovelace, 36"',
schema: Person,
});
for await (const event of client.ai.stream({
model: 'claude-opus-4-7',
input: '...',
schema: Person,
})) {
if (event.type === 'partial') console.log(event.data);
if (event.type === 'complete') console.log('done', event.data);
}HTML-to-PDF
const { pdf } = await client.htmlToPdf.convert(
{ html: btoa('<h1>Invoice</h1>'), outputMode: 'base64' },
{
onQueued: ({ position }) => console.log('queued at', position),
onProcessing: ({ progress }) => console.log('progress', progress),
},
);Or have the service PUT the rendered PDF directly to a presigned URL:
await client.htmlToPdf.convert({
sourceUrl: 'https://example.com/invoice/1',
uploadUrl: presignedPutUrl,
});Utilities
const { html } = await client.utilities.compileMjml({ mjml: '...' });
const { result } = await client.utilities.renderLiquid({ template: 'Hi {{ name }}', data: { name: 'Ada' } });
const { variables } = await client.utilities.extractLiquidVariables({ template: '{{ a }} {{ b }}' });
const { html: md } = await client.utilities.renderMarkdown({ markdown: '# title' });
const { html: email } = await client.utilities.renderEmail({ mjml: '...', data: { ... } });Error tracking
Report errors and events to Ulvio Server's in-core error-tracking module via the
errors namespace. It posts to POST /v1/errors using the same Bearer apiKey
— no second dependency, no separate DSN. The server resolves the target
environment from the API key.
try {
await risky();
} catch (err) {
await client.errors.capture(err, {
level: 'error', // 'fatal' | 'error' | 'warning' | 'info' | 'debug'
transaction: 'GET /users/:id',
tags: { region: 'eu-west-1' },
extra: { userId: 42 },
});
}capture() accepts an Error (its name, message and stack become a structured
exception) or a plain message string, plus an optional context object:
| Field | Description |
| --- | --- |
| level | Severity. Defaults to 'error'. |
| environment | Deployment label (e.g. 'production'). A label only — attribution still comes from the API key. |
| transaction | Route / operation name, e.g. 'GET /users/:id'. Grouping fallback. |
| platform | Platform hint. Defaults to 'node'. |
| release | Release identifier (git SHA, semver, …). |
| serverName | Host the event originated from. |
| tags | Indexed key/value tags, filterable in the issue UI. |
| extra | Arbitrary extra metadata. |
| user | User context (id, email, …). The server scrubs ip_address when configured. |
| request | Request context (method, url, headers, …). Pass a plain, serializable object. |
| fingerprint | Explicit grouping hints, overriding the default fingerprint. |
Best-effort and non-throwing. Reporting must never crash the host app, so
capture() never throws — it returns an ErrorCaptureResult:
const res = await client.errors.capture(err);
if (!res.ok) {
// res.error is a UlvioError describing why ingestion failed
console.warn('error reporting failed', res.error?.code);
}
// res.status is 'queued' on success, or 'throttled' when the server
// deliberately sampled the event out (also a success — do not retry).Fastify global error handler
Wire setErrorHandler once to report every unhandled route error. Extract the
fields you want from the request rather than passing the raw req (it is not
safely serializable):
import { Ulvio } from '@ulvio/client';
const ulvio = new Ulvio({ /* baseUrl, apiKey */ });
app.setErrorHandler((err, req, reply) => {
// Fire-and-forget; capture() is non-throwing.
void ulvio.errors.capture(err, {
transaction: `${req.method} ${req.routerPath ?? req.url}`,
tags: { method: req.method },
request: { method: req.method, url: req.url },
});
reply.send(err);
});Migrating from 0.2.x to 0.3.0
The 0.3.0 release collapses the four per-service config fields to a single { baseUrl, apiKey } pair, and routes html-to-pdf and utilities through the platform forwarder.
- Replace
platformApiUrl/htmlToPdfApiUrl/utilitiesApiUrlwith onebaseUrlpointing atapi.ulvio.dev(or your ulvio-server URL). - Replace
platformApiKeywithapiKey— the same key now authenticates every sub-client. - The legacy env vars (
ULVIO_PLATFORM_API_URL,HTML_TO_PDF_API_URL,UTILITIES_API_URL) should be replaced withULVIO_BASE_URL+ULVIO_API_KEY. - The three legacy error codes (
platform_not_configured,html_to_pdf_not_configured,utilities_not_configured) are gone; catchnot_configured(exported asNOT_CONFIGURED_CODE) instead.
// before (0.2.x)
new Ulvio({
platformApiUrl: process.env.ULVIO_PLATFORM_API_URL,
platformApiKey: process.env.ULVIO_PLATFORM_API_KEY,
htmlToPdfApiUrl: process.env.HTML_TO_PDF_API_URL,
utilitiesApiUrl: process.env.UTILITIES_API_URL,
});
// after (0.3.0)
new Ulvio({
baseUrl: process.env.ULVIO_BASE_URL,
apiKey: process.env.ULVIO_API_KEY,
});The platform side must be running a version that exposes /html-to-pdf/* and /utilities/* forwarders (ulvio-dev/ulvio-server).
Development
npm install
npm run typecheck
npm test
npm run buildEnd-to-end tests run against live containers via Docker Compose:
npm run test:e2eError tracking lives in Ulvio Server (Postgres-backed) rather than the platform compose stack, so it has a standalone smoke test you point at a running server with the error-tracking module enabled for the API key's environment:
ULVIO_BASE_URL=https://api.your-ulvio.dev ULVIO_API_KEY=sk_... npm run smoke:errorsReleasing
Tag the commit with the version (vX.Y.Z); the Release workflow verifies the tag matches package.json and runs npm publish --provenance.
npm version patch # bumps package.json + creates a vX.Y.Z tag
git push --follow-tagsThe workflow needs an NPM_TOKEN secret with publish access to the @ulvio scope.
