@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
- Working with the HTTP queue — every option header, retries, callbacks, deduplication and the event log.
- Trying the HTTP queue by hand — a runnable receiver with one route per behaviour.
examples/receiver.tsin this package — a working receiver onnode:http.