Working with the HTTP queue
chronq is a self-hosted HTTP queue. You publish a message to a destination URL;
chronq delivers it as a signed HTTP request, retries it if the destination does not
answer 2xx, and reports the outcome to a callback URL.
The shape is deliberately close to QStash, so the mental model transfers. Differences are called out inline and collected at the end under Deviations from QStash.
- Quickstart
- The publish API
- Option headers
- Batch publishing
- URL groups
- Responses
- What the destination receives
- Verifying deliveries
- Retries
- Callbacks
- Deduplication
- Scheduled deliveries
- Reading the event log
- Looking up and cancelling a message
- Running a receiver locally
- Operations
- Troubleshooting
- Deviations from QStash
Quickstart
The publish token and signing keys are on the Account page of the admin UI.
export CHRONQ_URL=http://localhost:5173
export CHRONQ_TOKEN=chronq_… # Account page
export CHRONQ_SIGNING_KEY=… # Account page, for the receiver
curl -X POST "$CHRONQ_URL/v2/publish/https://example.com/hook" \
-H "Authorization: Bearer $CHRONQ_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"orderId":42}'
{ "messageId": "4c0fa780-b1f5-45e2-92d1-07cc131c586a", "queue": "default" }
The destination receives a POST with your body, your headers, a
chronq-signature header, and a chronq-retried header. When it answers 2xx
the message is done. When it does not, chronq retries it.
The publish API
POST /v2/publish/{destinationUrl}
POST /v2/publish/{queueName}/{destinationUrl}
The destination is the literal remainder of the request line, so a URL is used
exactly as written — including its query string. ?a=1&b=two is part of the
destination, and two publishes differing only there are two different
destinations. Only a #fragment cannot survive, because a client never sends one.
A path whose first segment is not itself a URL names a queue first:
# into the `default` queue
curl -X POST "$CHRONQ_URL/v2/publish/https://example.com/hook" …
# into a named queue
curl -X POST "$CHRONQ_URL/v2/publish/webhooks/https://example.com/hook" …
A queue carries its own retry, expiry and retention settings, and they are the defaults for messages published through it: a header you do not send means "use the queue's setting", not "use the built-in default". A header you do send wins. See Queues and cronjobs for what a queue controls.
That precedence matters in practice. A queue with Retry limit: 9, Retry delay: 42s and Expire: 777s gives those to a bare publish; Chronq-Retries: 1 on that
same publish lowers only the retry budget.
Publishing into a queue that does not exist is a 400, not an implicit create, so
a typo fails instead of quietly making a queue with default settings.
Authentication is Authorization: Bearer <publish token>. There is no
unauthenticated publish path.
Requests must be UTF-8, and at most 1 MB
The body is stored and signed as a string, so a body that is not valid UTF-8 is
rejected with 400 rather than being silently mangled. Base64-encode binary
payloads and decode at the destination. A body above 1 MB is refused with 413
before it is read, since the publish path holds it in memory.
Option headers
All option headers are prefixed Chronq-. They are read from the publish request
and are not forwarded to the destination; use Chronq-Forward-* for that.
Request shape
| Header | Default | Meaning |
|---|---|---|
Content-Type |
— | Forwarded to the destination as content-type. |
Chronq-Method |
POST |
GET, POST, PUT, PATCH or DELETE. A GET is sent with no body. |
Chronq-Forward-* |
— | Forwarded with the prefix stripped: Chronq-Forward-X-Api-Key: k arrives as X-Api-Key: k. |
Chronq-Timeout |
queue's Expire, else 30s |
How long the destination may take. Also bounds how long the attempt may stay active. |
Chronq-Queue |
default |
Deliver through this queue. Equivalent to the path form. |
Retries and scheduling
| Header | Default | Meaning |
|---|---|---|
Chronq-Retries |
queue's Retry limit, else 3 |
Extra attempts after the first, so 4 deliveries in total. 0 means try once. |
Chronq-Retry-Delay |
queue's Retry delay, else 1s |
Base delay for the backoff between retries. |
Chronq-Delay |
— | Hold the first attempt, e.g. 50s, 1d10h30m. |
Chronq-Not-Before |
— | Unix timestamp in seconds. Takes precedence over Chronq-Delay. |
Deduplication and callbacks
| Header | Meaning |
|---|---|
Chronq-Deduplication-Id |
Collapse publishes sharing this id within a 10-minute window. |
Chronq-Content-Based-Deduplication |
true derives the id from the destination, body and forwarded headers. |
Chronq-Callback |
URL called with the outcome after a successful delivery. |
Chronq-Failure-Callback |
URL called once every retry is exhausted. |
Chronq-Label |
Comma-separated labels, stored with the run for filtering. |
Callbacks accept their own method, timeout and headers:
Chronq-Callback-Method: PUT
Chronq-Callback-Timeout: 5s
Chronq-Callback-Forward-X-Trace: abc123
The same three exist for Chronq-Failure-Callback-*.
Durations
<number><unit>, units ms, s, m, h, d, and segments may be combined:
500ms, 45s, 2m, 1h, 1d, 1d10h30m. A bare number is milliseconds,
matching QStash's retry-delay examples where 1000 means one second.
Zero is not a valid duration, in either spelling: 0 and 0s are both a 400,
as is anything unparseable or negative. Omit the header to take the queue's value
instead. Nothing finer than a second is applied to a retry delay, because pg-boss
measures it in whole seconds.
Batch publishing
POST /v2/batch takes a JSON array of up to 100 publishes, each with its options
as the same headers a single publish reads:
curl -X POST "$CHRONQ_URL/v2/batch" \
-H "Authorization: Bearer $CHRONQ_TOKEN" \
-H 'Content-Type: application/json' \
-d '[
{ "destination": "https://example.com/a", "body": "{\"n\":1}",
"headers": { "content-type": "application/json", "chronq-retries": "2" } },
{ "destination": "https://example.com/b", "queue": "orders" }
]'
[
{ "messageId": "f0f8370a-…", "queue": "default", "deduplicated": false },
{ "error": "unknown queue: \"orders\"" }
]
Every entry is validated before any is queued, so a malformed destination or
option anywhere answers 400 naming the entry's index, with nothing sent. Queueing
is not one transaction: an entry whose queue is unknown or disabled gets an error
result and the others still go. Check each result. The whole request is held to the
same 1 MB as a single publish.
URL groups
A URL group is a named set of endpoints. Publishing to the group's name, with no URL after it, delivers one copy to each endpoint:
curl -X POST "$CHRONQ_URL/v2/publish/order-events" \
-H "Authorization: Bearer $CHRONQ_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"orderId":42}'
[
{ "messageId": "6c05d2b3-…", "url": "https://billing.example.com/hook", "queue": "default", "deduplicated": false },
{ "messageId": "c0bfd854-…", "url": "https://audit.example.com/hook", "queue": "default", "deduplicated": false }
]
Each copy is an ordinary message — its own retries, callbacks, signature (bound to
that endpoint's URL) and event log entry — so a slow or failing endpoint never holds
back the others. Every option header applies to each copy; Chronq-Queue picks the
queue they go through. A Chronq-Deduplication-Id is scoped per endpoint, so a
retried publish is suppressed for every endpoint rather than letting only the first
through. The fan-out happens at publish time: editing a group changes later
publishes, not messages already queued. An unknown group is 404, an empty one
400.
Manage groups under URL groups in the admin UI, or with QStash's API (QStash
kept the topics path when it renamed them):
| Request | Does |
|---|---|
POST /v2/topics/{name}/endpoints |
Add { "endpoints": [{ "url": "…" }] }, creating the group if needed. Adding a URL twice is a no-op. |
DELETE /v2/topics/{name}/endpoints |
Remove those endpoints. The group stays, even empty. |
GET /v2/topics / GET /v2/topics/{name} |
List groups / read one. |
DELETE /v2/topics/{name} |
Delete the group. |
Names follow the queue naming rules; a group holds at most 100 endpoints.
Responses
| Status | Body | When |
|---|---|---|
200 |
{ messageId, queue } |
Queued. |
202 |
{ messageId, queue } |
Suppressed as a duplicate; messageId is the message already queued. |
400 |
{ error } |
Bad destination, option, body encoding, or unknown/disabled queue. |
401 |
{ error } |
Missing or wrong publish token. |
409 |
{ error } |
The instance has not been set up yet. |
413 |
{ error } |
Body larger than 1 MB. |
Send Content-Type: application/json (or any non-form type). A POST with a
form content type is treated as a browser form submission and rejected by
SvelteKit's cross-site protection with 403 before it reaches the queue.
messageId is the job id in the default queue's log, and what you look up in the
admin UI's event table.
Errors name the offending option, e.g.:
chronq-method must be one of GET, POST, PUT, PATCH, DELETE, got "TRACE"
chronq-retries must be between 0 and 100
destination must be an absolute URL, got "not-a-url"
unknown queue: "webhoks"
What the destination receives
POST /hook HTTP/1.1
Host: example.com
content-type: application/json
x-api-key: shh
chronq-signature: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…
chronq-retried: 0
{"orderId":42}
chronq-signature— an HS256 JWT proving the request came from chronq.chronq-retried— how many times this message has already been retried, so0on the first attempt and1on the first retry. Idempotency keys are usually built from this plus your own message identity.
A 3xx is not followed. Following it would forward the signature to another
host and make its sub claim describe a URL the request never reached, so a
redirect counts as a failed delivery.
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 a 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.
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. Signatures live 5 minutes. |
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 the sub claim is checked too, which pins a signature to
one endpoint. Pass clockToleranceSeconds if your receiver's clock drifts.
Behaviour you can rely on
verify pins alg to HS256 before reading any claim, so alg: none and
HS256-to-HS512 substitution cannot skip the check, and the HMAC comparison is
constant-time via Web Crypto. 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 the SDK, the above is implementable by hand — but only do that
against the claim list above, and never without the alg check.
Retries
A delivery is retried when it does not answer 2xx, including when it answers
nothing at all (connection refused, DNS failure, timeout). The retry decision is
made on the HTTP status, not on the body.
attempt 1 → 503
attempt 2 → 503 after ~retryDelay × 2
attempt 3 → 200 done
The delay between attempts is pg-boss's exponential backoff: roughly
retryDelay × 2ⁿ with jitter, capped by the queue's Max delay. It is not
QStash's fixed curve (12s, 2m28s, …). Set Chronq-Retry-Delay on the publish or
Retry delay on the queue to shape it.
Once retries are exhausted the job fails and stays in the event log, and the failure callback (if any) fires.
Asking for a later retry
A failed attempt that answers with Retry-After — delay-seconds or an HTTP-date —
or X-RateLimit-Reset (a unix timestamp) is retried no sooner than that, whatever
the status:
HTTP/1.1 429
Retry-After: 120
The wait is a floor, not a replacement: with backoff on, the next attempt is at least that long, and later attempts for the same message keep the raised delay as their base. A wait of more than a day is capped at a day. A destination answering this on the last attempt changes nothing: there is no next attempt to delay.
Opting out of retries
A destination that knows the failure is permanent can stop the retries immediately:
HTTP/1.1 489
Chronq-NonRetryable-Error: true
The message fails terminally and the failure callback fires on the first attempt.
This is the right answer for a 400-class problem — an unparseable payload, a
deleted resource — where retrying can only produce the same result.
Timeouts
Chronq-Timeout bounds a single attempt. An attempt that overruns is aborted and
retried like any other failure, and the recorded response body reads as the abort
reason with status 0 (no response). The same value bounds the job's active
time, so a wedged destination cannot hold an attempt open indefinitely.
Callbacks
A callback turns a fire-and-forget delivery into a request/response the publisher can observe without waiting. chronq calls it after the destination has answered:
-H "Chronq-Callback: https://example.com/after"
The success callback fires once, on the attempt that the destination accepts — not once per attempt, which is where QStash differs. The failure callback fires once, when retries are exhausted or a non-retryable failure is signalled. Both are best-effort: an unreachable callback is logged and never retried, because the delivery itself has already succeeded or failed on its own merits.
The callback receives POST (or Chronq-Callback-Method) with:
{
"messageId": "4c0fa780-b1f5-45e2-92d1-07cc131c586a",
"url": "https://example.com/hook",
"status": 200,
"responseBody": "{\"ok\":true}",
"responseHeaders": { "content-type": "application/json" },
"responseTruncated": false,
"durationMs": 132,
"retried": 0,
"maxRetries": 3,
"labels": ["orders"]
}
Callbacks are signed exactly like deliveries, with the callback URL as sub.
Verify them with the same Receiver:
await receiver.verify({ signature, body, url: 'https://example.com/after' });
retried === maxRetries identifies an exhausted retry budget. A failure callback
can also arrive with retried < maxRetries, which means the destination opted out
with a non-retryable failure and no further attempts were made.
Response bodies are kept up to 8 KiB; longer ones are cut and flagged with
responseTruncated.
Deduplication
-H "Chronq-Deduplication-Id: order-42"
A second publish with the same id inside the 10-minute window is accepted but not
enqueued: it answers 202 with the id of the message already queued.
# First
{"messageId":"e750aa7f-…","queue":"default"} HTTP 200
# Duplicate
{"messageId":"e750aa7f-…","queue":"default"} HTTP 202
This exists for the case where your connection to chronq drops before you read the
response: retrying the publish is safe, and 202 tells you it was already there.
For automatic ids, Chronq-Content-Based-Deduplication: true hashes the
destination, the body and the forwarded headers (including content-type), so
identical content collapses without you tracking an id. Header order and header
name casing do not affect the hash. An explicit Chronq-Deduplication-Id wins if
both are supplied.
Scheduled deliveries
A cronjob calls a URL on a schedule, through a queue, using the same signed
delivery path as a publish. Manage them under Cronjobs in the admin UI, or via
packages/jobs:
await createCronjob({
name: 'nightly-report',
queueId,
expression: '0 3 * * *', // cron, or an RRULE
timezone: 'America/Chicago',
missed: 'once', // or 'skip'
url: 'https://example.com/report',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"report":"nightly"}',
timeoutMs: 30_000,
failureCallbackUrl: 'https://example.com/alerts', // or null
enabled: true
});
Expressions are validated by pg-boss, so anything the form accepts will really run.
missed: 'once' sends a single catch-up run for occurrences that came due while
nothing was running; 'skip' ignores them.
Only the cronjob id travels in the schedule payload, so editing the URL, headers or body takes effect on the next run without rescheduling.
The admin UI's Run now button delivers one run immediately through the same path, which is the quickest way to test a destination.
The schedules API
Cronjobs can also be managed over HTTP with the publish token, so a deploy script can declare its schedules instead of someone clicking them in.
curl -X POST "$CHRONQ_URL/v2/schedules" \
-H "Authorization: Bearer $CHRONQ_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "nightly-report",
"cron": "0 3 * * *",
"timezone": "America/Chicago",
"url": "https://example.com/report",
"headers": { "content-type": "application/json" },
"body": "{\"report\":\"nightly\"}",
"failureCallback": "https://example.com/alerts"
}'
| Request | Does |
|---|---|
POST /v2/schedules |
Create, or replace the schedule with that name (201 created, 200 replaced). Fields left out take their defaults, not their previous values. |
GET /v2/schedules |
Every schedule. |
GET /v2/schedules/{name} |
One schedule, with its next three occurrences. |
DELETE /v2/schedules/{name} |
Unschedule it, and remove the queue it was given if it was created for it. |
Fields: name, cron and url are required; method, headers, body,
timezone (UTC), missed (skip or once), timeoutMs (30000), queue (an
existing queue's name; omitted, the schedule gets its own), enabled (true),
description and failureCallback are optional. Validation is the admin form's,
so an expression the API accepts is one pg-boss will run.
Failure alerts
A cronjob's Failure alert URL is called once when a run fails for good — its
queue's retries are exhausted, or the destination refused it as
non-retryable. It receives the same signed JSON as a
message's failure callback, with labels: ["<cronjob name>"], so one
alert endpoint can serve every cronjob. Failed attempts that will still be retried
do not call it.
Reading the event log
Every run is recorded with its state, HTTP status, duration, attempt count and the destination's response headers and body:
- Admin UI — Events on a queue shows every delivery through it; Events on a
cronjob shows only its own runs. Click a row for the full response, and to
Retry a
failedrun (one more attempt, same message id) or Cancel one that is stillcreatedorretry. A queue's Events also filters by label (?label=orders), matching any message published with thatChronq-Label. - oRPC —
queue.jobstakes{ queue, state?, label?, limit?, offset? }and returns{ jobs, total }; each job carries itslabels.
States are pg-boss's: created, retry, active, completed, cancelled,
failed. A failed job is one whose retries ran out; a completed job is one the
destination accepted. How long history is kept is the queue's Keep completed and
Queued retention settings.
Looking up and cancelling a message
The messageId a publish returns can be looked up and, until it is delivered,
taken back. Both take the publish token.
curl "$CHRONQ_URL/v2/messages/$MESSAGE_ID" -H "Authorization: Bearer $CHRONQ_TOKEN"
{
"messageId": "9b2687b2-…",
"queue": "default",
"state": "created",
"url": "https://example.com/hook",
"labels": ["orders"],
"cronjob": null,
"createdAt": "2026-10-16T23:32:52.604Z",
"startedAt": null,
"completedAt": null,
"retried": 0,
"maxRetries": 3,
"response": null
}
response is the last attempt's outcome (status, headers, body,
durationMs, truncated) once there has been one. 404 once the queue's
retention has deleted the job.
curl -X DELETE "$CHRONQ_URL/v2/messages/$MESSAGE_ID" -H "Authorization: Bearer $CHRONQ_TOKEN"
Cancels a message that is created (queued or delayed) or retry (waiting for
its next attempt). A message that already finished answers 409 with its state,
rather than a silent success, so a publisher racing the worker learns which side
won. An attempt already in flight is not interrupted.
Running a receiver locally
packages/sdk/examples/receiver.ts is a working receiver: an HTTP server that
verifies signatures and rejects anything it cannot authenticate.
CHRONQ_CURRENT_SIGNING_KEY=<Account page> PORT=8787 node packages/sdk/examples/receiver.ts
curl -X POST "$CHRONQ_URL/v2/publish/http://127.0.0.1:8787/hook" \
-H "Authorization: Bearer $CHRONQ_TOKEN" \
-H 'Content-Type: application/json' -d '{"hello":"world"}'
Publishing to localhost only works because the chronq instance is running on the
same machine. A destination must be reachable from the chronq process.
For more than a single route, examples/http-queue-demo is a Hono receiver with
one route per behaviour — retries, permanent failures, timeouts, callbacks,
deduplication — published through a queue created in the dashboard, so the
queue's settings are what drive it.
docs/http-queue-demo.md walks through each of them with
curl.
Operations
Rotating the publish token
Account → Rotate token. Every publisher using the old token starts getting 401
immediately, so update them in the same change window.
Named tokens
Account → Named tokens creates a token per publishing service. Each works
everywhere the publish token does (/v2/publish, /v2/messages, /v2/schedules,
/metrics) and is revoked on its own, so a leaked one is fixed without touching
any other service. The list shows the last four characters and when each was last
used (to the minute), which is how to find the ones nothing uses any more.
A named token is shown once, when it is created: only a keyed hash of it is stored, so neither the page nor a database dump can give it back. Revocation applies to the next request.
Rotating signing keys
Signing uses the newest key; verification accepts the two newest. That overlap is what lets a rotation happen without dropping deliveries.
- Account → Rotate signing key.
- Give receivers the new key as
CHRONQ_CURRENT_SIGNING_KEYand the old one asCHRONQ_NEXT_SIGNING_KEY. - Once every receiver is updated, Prune old keys.
Keys are re-read at most once a minute, so a rotation takes effect without a restart.
Throughput
A worker delivers up to a queue's Concurrency messages at once (10 unless
set, 1–100), polling once a second. At 10 that measures at roughly 9–10
deliveries/second per queue with a local destination. The implicit default
queue, which has no settings row, always uses 10.
Lower it for a destination that cannot take many requests at a time: at 1, a queue delivers strictly one message after another. The next batch is fetched only when the current one has settled, so a slow destination slows only its own queue. Changing it re-registers the queue's worker straight away; deliveries already in flight finish first.
The limit is per server process. With several app instances on one database, each delivers up to that many at once.
pg-boss's own default is one job per poll every two seconds, which is 0.5 deliveries/second. Every job in a batch is settled on its own result, so a failure among them does not affect its neighbours.
Metrics
The dashboard's Last 24 hours card shows, per queue, the runs that finished in the last day, how many succeeded, and the p50/p95 duration of their last attempt.
The same numbers are a Prometheus scrape target at /metrics, behind a publish
token — create a named token for the scraper:
scrape_configs:
- job_name: chronq
metrics_path: /metrics
authorization:
credentials: chronq_… # a named token
static_configs:
- targets: ['queue.example.com']
| Series | Labels | Meaning |
|---|---|---|
chronq_jobs |
queue, state |
Jobs pg-boss holds right now. |
chronq_deliveries_24h |
queue, outcome (completed/failed) |
Runs that finished in the last 24 h. |
chronq_delivery_duration_seconds_24h |
queue, quantile (0.5/0.95) |
Last-attempt duration of those runs. |
All are gauges computed from pg-boss's job table on each scrape, so they survive restarts and agree across instances. They only cover what the queue's retention keeps: a queue that deletes completed jobs after an hour reports at most an hour.
Pausing a destination
Disable the queue (or the cronjob to stop only its schedule). Disabling a queue stops new cronjob schedules into it and rejects publishes to it; jobs already queued are unaffected.
Troubleshooting
| Symptom | Cause |
|---|---|
401 on publish |
Token missing, or rotated and not updated. |
400 unknown queue |
The queue name in the path or Chronq-Queue does not exist. Names are lowercase. |
400 queue "x" is disabled |
The queue is disabled. |
400 body must be valid UTF-8 |
Non-text body. Base64 it. |
| Delivery never arrives | No worker running for that queue — check the admin UI shows the queue, and that the app's server process is up. |
502/503 in the log with a good-looking body |
The status is what counts; the destination returned a non-2xx. |
Status 0, body is a connect error |
The destination was unreachable from the chronq process. |
400 status in the log, retried anyway |
Expected: everything non-2xx retries unless the destination answers 489 with chronq-nonretryable-error: true. |
| Signature verification fails | Body re-serialised instead of raw, wrong url passed, or a stale key. |
| Callback rejected by the receiver | Callbacks are signed too — verify them against the callback URL. |
403 Cross-site POST form submissions are forbidden |
The publish request had a form content type. Send Content-Type: application/json. |
413 |
Body over 1 MB. |
| A queue's retry settings appear ignored | A header was sent, and headers win over the queue. |
| Deliveries feel slow | The per-queue batch size. See Throughput. |
Deviations from QStash
Worth knowing before porting something over:
- Backoff curve. pg-boss's
retryDelay × 2ⁿwith jitter, not QStash's12s, 2m28s, 30m8s, 6h7m6s, 24htable. Nopow(2, retried) * 1000expression language:Chronq-Retry-Delayis a duration only. Retry-Afterraises the retry delay rather than scheduling one attempt at an exact time: later attempts of the same message keep the raised delay as their base. See Asking for a later retry.- Schedules take JSON, not QStash's
POST /v2/schedules/{destination}withUpstash-Cronheaders, and are identified by name rather than a generatedscheduleId. - Header prefix is
Chronq-, and the signature header ischronq-signaturewithiss: chronq. The official QStash SDK will not verify our requests. - Binary bodies are rejected rather than passed through.
- Flow control is a per-queue concurrency setting, not QStash's
Upstash-Flow-Control-Keywith rate and parallelism per key. - URL groups have no per-endpoint
name, and/v2/batchentries take a URL, not a group name. - Removed. Flow control keys/values and the DLQ list API. A queue's
dead letter queuesetting still moves exhausted jobs into another queue, which the event log shows.