QStash
Signed HTTP delivery from Upstash, to a stable public URL.
import { createServer } from 'experimental-a2/server'
import { qstash } from 'experimental-a2/scheduler-qstash'
import { redis } from 'experimental-a2/store-redis'
import { orders } from '@/contracts'
export const scheduler = qstash()
export const ordersServer = createServer({
contract: orders,
store: redis({ url: process.env.REDIS_URL }),
scheduler,
handlers: {
/* ... */
},
})import { ordersServer, scheduler } from '@/server'
export const POST = scheduler.handler(ordersServer)npm install @upstash/qstash
Set QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY, and
QSTASH_NEXT_SIGNING_KEY. In development you need none of them: A2 starts
QStash's local server on the first publish. It needs @upstash/qstash 2.11.0
or newer.
The route checks Upstash-Signature before it touches the store and answers
unsigned requests with 401. Delivery is at least once, so handler side effects
need idempotency keys.
Callback URL
QStash calls /api/a2/scheduler. An explicit url always wins; otherwise A2
infers it:
- Vercel production:
https://${VERCEL_PROJECT_PRODUCTION_URL}/api/a2/scheduler. - Vercel preview:
https://${VERCEL_BRANCH_URL}/api/a2/scheduler. - Locally:
http://localhost:${PORT ?? 3000}/api/a2/scheduler, against QStash's development server.PORTmust be an integer from 1 through 65535.
Pass url for anything else: production outside Vercel, a custom path or
host, Docker networking, or a runtime without process.env. With an explicit
url outside production, A2 doesn't start the development server: set
QSTASH_DEV, or QSTASH_URL and credentials, yourself. An Edge runtime infers
the local URL but must also start the development server itself.
Options
| Option | Default | |
|---|---|---|
url | inferred, see above | the callback URL |
token | QSTASH_TOKEN | |
currentSigningKey | QSTASH_CURRENT_SIGNING_KEY | |
nextSigningKey | QSTASH_NEXT_SIGNING_KEY | |
headers | none | static headers on every callback |
client | a lazy @upstash/qstash client | your own configured client |
retries | QStash's default | retries per delivery; every attempt is billed |
maxBodyBytes | 1_048_576 (1 MiB) | the largest task A2 sends or accepts |
transport, clock | the @upstash/qstash client and receiver, the system clock | test seams for custom delivery |
headersare checked and copied whenqstash()runs. A2 asks QStash to redact every value from its dashboard and API, but QStash still stores and delivers them. Names are case-insensitive.Upstash-*,Content-Type, HTTP framing headers, and A2's Vercel identity headers are reserved.clientis thepublishJSON(...)part of a configuredClient, for its endpoint, retry policy, or telemetry. It can't be combined withtokenortransport. It receives callback credentials unredacted and must apply each publish's redaction. Put callback credentials inheaders, not on the client.maxBodyBytes: A2 reserves room for a task's retry metadata before the first publish, so an accepted task never outgrows the limit. Raise it for a larger paid-plan limit, and keep one value on every deployment that receives the URL.transportimplementspublish(request)andverify(request). It must keep the body, deliver every header, and apply every redaction.
How delivery works
- The route returns 200 once a task is done, or once its follow-up is queued, so each retry gets a fresh message and retry budget. That includes a drain that fails after queueing its follow-up.
- If the store is down (
STORE_UNAVAILABLE) or not set up yet (STORE_NOT_CONFIGURED, such as Postgres before its tables exist) before a drain queues its follow-up, or during an append or clear task, the task moves to a new message: 30 seconds later, four times longer each time, up to one day. - A signed but malformed task, or one over
maxBodyBytes, returns 489 withUpstash-NonRetryable-Error: true. Unknown contracts, missing configuration, and other failures return an error, so QStash retries. - If QStash can't reach the route at all, nothing queues a follow-up: watch QStash's dead-letter queue and redrive from it.
- Delays are capped by your plan: 7 days on free plans and 1 year on usage-based plans, with no fixed cap on fixed plans. Leave a second of margin at the cap.
Protected previews
For an inferred preview URL, each publish forwards the invocation's Vercel
OIDC token as x-vercel-trusted-oidc-idp-token, redacted, so a Vercel Trusted
Sources rule can let the first delivery through. This is best effort: a
missing or expiring token falls back to a plain publish, and retries reuse the
stored token, which may have expired. When every protected delivery must get
through, pass an automation bypass in headers instead. Signature checks still
apply after Vercel's.
Deployments
Inferred production and preview URLs are moving aliases, so a pending task can
reach a later deployment. Keep tasks and event schemas compatible across
deployments, and keep the same maxBodyBytes on all of them: a lower value
rejects tasks that an earlier deployment accepted.