@hookticon/client
v0.1.0
Published
Typed Node.js client for the Hookticon webhook relay API.
Maintainers
Readme
@hookticon/client
Typed Node.js client for the Hookticon webhook relay — hand it an HTTP request and it delivers that request to your target, retrying on a backoff curve until the target answers 2xx or the attempts run out.
Types are generated from the API's own OpenAPI document, so a change to the relay's contract shows up here as a compile error rather than a runtime 400.
Installation
npm install @hookticon/clientRequires Node.js >= 18 (for the global fetch).
This is an ESM-only package — it publishes no CommonJS build. From a CJS file, reach it with a
dynamic import: const { createHookticonClient } = await import('@hookticon/client').
Entry points
| Import | Environment | Notes |
| --- | --- | --- |
| @hookticon/client | Node.js | The only entry point |
There is no browser build, deliberately. The API key authenticates every relay call, and a key shipped in a browser bundle is a key handed to every visitor. Call this from your server.
Quick start
import { createHookticonClient } from '@hookticon/client';
const hookticon = createHookticonClient({
baseUrl: 'https://hookticon.example.com',
apiKey: process.env.HOOKTICON_API_KEY!,
});
// Hand over a request to be delivered elsewhere. Returns as soon as it is accepted.
const { id } = await hookticon.registerWebhook({
url: 'https://partner.example.com/webhooks/orders',
body: JSON.stringify({ orderId: 'A-1' }),
headers: { 'content-type': 'application/json' },
timeoutStrategy: 'SDF3',
failNotifyUrl: 'https://my-app.example.com/hookticon/failed',
});
// Later: how did it go?
const page = await hookticon.listWebhooks({ status: 'FAIL', limit: 100 });A successful registerWebhook means accepted for delivery, not delivered. Delivery is
asynchronous; poll listWebhooks or supply a failNotifyUrl to learn the outcome.
Configuration
type HookticonClientOptions = {
/** Where the relay lives, e.g. `https://hookticon.example.com`. Required — there is no default. */
baseUrl: string;
/** Sent as `x-hookticon-api-key` on every authenticated call. A server-side secret. */
apiKey: string;
/** Optional. When given, every response is logged at debug level. Pino/winston/console-shaped. */
logger?: {
debug(obj: unknown, msg?: string): void;
info(obj: unknown, msg?: string): void;
warn(obj: unknown, msg?: string): void;
error(obj: unknown, msg?: string): void;
};
/** Optional. Override the global `fetch` — a proxy agent, an instrumented client, a test double. */
fetch?: typeof fetch;
};baseUrl has no default on purpose: the relay's production host is deployment-specific, so any value
baked into this package would be wrong for someone, and silently relaying production traffic at the
wrong host is worse than a compile error. HOOKTICON_DEV_BASE_URL is exported for local experiments.
API
| Method | Description |
| --- | --- |
| registerWebhook(params) | Hand the relay a request to deliver. Returns { id }. |
| listWebhooks(params?) | One page of delivery records, newest first, plus the total. |
| health() | Liveness probe. Unauthenticated. |
registerWebhook(params)
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| url | string | — | Required. Absolute URL the request is delivered to. |
| method | string | post (server-side) | HTTP method used against the target. |
| timeoutStrategy | 'DEFAULT' \| 'SDF3' \| 'NO_TIMEOUT' | DEFAULT (server-side) | Retry curve. |
| failNotifyUrl | string | none | POSTed once, with the webhook record as JSON, when retries are exhausted. |
| body | BodyInit | none | Relayed byte for byte — never parsed or re-encoded. |
| headers | Record<string, string> | none | Forwarded verbatim to the target. |
Retry curves:
| Strategy | Behaviour |
| --- | --- |
| DEFAULT | Up to 37 attempts, tapering 1min → 3min → 10min → 1h → 1 day → 2 days |
| SDF3 | 4 attempts: after 6h, then 1 day, then 1 day |
| NO_TIMEOUT | A single attempt, never retried |
Defaults for method and timeoutStrategy are applied by the relay, not by this package — an
unset option is omitted from the request rather than filled in here, so the two can never disagree.
Everything in headers travels on to the target, including any credential it expects. The relay
strips the x-hookticon-* control headers and host; a caller header can never displace a control
header or the API key.
// Binary payloads are relayed unchanged — set a content-type so the target knows what it got.
await hookticon.registerWebhook({
url: 'https://partner.example.com/ingest',
body: new Uint8Array(pdfBytes),
headers: { 'content-type': 'application/pdf', authorization: 'Bearer partner-token' },
});listWebhooks(params?)
All filters are optional and combine with AND.
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| status | 'PENDING' \| 'SUCCESS' \| 'FAIL' | none | Only webhooks in this delivery state. |
| timeoutStrategy | 'DEFAULT' \| 'SDF3' \| 'NO_TIMEOUT' | none | Only webhooks on this retry curve. |
| url | string | none | Case-insensitive substring of the target URL. |
| createdFrom | string (ISO 8601) | none | Registered at or after this instant, inclusive. |
| createdTo | string (ISO 8601) | none | Registered at or before this instant, inclusive. |
| limit | number (1–200) | 50 | Page size. |
| offset | number | 0 | Rows skipped, for paging. |
const { items, total, limit, offset } = await hookticon.listWebhooks({
status: 'FAIL',
url: 'partner.example.com',
createdFrom: '2026-01-01T00:00:00.000Z',
limit: 100,
});
for (const webhook of items) {
console.log(webhook.id, webhook.tries, webhook.nextTryAt);
}The relayed request itself — body and forwarded headers — is deliberately not returned: it is your own payload and may carry your credentials. It is visible in the relay's admin panel, which is guarded by a separate password.
health()
const { status } = await hookticon.health(); // { status: 'ok' }Answers as soon as the relay is serving and checks no dependencies — a liveness signal, not a readiness one. Unauthenticated, so it works before an API key is configured.
Error handling
Every failure is an Error subclass from this package, so one instanceof catches them all.
| Class | Meaning |
| --- | --- |
| HookticonError | Base class. Everything below extends it. |
| HookticonApiError | The relay answered, and the answer was not 2xx. Carries statusCode, code, response. |
| HookticonTransportError | The relay could not be reached — DNS, TLS, refused connection, abort. Carries cause. |
The split matters: a HookticonApiError is usually a bug in the call, a HookticonTransportError is
usually worth retrying.
| Type guard | Narrows to |
| --- | --- |
| isHookticonError(error) | HookticonError |
| isHookticonApiError(error, code?) | HookticonApiError, optionally with that code |
| isHookticonApiCodeError(error, 'UNAUTHORIZED') | HookticonApiError & { code: 'UNAUTHORIZED' } — code is the literal inside the branch |
| isHookticonTransportError(error) | HookticonTransportError |
| code | Status | Meaning |
| --- | --- | --- |
| INVALID_HEADERS | 400 | A control header is missing or malformed (registerWebhook). |
| INVALID_QUERY | 400 | A query parameter is malformed or out of range (listWebhooks). |
| UNAUTHORIZED | 401 | Missing or unknown API key. |
| INTERNAL_ERROR | 500 | Unexpected server-side failure. |
code is undefined when something between you and the relay — a proxy, a load balancer — answered
instead of the relay itself. statusCode is always set on a HookticonApiError.
import { isHookticonApiCodeError, isHookticonTransportError } from '@hookticon/client';
try {
await hookticon.registerWebhook({ url: target, body });
} catch (error) {
if (isHookticonApiCodeError(error, 'UNAUTHORIZED')) {
throw new Error('Rotate HOOKTICON_API_KEY');
}
if (isHookticonTransportError(error)) {
return scheduleRetry(); // the relay is unreachable, not unhappy
}
throw error;
}Advanced
A custom fetch
Anything fetch-shaped works — a proxy agent, an OpenTelemetry-instrumented client, or a test double:
const hookticon = createHookticonClient({
baseUrl,
apiKey,
fetch: (request) => instrumentedFetch(request),
});Regenerating from the spec
src/clients/generated and openapi.json are both committed, so a fresh clone builds without the API
being reachable, and spec drift is visible in the diff. Never hand-edit src/clients/generated —
change the spec in the backend repo and regenerate:
npm run codegen:dump # dump the spec from ../backend's working tree, then generate
npm run codegen:fetch # or: pull it from a deployment (OPENAPI_URL overrides the default)
npm run codegen # regenerate from the openapi.json already committed hereCI re-runs npm run codegen and fails on a non-empty diff, so committed types cannot quietly
describe an older spec.
License
MIT
