@chronq/sdk

Publish messages to a chronq instance, and verify that an incoming request was really delivered by chronq.

Two halves, usable independently:

Export Runs where What it does
Client Anywhere with fetch Publishes and schedules HTTP deliveries.
Receiver Your HTTP handler Verifies the chronq-signature header.

Nothing here depends on Node. Signature verification is written against Web Crypto, so the receiver half runs on Node, Bun, Deno, Cloudflare Workers and in the browser alike.

Installing

npm install @chronq/sdk

The published tarball is dist/ — compiled JavaScript plus .d.ts files. It has no dependencies, needs no @types/node, and runs on Node 22+, Bun, Deno, Cloudflare Workers and in the browser.

Inside this repo it is a workspace package instead, and the export map is different: it points at src/index.ts, so nothing has to be built before the app runs and Vite compiles the TypeScript directly. publishConfig swaps that for dist/ when the package is packed, which is why the two differ.

{ "devDependencies": { "@chronq/sdk": "workspace:*" } }

That only works because the bundler compiles it. Node refuses to strip types for files under node_modules, so a workspace dependency installed the normal way produces a server that cannot start — apps/web gets away with listing it under devDependencies because adapter-node bundles it into build/. The comment at the top of the Dockerfile has the consequences.

Publishing

import { Client } from '@chronq/sdk';

const client = new Client({
  baseUrl: 'https://chronq.example.com',
  token: process.env.CHRONQ_TOKEN!      // the publish token
});

const { messageId, deduplicated } = await client.publishJSON({
  url: 'https://my-app.example.com/hook',   // absolute destination
  body: { hello: 'world' },                 // serialised for you
  retries: 3
});

publishJSON sets content-type: application/json and calls JSON.stringify for you. Use publish when you want to send a raw string, or a body that is not JSON.

Options

Every option is optional except url, which must be absolute.

Option Type Meaning
url string Destination the message is delivered to.
queue string Deliver through this queue instead of default.
body string Raw body. publishJSON takes any JSON value instead.
contentType string content-type on the delivered request.
method 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' Method to deliver with.
headers Record<string, string> Headers forwarded to the destination.
timeoutMs number Delivery timeout.
retries number Extra attempts after the first failure.
retryDelayMs number Base delay between retries.
delayMs number Delay the first attempt by this much.
notBefore Date Deliver no earlier than this. Wins over delayMs.
deduplicationId string Suppress duplicates carrying the same id.
contentBasedDeduplication boolean Derive the id from destination, body and headers.
callback string Called with the destination's response after each attempt.
failureCallback string Called once all retries are exhausted.
labels string[] Labels for filtering.

A queue carries its own retry, expiry and retention settings, which the per-message options above do not override. To change that behaviour, change the queue's settings in the admin UI.

Return value

interface PublishResponse {
  messageId: string;
  /** True when the message was rejected as a duplicate of one already queued. */
  deduplicated: boolean;
}

deduplicated is derived from the 202 the API answers with when it refuses a duplicate. The publish still succeeded; nothing new was queued.

Errors

publish and publishJSON throw an Error when the response is not ok, with the status and the API's own message in the text:

publish failed with 400: invalid destination url

A response missing messageId also throws, rather than returning an object with undefined in it.

Batch

const results = await client.batch([
  { url: 'https://example.com/a', body: '{"n":1}', contentType: 'application/json' },
  { url: 'https://example.com/b', queue: 'orders', retries: 1 }
]);
// one entry per request, in order: { messageId, queue, deduplicated } or { error }

Up to 100 per call. All are validated first, so a bad one throws with nothing sent; an unknown or disabled queue is reported in that entry and the rest still go.

URL groups

await client.addEndpoints('order-events', ['https://billing.example.com/hook', 'https://audit.example.com/hook']);

const results = await client.publishToUrlGroup('order-events', { body: '{"orderId":42}', contentType: 'application/json' });
// one entry per endpoint: { messageId, url, queue, deduplicated } or { url, error }

await client.removeEndpoints('order-events', ['https://audit.example.com/hook']);
await client.listUrlGroups();
await client.deleteUrlGroup('order-events');

Looking up and cancelling

const { messageId } = await client.publish({ url, delayMs: 60_000 });

const message = await client.getMessage(messageId);
// message.state: 'created' | 'retry' | 'active' | 'completed' | 'cancelled' | 'failed'
// message.response: the last attempt's status, headers and body, or null

await client.cancelMessage(messageId); // throws with 409 if it already finished

Schedules

await client.upsertSchedule({
  name: 'nightly-report',          // posting the same name again replaces it
  cron: '0 3 * * *',
  timezone: 'America/Chicago',
  url: 'https://example.com/report',
  failureCallback: 'https://example.com/alerts'
});

await client.listSchedules();
await client.getSchedule('nightly-report'); // includes the next three occurrences
await client.deleteSchedule('nightly-report');

Injecting fetch

fetch defaults to globalThis.fetch and can be replaced for tests or for a runtime that provides its own:

new Client({ baseUrl, token, fetch: myFetch });

Verifying deliveries

Without verification, anyone who learns your endpoint URL can post to it and you cannot tell them apart from chronq. Verify every delivery.

import { Receiver, SIGNATURE_HEADER } from '@chronq/sdk';

const receiver = new Receiver({
  currentSigningKey: process.env.CHRONQ_CURRENT_SIGNING_KEY!,
  nextSigningKey: process.env.CHRONQ_NEXT_SIGNING_KEY
});

// In your request handler:
const claims = await receiver.verify({
  signature: request.headers[SIGNATURE_HEADER],
  body: rawBody,                            // the raw string, not a parsed object
  url: 'https://my-app.example.com/hook'    // must match the destination exactly
});

verify resolves with the claims or throws SignatureError. It is the only supported way to trust a delivery.

The raw body matters

The body claim is the SHA-256 of the exact bytes sent. Parse-then-re-serialise changes whitespace, key order and number formatting, and verification fails:

// Wrong: JSON.stringify of a parsed object is not the string that was signed.
const parsed = JSON.parse(await request.text());
await receiver.verify({ signature, body: JSON.stringify(parsed) }); // ✗ fails

Read the body once, verify the string, then parse that same string.

Key rotation

Receiver tries currentSigningKey first, then falls back to nextSigningKey, so a rotation does not break deliveries that are already in flight. Set nextSigningKey to the outgoing key while rotating, and drop it once no signature signed with it can still be valid — 5 minutes plus your clock tolerance.

Both keys must be present or nothing verifies. Constructing a Receiver without currentSigningKey throws.

Claims

Claim Value
iss Always chronq.
sub The destination URL the request was delivered to.
exp / iat / nbf Expiry, issued-at and not-before, unix seconds.
jti Unique token id, so one delivery cannot be replayed as another.
body Unpadded base64url SHA-256 of the raw body.

Pass url to verify and sub is checked too, which pins a signature to one endpoint. Omit it and any valid signature for your instance is accepted, whatever URL it was minted for.

Signature lifetime is 5 minutes. Pass clockToleranceSeconds for leeway in both directions if your receiver's clock drifts.

What verification guarantees

verify pins alg to HS256 before reading any claim, so alg: none and HS256-to-HS512 substitution cannot skip the check. The HMAC comparison is done by Web Crypto in constant time. It rejects a wrong key, a tampered payload, a modified body, a mismatched destination, an expired token, and a token that is not yet valid.

If you cannot use this package, the above is implementable by hand — but only against the claim list above, and never without the alg check.

Lower-level exports

Client and Receiver cover almost every use. The rest is exported for cases they do not fit: proxies that need to verify and re-sign, tests, and tooling.

import {
  signMessage,        // (SignOptions) => Promise<string>
  verifySignature,    // (VerifyOptions) => Promise<SignatureClaims>
  bodyHash,           // (body: string) => Promise<string>
  SignatureError,
  ISSUER,             // 'chronq'
  SIGNATURE_HEADER,   // 'chronq-signature'
  RETRIED_HEADER,     // 'chronq-retried'
  DEFAULT_TTL_SECONDS,// 300
  bytesToBase64url,
  base64urlToBytes,
  bytesToBase64urlOfJson
} from '@chronq/sdk';

signMessage and verifySignature take an optional now (unix ms) so time can be controlled in tests. verifySignature accepts an optional body and destination; omit either to skip that check.

RETRIED_HEADER is the attempt number on a delivered request: 0 on the first attempt, 1 on the first retry. Idempotency keys are usually built from it plus your own message identity.

Types

ClientConfig, PublishRequest, PublishResponse, ReceiverConfig, VerifyRequest, SignatureClaims, SignOptions and VerifyOptions are all exported.

Further reading