Trying the HTTP queue by hand

Every behaviour in Working with the HTTP queue, done once with curl against a receiver you can watch — published through a queue you create in the dashboard, the way a real integration works.

The receiver is examples/http-queue-demo: a small Hono app with one route per behaviour, which verifies every delivery with @chronq/sdk and logs what it saw to a page at http://127.0.0.1:8787. Nothing is persisted, so restarting it gives you a clean slate.

What you need

Two terminals, and a chronq instance that is set up:

pnpm install          # the demo adds hono and @hono/node-server
pnpm db:start         # Postgres in Docker
pnpm db:push          # schema
pnpm dev              # http://localhost:5173 — also runs the delivery worker

The delivery worker runs inside the dev server, so pnpm dev is what actually makes deliveries happen. If it is not running, publishes are accepted and then sit in the queue, which looks exactly like a broken destination.

On a fresh instance, open http://localhost:5173 and complete /setup first.

Two credentials are involved, and they are different things:

Credential Used by Where
Publish token You, when publishing Account page → Publish token
Signing key The receiver, to verify Account page → Signing keys → Current

1. Create the queue

Queues → Create a queue at http://localhost:5173/app/queues/new:

Field Value Why it matters later
Name demo The name in the publish URL. Lowercase letters, digits, - and _ only.
Retry limit 4 Extra attempts after the first, so five in total.
Retry delay (s) 2 Base wait between attempts.
Exponential backoff off Leave it off to keep the gaps even and the demo quick.
Expire (s) 60 Longest a delivery may stay active, and the default Chronq-Timeout.

Leave everything else alone. Every value here is a default for messages published into this queue, which is the point of publishing into one: retry behaviour belongs to the queue, not scattered across publishers.

Creating a queue in the UI is self-contained — it is pushed into pg-boss and a worker is attached to it immediately, so there is no pnpm jobs:sync to run afterwards. Two things about a queue are fixed at creation: the name (it is the pg-boss queue identity, and renaming would orphan every job already queued) and the policy (pg-boss cannot change it in place, so the sync warns rather than pretending to apply it).

The queue has to exist. Publishing into a queue that does not is a 400, never an implicit create, so a typo fails loudly instead of quietly building a queue with default settings. Only the default queue exists without being created — it is the fallback for a publish with no queue in the path.

2. Start the receiver

The fast way is to let the demo read the credentials out of the database:

pnpm --filter @chronq/http-queue-demo creds > examples/http-queue-demo/.env
pnpm --filter @chronq/http-queue-demo start

Copying them off the Account page by hand works just as well — export CHRONQ_CURRENT_SIGNING_KEY before start and skip the first line. creds only reads; if it says there is no publish token yet, open the Account page once, which is what creates it. It warns if the queue from step 1 is missing.

chronq demo receiver listening on http://127.0.0.1:8787
  dashboard   http://127.0.0.1:8787/
  verify with key …1earJk
  publish at  http://127.0.0.1:8787/hook

Leave it running and open http://127.0.0.1:8787 in a browser. It reloads every two seconds, so it doubles as the log you will be reading for the rest of this guide. In the second terminal:

export CHRONQ_URL=http://localhost:5173
export CHRONQ_TOKEN=chronq_…                       # Account page
export QUEUE=demo                                  # the queue from step 1
export HOOK=http://127.0.0.1:8787

The rest of the guide writes publishes as "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/…". A queue in the first path segment is only read as a queue when it is not itself a URL, which is why the destination still starts with http://.

One destination, one spelling. 127.0.0.1 and localhost are different destinations to chronq, and the signature is pinned to the exact URL. Publish to whichever you set $HOOK to, and do not mix.

3. Deliver your first message

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/hook" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Forward-X-Demo-Id: first' \
  -d '{"orderId":42}'
{"messageId":"666cb78a-60b2-4fc7-ad13-b23fee5b22f8","queue":"demo"}

200 means queued, not delivered — and the queue in the response is the one you named. The delivery happens a moment later, within the worker's one-second poll. The receiver's page should now show:

1  15:02:41  delivery POST /hook   200 accepted   0   {"orderId":42}

The three things worth noticing in that row: the status the destination answered, the outcome chronq will act on, and retried=0 for a first attempt.

Now open Queues → demo → Events in the admin UI. The same run is there with the response body, the response headers, how long it took and the attempt count, and it is scoped to this queue — that view is read back out of pg-boss, so it survives a restart of the receiver. Publishing into default instead would have put it in a different queue's history.

Chronq-Forward-X-Demo-Id is why the row is easy to find. chronq does not tell the destination which message it is delivering, so a publisher that wants to tie a delivery back to its publish has to send an identifier of its own. The demo stores it and the scenarios runner matches on it.

4. See exactly what arrives

/echo answers with the request it received. Publish at it, then read the response in the queue's Events tab, or ask the receiver directly:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/echo" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Forward-X-Api-Key: shh' \
  -H 'Chronq-Forward-X-Demo-Id: echo-1' \
  -d '{"hello":"world"}'

curl -s "$HOOK/events?demo-id=echo-1" | python3 -m json.tool | head -40
{
  "method": "POST",
  "url": "http://127.0.0.1:8787/echo",
  "retried": 0,
  "headers": {
    "chronq-retried": "0",
    "chronq-signature": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
    "content-type": "application/json",
    "host": "127.0.0.1:8787",
    "x-api-key": "shh"
  },
  "body": "{\"hello\":\"world\"}",
  "claims": {
    "iss": "chronq",
    "sub": "http://127.0.0.1:8787/echo",
    "exp": 1790865417, "iat": 1790865117, "nbf": 1790865117,
    "jti": "320f5a0c-1522-4b27-8c9f-2c1145ec9fdf",
    "body": "2Axt8z3OHzbwEE1UhvLi4YjyKiY7rIAS2Xo8RuU1RmY"
  }
}

Four things to take from this:

  • x-api-key arrived without its prefix. Chronq-Forward-* headers are stripped and forwarded; every other option header, Authorization included, is consumed by chronq and never reaches the destination.
  • sub is the destination, query string and all. It is what makes a signature valid for one endpoint and no other.
  • body is a hash, not the body: unpadded base64url SHA-256 of the exact bytes sent.
  • jti is unique per attempt, so a replay of one delivery cannot be passed off as another.

Add a query string yourself and watch sub follow it:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/echo?a=1&b=two" … -d '{}'

5. Watch verification reject things

Without verification, anyone who learns your URL can post to it and you cannot tell them apart from chronq. The demo verifies before its routes run, so all three of these fail on purpose.

No signature at all — what an attacker guessing the URL would send:

curl -X POST "$HOOK/hook" -H 'Content-Type: application/json' -d '{"orderId":42}'
# {"error":"invalid signature: missing chronq-signature header"}   HTTP 401

A signature for a different endpoint. Take the chronq-signature from any /echo event and replay it against /hook:

SIG=$(curl -s "$HOOK/events?demo-id=echo-1" \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['events'][0]['headers']['chronq-signature'])")

curl -X POST "$HOOK/hook" -H 'Content-Type: application/json' \
  -H "chronq-signature: $SIG" -d '{"hello":"world"}'
# {"error":"invalid signature: invalid subject: http://127.0.0.1:8787/echo, want: http://127.0.0.1:8787/hook"}   401

A tampered body. Same signature, same URL, different payload:

curl -X POST "$HOOK/echo" -H 'Content-Type: application/json' \
  -H "chronq-signature: $SIG" -d '{"hello":"tampered"}'
# {"error":"invalid signature: body hash does not match"}   401

Replaying that same signature to the same URL with the same body is accepted, which is the point: the signature proves provenance, and jti plus a clock check is what bounds how long a captured request stays useful.

A rejected delivery is a non-2xx, so chronq retries it — up to the queue's Retry limit. That is deliberate, since fixing the receiver's key lets the next attempt succeed, but it does mean a misconfigured key looks like a flaky destination in the event log.

6. Retries

/flaky?succeed-on=2 answers 503 until chronq-retried reaches 2, then 200. Publish it with no retry headers at all, so the queue's settings apply:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/flaky?succeed-on=2" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Forward-X-Demo-Id: flaky-manual' \
  -d '{"n":1}'

curl -s "$HOOK/events?demo-id=flaky-manual" | python3 -c "
import json,sys
for e in sorted(json.load(sys.stdin)['events'], key=lambda x: x['seq']):
    print(f\"attempt retried={e['retried']} -> HTTP {e['status']} ({e['outcome']}) at {e['at'][11:19]}\")"
attempt retried=0 -> HTTP 503 (failed)   at 15:03:23
attempt retried=1 -> HTTP 503 (failed)   at 15:03:26
attempt retried=2 -> HTTP 200 (accepted) at 15:03:29

Three seconds apart, from a queue configured with a 2s retry delay: the wait is the configured delay plus up to a second of the worker's poll interval, so the gaps you measure are always slightly wider than the number you set.

The retry decision is made on the status alone, never the body. Turn on Exponential backoff for the queue and repeat, and the gaps double each attempt instead of staying even. The queue's Events tab shows the run as completed, with retryCount 2/4.

Chronq-Retries counts attempts after the first: the queue's limit of 4 means up to five deliveries. 0 means try once.

7. Stop retrying on a permanent error

A destination that knows the failure is permanent can end the run immediately:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/permanent-failure" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Failure-Callback: '"$HOOK"'/failure-callback' \
  -H 'Chronq-Label: demo' \
  -d '{"n":1}'

The route answers 489 with chronq-nonretryable-error: true. The receiver logs one attempt and no more, and the failure callback fires straight away rather than after the queue's four retries:

{
  "messageId": "48508549-8d87-4996-95af-33d7ab337e63",
  "url": "http://127.0.0.1:8787/permanent-failure",
  "status": 489,
  "responseHeaders": { "chronq-nonretryable-error": "true", ... },
  "retried": 0,
  "maxRetries": 4,
  "labels": ["demo"]
}

retried: 0 with maxRetries: 4 is how a callback tells "the destination refused" apart from "we ran out of attempts" — the latter arrives with retried === maxRetries. And maxRetries here is the queue's limit, which the publisher never sent.

8. Callbacks

A callback turns fire-and-forget into something the publisher can observe without waiting:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/hook" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Callback: '"$HOOK"'/callback' \
  -H 'Chronq-Callback-Method: PUT' \
  -H 'Chronq-Callback-Forward-X-Trace: abc123' \
  -d '{"orderId":42}'

/callback logs what chronq sent. The body is the destination's response, not the original payload:

{
  "messageId": "73d5d54b-35d3-4ec6-bd4a-181570e47db2",
  "url": "http://127.0.0.1:8787/hook",
  "status": 200,
  "responseBody": "{\"ok\":true,\"at\":\"2026-10-01T15:02:44.948Z\"}",
  "durationMs": 12,
  "retried": 0,
  "maxRetries": 4,
  "labels": []
}

The success callback fires once, on the attempt the destination accepted — not once per attempt. Callbacks are signed exactly like deliveries, with the callback URL as sub, so the demo verifies them with the same Receiver; a callback that failed verification would show up as rejected in its log.

Callbacks are best effort. An unreachable callback is logged by chronq and never retried, because the delivery already succeeded or failed on its own merits.

Note the callback carries the same three knobs as a delivery — Chronq-Callback-Method, -Timeout, and -Forward-* — and the same three exist for Chronq-Failure-Callback-*.

9. Deduplication

Send the same publish twice with the same id:

for i in 1 2; do
  curl -sS -w "  <- HTTP %{http_code}\n" \
    -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/hook" \
    -H "Authorization: Bearer $CHRONQ_TOKEN" \
    -H 'Content-Type: application/json' \
    -H 'Chronq-Deduplication-Id: order-42' \
    -d '{"orderId":42}'
done
{"messageId":"275e0ea8-e089-4489-a66d-6a70c02dafa1","queue":"demo"}  <- HTTP 200
{"messageId":"275e0ea8-e089-4489-a66d-6a70c02dafa1","queue":"demo"}  <- HTTP 202

202 is the tell: nothing new was enqueued, and the messageId belongs to the message already queued. Only one delivery reaches the receiver. This exists for the case where your connection drops before you read the response — retrying the publish is safe.

Run it a second time inside the ten-minute window and both answers are 202, because the first publish is the one that got enqueued. Change the id to start over.

Chronq-Content-Based-Deduplication: true does the same without you tracking an id: the id is derived from the destination, the body and the forwarded headers, so header order and casing do not matter. An explicit id wins if both are given.

10. Scheduling and methods

Hold the first attempt:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/hook" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Delay: 20s' \
  -d '{"later":true}'

Nothing appears at the receiver for twenty seconds. Chronq-Not-Before takes a unix timestamp instead and wins if both are sent. Sub-second durations are accepted but rounded, because pg-boss measures in whole seconds.

Deliver a GET, which carries no body:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/echo" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Chronq-Method: GET' \
  -H 'Chronq-Forward-X-Demo-Id: get-1'

GET, POST, PUT, PATCH and DELETE are the choices. A 3xx from the destination is not followed — that would forward the signature to another host — so a redirect counts as a failure and is retried.

For anything on a schedule rather than a one-off, use a cronjob (Cronjobs in the admin UI). Its Run now button goes through exactly this delivery path, which is the quickest way to test a real endpoint. A cronjob also has to point at a queue, and it can create one for itself if you would rather not do it first.

11. Timeouts

/slow?ms=4000 sleeps before answering. Give it a one-second budget:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/slow?ms=4000" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Timeout: 1s' \
  -H 'Chronq-Retries: 0' \
  -H 'Chronq-Forward-X-Demo-Id: slow-1' \
  -d '{"n":1}'

The attempt is aborted at one second and recorded with status 0 and the abort reason as the body — 0 means "no response", which is also what a connection refused or a DNS failure looks like.

The receiver still answers four seconds later, so its own log shows a 200 for a request chronq had already given up on. That divergence is the lesson: the destination's view of an attempt and the queue's view are not the same thing, and the queue's view is the one that decides retries. The demo labels that row so it is not mistaken for a success.

Without a Chronq-Timeout header the queue's Expire is the budget, so a queue with Expire 60 gives every delivery a minute.

12. Queue settings, and when a header wins

Everything published into a queue inherits its settings, and a header on the publish overrides just that message. Publish at a route that never succeeds, sending no retry options at all:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/always-fails" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Failure-Callback: '"$HOOK"'/failure-callback' \
  -H 'Chronq-Label: budget' \
  -d '{"n":1}'

The queue does the rest, and the failure callback reports the budget it resolved:

attempts:  retried=0 → 503   15:03:55
           retried=1 → 503   15:03:58
           retried=2 → 503   15:04:01
           retried=3 → 503   15:04:04
           retried=4 → 503   15:04:07

failure callback: status=503 retried=4 maxRetries=4 labels=['budget']

Five attempts over twelve seconds, from a queue configured with Retry limit 4 and Retry delay 2s — none of which the publisher sent. retried === maxRetries is the exhausted budget.

Now send one header and watch it win:

curl -X POST "$CHRONQ_URL/v2/publish/$QUEUE/$HOOK/always-fails" \
  -H "Authorization: Bearer $CHRONQ_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Chronq-Retries: 1' \
  -d '{"n":1}'

Two attempts, then the run fails. Only that message is affected: the queue still gives four retries to everything else. The same holds for Chronq-Retry-Delay (the base wait), Chronq-Timeout and Chronq-Delay — omit the header to take the queue's value, send it to override.

That precedence is why the earlier commands could leave the retry options out entirely. It also means a queue's retry limit is a ceiling that publishers can lower but not raise, which is a useful thing to be able to say about an integration you do not control.

13. All of it in one command

The runner publishes one message per behaviour above — all through CHRONQ_QUEUE, demo by default — and follows each one to its outcome:

pnpm --filter @chronq/http-queue-demo scenarios
Publishing 11 scenario(s) to http://localhost:5173
  queue demo, delivered to http://127.0.0.1:8787

  basic           200 380848a7-bb2c-4576-b7bd-030d55720b3a
  …
  queue-defaults  200 1e48d6cf-d41a-4ef1-9c94-a95ff36a213f

  ✓ basic           delivered on attempt 1 (HTTP 200)
  ✓ retry           delivered on attempt 3 (HTTP 200)
  • timeout         no answer inside Chronq-Timeout (chronq logged status 0)
  ✗ permanent       stopped at once (HTTP 489, non-retryable)
      callback received: HTTP 489, retried 0/4
  ✗ queue-defaults  spent the whole budget: 5 attempt(s), HTTP 503
      callback received: retried 4/4, resolved from the queue
  ✓ dedup           delivered on attempt 1 (HTTP 200)
  …

Pass a name to run one: scenarios retry. The list is basic, forwarded, retry, timeout, permanent, exhausted, queue-defaults, callback, dedup, delayed, get. If the queue does not exist the runner stops on the first publish and tells you where to create it.

What the receiver is doing

Worth copying into a real endpoint, because the details are where this goes wrong:

const raw = await c.req.text();          // read the body once, as a string
const claims = await receiver.verify({
  signature: c.req.header('chronq-signature'),
  body: raw,
  url: c.req.url                          // must be the destination exactly
});
  • Verify the raw body, then parse that same string. body is a hash of the exact bytes sent. Parse-then-re-serialise changes whitespace, key order and number formatting, and verification fails on a payload that is semantically identical.
  • Pass the URL the request was delivered to. c.req.url is reconstructed from the Host header, so it matches whatever the publisher wrote. Behind a tunnel or proxy it will not, which is what CHRONQ_PUBLIC_URL in the demo overrides.
  • Verify before doing anything else. The demo's guard sits in front of every route so no route can act on an unverified request.
  • Give the receiver both keys while rotating. CHRONQ_NEXT_SIGNING_KEY is the outgoing key: signatures minted with it keep verifying until every receiver has been updated.

Troubleshooting

Symptom Cause
400 unknown queue: "demoo" The queue in the path does not exist. Create it under Queues; queues are never created implicitly by a publish.
401 invalid or missing publish token Wrong $CHRONQ_TOKEN, or it was rotated. Account → Rotate token.
Publish returns 200 but nothing arrives The worker only runs inside pnpm dev. Check that terminal before suspecting the destination.
The queue's Events tab is empty You are looking at a different queue than the one in the publish URL. The response's queue field names the one that took it.
rejected rows in the receiver's log Signature verification failed — usually 127.0.0.1 vs localhost, or a key that was rotated. The reason is in the row.
Receiver shows 200, the queue's Events tab shows 0 The destination answered after Chronq-Timeout. See Timeouts.
400 in the log but it retried anyway Everything non-2xx retries. Answer 489 with chronq-nonretryable-error: true to stop it.
A queue's retry settings appear ignored A header was sent, and headers win over the queue. See Queue settings.
403 Cross-site POST form submissions are forbidden The publish used a form content type from a browser. Send Content-Type: application/json. Only a production build enforces this; pnpm dev lets it through.
413 Body over 1 MB. Base64-encode binary payloads, which are rejected outright: the body is stored and signed as a string.
Chronq-Retries: 0s → must be an integer Retries is a count, not a duration. Chronq-Timeout, Chronq-Delay and Chronq-Retry-Delay take durations.
Deliveries feel slow Throughput is per queue: ten concurrent jobs, polled once a second.

Cleaning up

# stop the receiver with Ctrl-C, then:
rm examples/http-queue-demo/.env     # holds a copy of the publish token; it is gitignored

The demo queue keeps its run history according to its retention settings, and deleting the queue from the admin UI takes that history with it — which is worth knowing before you do it to a queue something real publishes into.

The reference guide covers the parts this walkthrough does not: key rotation, the full option table, event-log access over oRPC, and the deliberate deviations from QStash.