QStash

Signed HTTP delivery from Upstash, to a stable public URL.

server/orders.ts
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: {
    /* ... */
  },
})
app/api/a2/scheduler/route.ts
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. PORT must 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

OptionDefault
urlinferred, see abovethe callback URL
tokenQSTASH_TOKEN
currentSigningKeyQSTASH_CURRENT_SIGNING_KEY
nextSigningKeyQSTASH_NEXT_SIGNING_KEY
headersnonestatic headers on every callback
clienta lazy @upstash/qstash clientyour own configured client
retriesQStash's defaultretries per delivery; every attempt is billed
maxBodyBytes1_048_576 (1 MiB)the largest task A2 sends or accepts
transport, clockthe @upstash/qstash client and receiver, the system clocktest seams for custom delivery
  • headers are checked and copied when qstash() 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.
  • client is the publishJSON(...) part of a configured Client, for its endpoint, retry policy, or telemetry. It can't be combined with token or transport. It receives callback credentials unredacted and must apply each publish's redaction. Put callback credentials in headers, 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.
  • transport implements publish(request) and verify(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 with Upstash-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.