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
- 1. Create the queue
- 2. Start the receiver
- 3. Deliver your first message
- 4. See exactly what arrives
- 5. Watch verification reject things
- 6. Retries
- 7. Stop retrying on a permanent error
- 8. Callbacks
- 9. Deduplication
- 10. Scheduling and methods
- 11. Timeouts
- 12. Queue settings, and when a header wins
- 13. All of it in one command
- What the receiver is doing
- Troubleshooting
- Cleaning up
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 thedefaultqueue 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.1andlocalhostare different destinations to chronq, and the signature is pinned to the exact URL. Publish to whichever you set$HOOKto, 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-keyarrived without its prefix.Chronq-Forward-*headers are stripped and forwarded; every other option header,Authorizationincluded, is consumed by chronq and never reaches the destination.subis the destination, query string and all. It is what makes a signature valid for one endpoint and no other.bodyis a hash, not the body: unpadded base64url SHA-256 of the exact bytes sent.jtiis 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.
bodyis 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.urlis reconstructed from theHostheader, so it matches whatever the publisher wrote. Behind a tunnel or proxy it will not, which is whatCHRONQ_PUBLIC_URLin 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_KEYis 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.