---
title: QStash
description: Signed HTTP delivery from Upstash, to a stable public URL.
---

```ts 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: {
    /* ... */
  },
})
```

```ts app/api/a2/scheduler/route.ts
import { ordersServer, scheduler } from '@/server'

export const POST = scheduler.handler(ordersServer)
```

```package-install
npm i @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

| 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 |

- **`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.
