Back to skills
SkillHub ClubShip Full StackFull Stack

qstash-js

Work with the QStash JavaScript/TypeScript SDK for serverless messaging, scheduling. Use when publishing messages to HTTP endpoints, creating schedules, managing queues, verifying incoming messages in serverless environments.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
257
Hot score
98
Updated
March 20, 2026
Overall rating
C3.2
Composite score
3.2
Best-practice grade
B75.6

Install command

npx @skill-hub/cli install upstash-qstash-js-skills

Repository

upstash/qstash-js

Skill path: skills

Work with the QStash JavaScript/TypeScript SDK for serverless messaging, scheduling. Use when publishing messages to HTTP endpoints, creating schedules, managing queues, verifying incoming messages in serverless environments.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: upstash.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install qstash-js into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/upstash/qstash-js before adding qstash-js to shared team environments
  • Use qstash-js for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: qstash-js
description: Work with the QStash JavaScript/TypeScript SDK for serverless messaging, scheduling. Use when publishing messages to HTTP endpoints, creating schedules, managing queues, verifying incoming messages in serverless environments.
---

# QStash JavaScript SDK

QStash is an HTTP-based messaging and scheduling solution for serverless and edge runtimes. This skill helps you use the QStash JS SDK effectively.

## When to use this skill

Use this skill when:

- Publishing HTTP messages to endpoints or URL groups
- Creating scheduled or delayed message delivery
- Managing FIFO queues with configurable parallelism
- Verifying incoming webhook signatures from QStash
- Implementing callbacks, DLQ handling, or message deduplication

## Quick Start

### Installing the SDK

```bash
npm install @upstash/qstash
```

### Basic Publishing

```typescript
import { Client } from "@upstash/qstash";

const client = new Client({
  token: process.env.QSTASH_TOKEN!,
});

const result = await client.publishJSON({
  url: "https://my-api.example.com/webhook",
  body: { event: "user.created", userId: "123" },
});
```

## Core Concepts

For fundamental QStash operations, see:

- [Publishing Messages](fundamentals/publishing-messages.md)
- [Schedules](fundamentals/schedules.md)
- [Queues and Flow Control](fundamentals/queues-and-flow-control.md)
- [URL Groups](fundamentals/url-groups.md)

For verifying incoming messages:

- [Receiver Verification](verification/receiver.md) - Core signature verification with the Receiver class
- Platform-Specific Verifiers:
  - [Next.js](verification/platform-specific/nextjs.md) - App Router, Pages Router, and Edge Runtime

For advanced features:

- [Callbacks](advanced/callbacks.md)
- [Dead Letter Queue (DLQ)](advanced/dlq.md)
- [Message Deduplication](advanced/deduplication.md)
- [Region migration & multi-region support](advanced/multi-region.md)
  - If needed, [multi-region env variable setup verification script](advanced/multi-region/verify-multi-region-setup.ts). Can be run without arguments

## Platform Support

QStash JS SDK works across various platforms:

- Next.js (App Router and Pages Router)
- Cloudflare Workers
- Deno
- Node.js (v18+)
- Vercel Edge Runtime
- SvelteKit, Nuxt, SolidJS, and other frameworks

> **Note on Workflow SDK:** For building complex durable workflows that chain multiple QStash messages together, consider using the separate QStash Workflow SDK (`@upstash/workflow`). The Workflow SDK empowers you to orchestrate multi-step processes with automatic state management, retries, and fault tolerance. This Skills file focuses on the core QStash messaging SDK.

## Best Practices

- Always verify incoming QStash messages using the Receiver class
- Use environment variables for tokens and signing keys
- Set appropriate retry counts and timeouts for your use case
- Use queues for ordered processing with controlled parallelism
- Implement DLQ handling for failed message recovery


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### fundamentals/publishing-messages.md

```markdown
# Publishing Messages

Publish HTTP messages to destinations using the QStash SDK. Messages are delivered asynchronously with built-in retries and monitoring.

## Basic Publishing

### publishJSON()

```typescript
import { Client } from "@upstash/qstash";

const client = new Client({ token: process.env.QSTASH_TOKEN! });

const result = await client.publishJSON({
  url: "https://api.example.com/webhook",
  body: { userId: "123", event: "order.completed" },
});

console.log(result.messageId); // "msg_123..."
```

## Destination Options

Specify where to send the message using one of these mutually exclusive options:

```typescript
await client.publishJSON({
  // Send to a single HTTP endpoint:
  url: "https://api.example.com/webhook",
  // Send to all endpoints in a URL group. Creates one message per endpoint:
  // Learn more in [URL Groups](url-groups.md).
  urlGroup: "my-api-group",
  // ...
});
```

## Message Options

```typescript
// JSON object
await client.publishJSON({
  url: "https://api.example.com/webhook",
  // The message payload
  body: { order_id: "123", items: [1, 2, 3] },
  // Send to a FIFO queue for ordered processing:
  // Learn more in [Queues and Flow Control](queues-and-flow-control.md).
  queueName: "my-fifo-queue",
  // Send with a flow control key to limit rate/parallelism:
  flowControl: {
    key: "user-123",
    parallelism: 2,
    rate: 10,
    period: 60,
  },
  // request headers
  headers: {
    "Content-Type": "application/json",
    "X-Custom-Header": "value",
    Authorization: "Bearer token", // auth token for the destination
  },
  // request method
  method: "PUT",
  // Delay message delivery by a duration in seconds:
  delay: 60,
  // alternative of delay, deliver at a specific Unix timestamp in seconds:
  notBefore: 1700000000,
  // retries
  retries: 10,
  // Customize the delay between retries using a mathematical expression.
  // Default is exponential backoff.
  // Supported functions: `pow`, `sqrt`, `abs`, `exp`, `floor`, `ceil`, `round`, `min`, `max`.
  retryDelay: "5000",
  // Maximum duration for the HTTP request in seconds:
  timeout: 15,
  // URL called if the message is successfully delivered:
  callback: "https://api.example.com/qstash-callback",
  // URL called only when all retries are exhausted:
  failureCallback: "https://api.example.com/failure-handler",
  // id for deduplicating messages
  deduplicationId: "custom-id-123",
  // enable content-based deduplication
  contentBasedDeduplication: true,
  // label for filtering logs, dlq, cancellation
  label: "order-webhook",
});
```

## Batch Publishing

Publish multiple messages in a single request:

```typescript
const results = await client.batchJSON([
  {
    url: "https://api.example.com/webhook-1",
    body: { event: "first" },
  },
  {
    url: "https://api.example.com/webhook-2",
    body: { event: "second" },
    delay: 60,
  },
  {
    urlGroup: "my-group",
    body: { event: "third" },
  },
]);
```

Each message in the batch can have different options.

## Response Types

### Single URL

When publishing to a `url`, response contains:

```typescript
{
  messageId: string;    // Unique message identifier
  url: string;          // Destination URL
  deduplicated?: boolean; // true if message was deduplicated
}
```

### URL Group

When publishing to a `urlGroup`, response contains an array:

```typescript
[
  {
    messageId: string;
    url: string;          // First endpoint URL
    deduplicated?: boolean;
  },
  {
    messageId: string;
    url: string;          // Second endpoint URL
    deduplicated?: boolean;
  },
  // ... one per endpoint
]
```

```

### fundamentals/schedules.md

```markdown
# Schedules

Schedule recurring messages using cron expressions.

## Creating Schedules

```typescript
import { Client } from "@upstash/qstash";

const client = new Client({ token: process.env.QSTASH_TOKEN! });

const result = await client.schedules().create({
  destination: "https://api.example.com/daily-report",
  cron: "0 9 * * *", // Daily at 9 AM UTC
  body: JSON.stringify({ report: "daily" }),
  headers: { "Content-Type": "application/json" },
});

console.log(result.scheduleId);
```

- `destination`: URL or URL group name
- `cron`: Cron expression (required)
- `body`: Message payload
- All publish options supported (retries, timeout, callback, etc.)

## Common Cron Patterns

```
0 * * * *        Every hour
0 9 * * *        Daily at 9 AM UTC
0 9 * * 1        Weekly on Monday at 9 AM
0 9 1 * *        Monthly on 1st at 9 AM
*/15 * * * *     Every 15 minutes
0 9-17 * * 1-5   Weekdays, 9 AM to 5 PM, hourly
0 0 1 1 *        Annually on January 1st
```

Format: `minute hour day month weekday`

- Minute: 0-59
- Hour: 0-23 (UTC)
- Day: 1-31
- Month: 1-12
- Weekday: 0-6 (Sunday=0)

## Managing Schedules

### List All Schedules

```typescript
const schedules = await client.schedules().list();

schedules.forEach((s) => {
  console.log(`${s.scheduleId}: ${s.cron} -> ${s.destination}`);
  console.log(`  Next run: ${new Date(s.nextScheduleTime!)}`);
  console.log(`  Paused: ${s.isPaused}`);
});
```

### Get Schedule Details

```typescript
const schedule = await client.schedules().get("scd_123...");

console.log(schedule.cron);
console.log(schedule.destination);
console.log(schedule.retries);
console.log(schedule.body); // Base64 encoded
```

### Delete Schedule

```typescript
await client.schedules().delete("scd_123...");
```

### Pause and Resume

```typescript
// Pause - stops scheduling new messages
await client.schedules().pause({ scheduleId: "scd_123..." });

// Resume - restarts scheduling
await client.schedules().resume({ scheduleId: "scd_123..." });
```

In-flight messages continue when paused.

## Schedule with Options

```typescript
await client.schedules().create({
  destination: "https://api.example.com/cleanup",
  cron: "0 2 * * *", // Daily at 2 AM
  body: JSON.stringify({ task: "cleanup" }),
  headers: { "Content-Type": "application/json" },
  retries: 3,
  timeout: 120,
  callback: "https://api.example.com/schedule-callback",
  failureCallback: "https://api.example.com/schedule-failure",
  label: "nightly-cleanup",
});
```

## Schedule to URL Group

```typescript
await client.schedules().create({
  destination: "status-checkers", // URL group name
  cron: "*/5 * * * *", // Every 5 minutes
  body: JSON.stringify({ check: "health" }),
});
```

Schedule creates one message per endpoint in the group on each trigger.

## Schedule to Queue

```typescript
await client.schedules().create({
  destination: "https://api.example.com/process",
  cron: "0 * * * *",
  queueName: "hourly-tasks",
  body: JSON.stringify({ task: "process" }),
});
```

Messages are enqueued for ordered FIFO delivery. See [Queues](queues-and-flow-control.md).

## Updating Schedules

To update, provide the existing `scheduleId`:

```typescript
await client.schedules().create({
  scheduleId: "scd_123...", // Existing schedule ID
  destination: "https://api.example.com/updated-endpoint",
  cron: "0 10 * * *", // New time
  body: JSON.stringify({ updated: true }),
});
```

All fields are replaced with new values.

## Deduplication

Prevent duplicate schedules:

```typescript
await client.schedules().create({
  destination: "https://api.example.com/daily",
  cron: "0 9 * * *",
  deduplicationId: "daily-report-schedule",
  body: JSON.stringify({ report: "daily" }),
});
```

Deduplication happens before schedule creation. See [Deduplication](../advanced/deduplication.md).

## Schedule Tracking

Schedule metadata includes execution history:

```typescript
const schedule = await client.schedules().get("scd_123...");

console.log(schedule.lastScheduleTime); // Last execution time
console.log(schedule.nextScheduleTime); // Next execution time
console.log(schedule.lastScheduleStates); // Recent message states
```

`lastScheduleStates` maps message IDs to states:

- `IN_PROGRESS`: Currently delivering
- `SUCCESS`: Successfully delivered
- `FAIL`: Failed after retries

```

### fundamentals/queues-and-flow-control.md

```markdown
# Queues and Flow Control

## Queues

Queues provide FIFO (First-In-First-Out) ordered message delivery with configurable parallelism.

### Basic Queue Usage

```typescript
import { Client } from "@upstash/qstash";

const client = new Client({ token: process.env.QSTASH_TOKEN! });

// Enqueue a message
await client.queue({ queueName: "orders" }).enqueueJSON({
  url: "https://api.example.com/process-order",
  body: { orderId: "123", items: ["item1", "item2"] },
});
```

Messages are delivered in order, one at a time by default.

### Ordering Guarantees

- Messages delivered in FIFO order
- Next message waits for current message to be delivered or fail
- Next message waits for callbacks to complete
- Retries don't break ordering (next waits for all retries)

### Configuring Parallelism

Control how many messages process concurrently:

```typescript
// Create or update queue with parallelism
await client.queue({ queueName: "orders" }).upsert({
  parallelism: 5, // Process up to 5 messages concurrently
});

// Then enqueue messages
await client.queue({ queueName: "orders" }).enqueueJSON({
  url: "https://api.example.com/process",
  body: { task: "data" },
});
```

**Note:** Queue parallelism is being deprecated. Use Flow Control for rate limiting (see below).

### Managing Queues

```typescript
// Get queue details
const queue = await client.queue({ queueName: "orders" }).get();
console.log(queue.parallelism);
console.log(queue.lag); // Messages waiting
console.log(queue.paused);

// List all queues
const queues = await client.queue().list();

// Pause queue (stops processing new messages)
await client.queue({ queueName: "orders" }).pause();

// Resume queue
await client.queue({ queueName: "orders" }).resume();

// Delete queue
await client.queue({ queueName: "orders" }).delete();
```

### All Message Options Work

```typescript
await client.queue({ queueName: "orders" }).enqueueJSON({
  url: "https://api.example.com/process",
  body: { order: "data" },
  delay: 60,
  retries: 5,
  timeout: 30,
  callback: "https://api.example.com/callback",
  deduplicationId: "order-123",
});
```

See [Publishing Messages](publishing-messages.md) for all options.

## Flow Control

Control message processing rate and concurrency without queues. More flexible than queue parallelism.

### Basic Flow Control

```typescript
await client.publishJSON({
  url: "https://api.example.com/webhook",
  body: { userId: "user-123", event: "action" },
  flowControl: {
    key: "user-123", // Group messages by key
    parallelism: 2, // Max 2 concurrent requests for this key
    rate: 10, // Max 10 requests
    period: 60, // Per 60 seconds
  },
});
```

### Flow Control Options

**key** (required): Groups messages for rate limiting

- Example: `user-${userId}`, `api-${service}`, `tenant-${tenantId}`

**parallelism** (optional): Max concurrent active requests with same key

- Example: `parallelism: 3` = max 3 requests in-flight

**rate** (optional): Max requests to activate within period

- Example: `rate: 100` with `period: 60` = 100 requests per minute

**period** (optional): Time window for rate limit in seconds or duration string

- Default: `1` (1 second)
- Examples: `60`, `"1m"`, `"5s"`, `"1h"`

## Queues vs Flow Control

**Use Queues when:**

- Need strict FIFO ordering
- Messages must process sequentially
- Single destination with controlled throughput

**Use Flow Control when:**

- Rate limiting by user, tenant, or other key
- Need flexible concurrency control
- No strict ordering required
- Works with any publish/schedule operation

## Best Practices

- Use descriptive queue names: `order-processing`, `email-sending`
- Use descriptive flow control keys: `user-${id}`, `tenant-${id}`
- Start with low parallelism and increase based on capacity
- Monitor queue lag to detect processing bottlenecks
- Use Flow Control for multi-tenant rate limiting
- Pause queues during maintenance, not deletion
- Use deduplication to prevent duplicate enqueues

```

### fundamentals/url-groups.md

```markdown
# URL Groups

URL Groups (also called Topics) let you publish a single message to multiple endpoints simultaneously.

## Creating URL Groups

Add endpoints to create or update a URL group:

```typescript
import { Client } from "@upstash/qstash";

const client = new Client({ token: process.env.QSTASH_TOKEN! });

await client.urlGroups().addEndpoints({
  name: "payment-webhooks",
  endpoints: [
    { url: "https://api1.example.com/webhook" },
    { url: "https://api2.example.com/webhook" },
    { url: "https://api3.example.com/webhook", name: "primary" },
  ],
});
```

- `name`: URL group identifier (alphanumeric, hyphens, underscores, periods)
- `url`: Endpoint URL (required)
- `name`: Optional endpoint name for identification

## Publishing to URL Groups

Publish once, deliver to all endpoints:

```typescript
const result = await client.publishJSON({
  urlGroup: "payment-webhooks",
  body: { orderId: "123", amount: 99.99, status: "paid" },
});

// Returns array with result for each endpoint
result.forEach((r) => {
  console.log(`Sent to ${r.url}: ${r.messageId}`);
});
```

Each endpoint gets a separate message with its own retry logic and tracking.

## Managing Endpoints

### Remove Endpoints

Remove by URL or name:

```typescript
// Remove by URL
await client.urlGroups().removeEndpoints({
  name: "payment-webhooks",
  endpoints: [{ url: "https://api2.example.com/webhook" }],
});

// Remove by endpoint name
await client.urlGroups().removeEndpoints({
  name: "payment-webhooks",
  endpoints: [{ name: "primary" }],
});
```

### List URL Groups

```typescript
const groups = await client.urlGroups().list();

groups.forEach((group) => {
  console.log(`${group.name}: ${group.endpoints.length} endpoints`);
  group.endpoints.forEach((ep) => {
    console.log(`  - ${ep.url}${ep.name ? ` (${ep.name})` : ""}`);
  });
});
```

### Get Specific URL Group

```typescript
const group = await client.urlGroups().get("payment-webhooks");

console.log(`Created: ${new Date(group.createdAt)}`);
console.log(`Updated: ${new Date(group.updatedAt)}`);
console.log(`Endpoints: ${group.endpoints.length}`);
```

### Delete URL Group

```typescript
await client.urlGroups().delete("payment-webhooks");
```

Deleting a URL group does not affect in-flight messages.

## URL Group vs Individual Publishing

**Use URL Groups when:**

- Broadcasting same message to multiple endpoints
- Need to manage endpoint list centrally
- Adding/removing endpoints dynamically
- All endpoints process the same data

**Use individual publishing when:**

- Each endpoint needs different message content
- Different retry/timeout settings per endpoint
- Endpoints have different purposes

## All Message Options Work

URL Groups support all publishing options:

```typescript
await client.publishJSON({
  urlGroup: "notifications",
  body: { event: "user.signup", userId: "123" },
  delay: 60,
  retries: 5,
  callback: "https://api.example.com/callback",
  deduplicationId: "signup-123",
});
```

See [Publishing Messages](publishing-messages.md) for all options.

```

### verification/receiver.md

```markdown
# Receiver - Message Verification

## Overview

The `Receiver` class verifies that incoming requests are genuinely from QStash by validating JWT signatures. This prevents unauthorized requests from reaching your endpoints.

## Getting Your Signing Keys

Sign in to the [Upstash Console](https://console.upstash.com/qstash) and navigate to your QStash instance to find:

- **Current Signing Key**: Active key for signature verification
- **Next Signing Key**: Key to use after rotation

Store these as environment variables:

```bash
QSTASH_CURRENT_SIGNING_KEY="your_current_key"
QSTASH_NEXT_SIGNING_KEY="your_next_key"
```

## Creating a Receiver Instance

### Basic Setup

```typescript
import { Receiver } from "@upstash/qstash";

const receiver = new Receiver({
  currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
  nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
```

### Multi-Region Mode

If you're using multi-region QStash (with `QSTASH_REGION` environment variable set), signature verification requires additional configuration. The SDK automatically detects the region from the `upstash-region` header and uses region-specific signing keys.

> **Important:** Multi-region signature verification requires careful setup. See [Multi-Region Setup](../advanced/multi-region.md) for complete details on environment variables, region detection, and verification strategies.

## Verifying Incoming Requests

### Basic Verification

```typescript
try {
  await receiver.verify({
    signature: request.headers.get("upstash-signature")!,
    body: await request.text(),
  });

  // Request is valid - process it
  return new Response("OK", { status: 200 });
} catch (error) {
  // Invalid signature
  return new Response("Unauthorized", { status: 401 });
}
```

### With URL Verification

For extra security, verify the request was sent to the correct URL:

```typescript
await receiver.verify({
  signature: request.headers.get("upstash-signature")!,
  body: await request.text(),
  url: "https://my-api.example.com/webhook",
});
```

### With Clock Tolerance

Handle minor clock differences between servers:

```typescript
await receiver.verify({
  signature: request.headers.get("upstash-signature")!,
  body: await request.text(),
  clockTolerance: 5, // Allow 5 seconds difference
});
```

## Required Headers

QStash sends these headers with every request:

- `Upstash-Signature`: JWT signature to verify

> **Note:** In multi-region mode, QStash also sends an `Upstash-Region` header. See [Multi-Region Setup](../advanced/multi-region.md) for details.

## Handling Verification Failures

### SignatureError

The `verify()` method throws `SignatureError` for invalid signatures:

```typescript
import { Receiver, SignatureError } from "@upstash/qstash";

try {
  await receiver.verify({
    signature: request.headers.get("upstash-signature")!,
    body: await request.text(),
  });
} catch (error) {
  if (error instanceof SignatureError) {
    console.error("Invalid signature:", error.message);
    return new Response("Invalid signature", { status: 401 });
  }
  throw error;
}
```

### Common Failure Reasons

1. **Missing or wrong signing keys**
   - Verify keys in Upstash Console match environment variables
2. **Body mismatch**

   - Ensure you pass the raw request body (not parsed JSON)
   - Don't modify the body before verification

3. **Expired signature**

   - QStash signatures expire after 5 minutes
   - Check server clock is synchronized
   - Use `clockTolerance` if needed

4. **URL mismatch**
   - Ensure the `url` parameter matches the destination URL
   - Include protocol, domain, and path

## Key Rotation

The Receiver supports seamless key rotation:

1. Verification tries `currentSigningKey` first
2. If that fails, tries `nextSigningKey`
3. Only throws error if both fail

To rotate keys:

1. Set new key as `QSTASH_NEXT_SIGNING_KEY`
2. Wait for all in-flight requests to complete
3. Update `QSTASH_CURRENT_SIGNING_KEY` to the new key
4. Generate a new `QSTASH_NEXT_SIGNING_KEY`

## Best Practices

### Always Verify

Never trust incoming requests without verification:

```typescript
// ❌ Don't do this
app.post("/webhook", async (req) => {
  const data = await req.json();
  processWebhook(data); // Unverified!
});

// ✅ Do this
app.post("/webhook", async (req) => {
  const body = await req.text();

  await receiver.verify({
    signature: req.headers.get("upstash-signature")!,
    body,
  });

  const data = JSON.parse(body);
  processWebhook(data);
});
```

### Use Environment Variables

Never hardcode signing keys:

```typescript
// ❌ Don't do this
const receiver = new Receiver({
  currentSigningKey: "sig_abc123...",
});

// ✅ Do this
const receiver = new Receiver({
  currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
  nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
```

### Read Body Once

Request bodies can only be read once. Save it for reuse:

```typescript
const body = await request.text();

// Verify with raw body
await receiver.verify({
  signature: request.headers.get("upstash-signature")!,
  body,
});

// Parse after verification
const data = JSON.parse(body);
```

## Platform-Specific Verification

For framework-specific implementations, see:

- [Next.js Verification](platform-specific/nextjs.md)

```

### verification/platform-specific/nextjs.md

```markdown
# Next.js Endpoint Verification

## Overview

Next.js applications can use QStash in both App Router (route handlers) and Pages Router (API routes). The SDK provides dedicated verification utilities for each.

## App Router Verification

### Using verifySignatureAppRouter

The SDK provides `verifySignatureAppRouter` for App Router route handlers:

```typescript
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";

// app/api/webhook/route.ts
export const POST = verifySignatureAppRouter(async (req) => {
  const body = await req.json();

  // Request is verified - process it
  console.log("Received verified message:", body);

  return new Response("OK", { status: 200 });
});
```

### With Custom Configuration

```typescript
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";

export const POST = verifySignatureAppRouter(
  async (req) => {
    const body = await req.json();
    return Response.json({ received: true });
  },
  {
    currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY,
    nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY,
    clockTolerance: 5, // Allow 5 seconds clock difference
  }
);
```

### Multi-Region Support

```typescript
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";

export const POST = verifySignatureAppRouter(async (req) => {
  const upstashRegion = req.headers.get("upstash-region");
  console.log("Request from region:", upstashRegion);

  const body = await req.json();
  return Response.json({ region: upstashRegion, data: body });
});
```

## Pages Router Verification

### Using verifySignature

For Pages Router API routes, use the `verifySignature` wrapper:

```typescript
import type { NextApiRequest, NextApiResponse } from "next";
import { verifySignature } from "@upstash/qstash/nextjs";

// pages/api/webhook.ts
async function handler(req: NextApiRequest, res: NextApiResponse) {
  const body = req.body;

  // Request is verified
  console.log("Received:", body);

  res.status(200).json({ success: true });
}

export default verifySignature(handler);
```

### With Configuration

```typescript
import type { NextApiRequest, NextApiResponse } from "next";
import { verifySignature } from "@upstash/qstash/nextjs";

async function handler(req: NextApiRequest, res: NextApiResponse) {
  res.status(200).json({ message: "Verified" });
}

export default verifySignature(handler, {
  currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY,
  nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY,
  clockTolerance: 5,
});
```

## Manual Verification with Receiver

For more control, use the `Receiver` class directly:

### Manual Verification

```typescript
import { Receiver } from "@upstash/qstash";
import { NextRequest, NextResponse } from "next/server";

const receiver = new Receiver({
  currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
  nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});

export async function POST(req: NextRequest) {
  const signature = req.headers.get("upstash-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 401 });
  }

  const body = await req.text();

  try {
    await receiver.verify({
      signature,
      body,
      url: req.url,
    });

    // Verified - parse and process
    const data = JSON.parse(body);
    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }
}
```

## Best Practices

### Handle Errors Gracefully

```typescript
export const POST = verifySignatureAppRouter(async (req) => {
  try {
    const body = await req.json();
    await processWebhook(body);
    return Response.json({ success: true });
  } catch (error) {
    console.error("Processing error:", error);
    return Response.json({ error: "Processing failed" }, { status: 500 });
  }
});
```

## Common Issues

### Missing Environment Variables

```typescript
if (!process.env.QSTASH_CURRENT_SIGNING_KEY) {
  throw new Error("Missing QSTASH_CURRENT_SIGNING_KEY");
}
```

## Related Resources

- [General Receiver Documentation](../receiver.md)
- [Multi-Region Setup](../../advanced/multi-region.md)
- [Next.js Documentation](https://nextjs.org/docs)
- [Vercel Deployment](https://vercel.com/docs)

```

### advanced/callbacks.md

```markdown
# Callbacks

Callbacks let you receive delivery results without waiting for the HTTP request to complete. QStash calls your callback URL with the response after delivering the message.

## Why Use Callbacks?

Serverless functions have execution time limits. Callbacks allow you to:

- Publish long-running tasks without blocking
- Receive delivery confirmation asynchronously
- Handle failures separately with failure callbacks

You can use callbacks individually or together:

```typescript
await client.publishJSON({
  url: "https://api.example.com/webhook",
  body: { order: "12345" },
  callback: "https://api.example.com/callback",
  failureCallback: "https://api.example.com/failure",
});
```

## callback

Called after each delivery attempt (success or failure):

The callback is invoked after every retry attempt until the destination returns a 2XX status or retries are exhausted. Check `retried === maxRetries` in the callback body to detect final failure.

## failureCallback

Called only when all retries are exhausted:

Use this as a serverless alternative to polling the DLQ. See [DLQ](dlq.md) for more options.

## Callback Payload

### Success Callback Body

```json
{
  "status": 200,
  "header": { "content-type": ["application/json"] },
  "body": "YmFzZTY0IGVuY29kZWQgcm9keQ==",
  "retried": 2,
  "maxRetries": 3,
  "sourceMessageId": "msg_xxx",
  "topicName": "myTopic",
  "endpointName": "myEndpoint",
  "url": "https://api.example.com/webhook",
  "method": "POST",
  "sourceHeader": { "content-type": "application/json" },
  "sourceBody": "YmFzZTY0IGVuY29kZWQgcm9keQ==",
  "notBefore": 1701198458025,
  "createdAt": 1701198447054,
  "scheduleId": "scd_xxx",
  "callerIP": "178.247.74.179"
}
```

### Failure Callback Body

```json
{
  "status": 500,
  "header": { "content-type": ["text/plain"] },
  "body": "RXJyb3IgbWVzc2FnZQ==",
  "retried": 3,
  "maxRetries": 3,
  "dlqId": "1725323658779-0",
  "sourceMessageId": "msg_xxx",
  "topicName": "myTopic",
  "endpointName": "myEndpoint",
  "url": "https://api.example.com/webhook",
  "method": "POST",
  "sourceHeader": { "content-type": "application/json" },
  "sourceBody": "YmFzZTY0IGVuY29kZWQgcm9keQ==",
  "notBefore": 1701198458025,
  "createdAt": 1701198447054,
  "scheduleId": "scd_xxx",
  "callerIP": "178.247.74.179"
}
```

### Field Descriptions

- `status` - HTTP status code from destination
- `header` - Response headers from destination
- `body` - Base64-encoded response body (may be truncated per plan limits)
- `retried` - Number of retry attempts made
- `maxRetries` - Maximum retry limit
- `dlqId` - Dead Letter Queue ID (failure callbacks only)
- `sourceMessageId` - Original message ID
- `topicName` - URL group name (if applicable)
- `endpointName` - Endpoint name within URL group (if applicable)
- `url` - Destination URL
- `method` - HTTP method used
- `sourceHeader` - Original message headers
- `sourceBody` - Base64-encoded original message body
- `notBefore` - Scheduled delivery time (Unix ms)
- `createdAt` - Message creation time (Unix ms)
- `scheduleId` - Schedule ID (if from schedule)
- `callerIP` - IP address that published the message

## Callback Configuration

Callbacks are themselves QStash messages and can be configured with the same options. Use the `Upstash-Callback-*` or `Upstash-Failure-Callback-*` header prefix:

**Not available via SDK parameters** - requires custom headers:

```typescript
await client.publish({
  url: "https://api.example.com/webhook",
  body: "data",
  callback: "https://api.example.com/callback",
  headers: {
    // Configure callback behavior
    "Upstash-Callback-Retries": "3",
    "Upstash-Callback-Timeout": "30",
    "Upstash-Callback-Method": "PUT",
    "Upstash-Callback-Delay": "60",

    // Forward custom headers to callback
    "Upstash-Callback-Forward-Authorization": "Bearer token",
    "Upstash-Callback-Forward-X-Custom": "value",

    // Configure failure callback
    "Upstash-Failure-Callback-Retries": "5",
    "Upstash-Failure-Callback-Forward-Authorization": "Bearer token",
  },
});
```

Available configuration headers:

- `Upstash-Callback-Retries` / `Upstash-Failure-Callback-Retries`
- `Upstash-Callback-Timeout` / `Upstash-Failure-Callback-Timeout`
- `Upstash-Callback-Delay` / `Upstash-Failure-Callback-Delay`
- `Upstash-Callback-Method` / `Upstash-Failure-Callback-Method`
- `Upstash-Callback-Forward-*` / `Upstash-Failure-Callback-Forward-*`

## Notes

- Callbacks are charged as regular messages
- Callbacks retry until the callback URL returns 2XX or retries are exhausted
- Response body may be truncated if it exceeds your plan's message size limit
- Both URLs must be publicly accessible

```

### advanced/dlq.md

```markdown
# Dead Letter Queue (DLQ)

Messages that fail after all retries are moved to the Dead Letter Queue for manual inspection and recovery.

## What is the DLQ?

When a message fails delivery after exhausting all retries, QStash moves it to the DLQ instead of discarding it. This lets you:

- Investigate failure reasons
- Manually retry after fixing issues
- Delete permanently failed messages
- Track patterns in failures

Common failure reasons:

- Destination endpoint errors (5XX responses)
- Timeouts
- Network issues
- Invalid responses from destination

## Listing DLQ Messages

```typescript
import { Client } from "@upstash/qstash";

const client = new Client({ token: process.env.QSTASH_TOKEN! });

const result = await client.dlq.listMessages();

console.log(`Found ${result.messages.length} failed messages`);

result.messages.forEach((msg) => {
  console.log(`Message ${msg.messageId} to ${msg.url}`);
  console.log(`Status: ${msg.responseStatus}`);
  console.log(`DLQ ID: ${msg.dlqId}`);
});
```

## Pagination

Use cursor-based pagination for large DLQ:

```typescript
let cursor: string | undefined;
const allMessages = [];

do {
  const result = await client.dlq.listMessages({
    cursor,
    count: 50, // Return up to 50 messages
  });
  allMessages.push(...result.messages);
  cursor = result.cursor;
} while (cursor);

console.log(`Total failed messages: ${allMessages.length}`);
```

## Filtering DLQ Messages

Filter by various criteria:

```typescript
const result = await client.dlq.listMessages({
  filter: {
    messageId: "msg_123...",
    url: "https://api.example.com/webhook",
    urlGroup: "payment-webhooks",
    queueName: "order-processing",
    scheduleId: "scd_123...",
    label: "payment-processing",
    responseStatus: 500,
    fromDate: oneDayAgo,
    toDate: Date.now(),
    callerIp: "192.168.1.1",
  },
});
```

## Message Details

Each DLQ message includes:

```typescript
type DlqMessage = {
  dlqId: string; // Unique DLQ identifier
  messageId: string; // Original message ID
  url: string; // Destination URL
  method?: string; // HTTP method
  header?: Record<string, string[]>; // Request headers
  body?: string; // Request body
  urlGroup?: string; // URL group name
  queueName?: string; // Queue name
  scheduleId?: string; // Schedule ID
  createdAt: number; // Creation timestamp (ms)
  notBefore?: number; // Scheduled delivery time (ms)
  label?: string; // Message label

  // Failure details
  responseStatus?: number; // HTTP status from destination
  responseHeader?: Record<string, string[]>; // Response headers
  responseBody?: string; // Response body (UTF-8)
  responseBodyBase64?: string; // Response body (base64 if non-UTF-8)
};
```

## Deleting Messages

```typescript
await client.dlq.delete("1725323658779-0");
await client.dlq.deleteMany({
  dlqIds: ["1725323658779-0", "1725323658780-1", "1725323658781-2"],
});
```

## Understanding Failures

Inspect failure details:

```typescript
const result = await client.dlq.listMessages();

for (const msg of result.messages) {
  console.log(`\nMessage ${msg.messageId}:`);
  console.log(`URL: ${msg.url}`);
  console.log(`Status: ${msg.responseStatus}`);

  if (msg.responseBody) {
    console.log(`Response: ${msg.responseBody}`);
  } else if (msg.responseBodyBase64) {
    const decoded = Buffer.from(msg.responseBodyBase64, "base64").toString();
    console.log(`Response: ${decoded}`);
  }

  if (msg.responseHeader) {
    console.log(`Headers:`, msg.responseHeader);
  }
}
```

## Using Failure Callbacks

Instead of polling the DLQ, use failure callbacks for real-time notifications:

See [Callbacks](callbacks.md) for more details.

## DLQ Retention

Messages remain in the DLQ based on your plan:

- **Free**: 7 days
- **Paid**: Check your plan on [QStash Pricing](https://upstash.com/pricing/qstash)

Messages are automatically deleted when retention expires.

## Best Practices

- Set up failure callbacks for critical messages
- Regularly monitor DLQ for patterns
- Delete non-retriable messages to keep DLQ clean
- Use labels to categorize and filter failures
- Alert on DLQ message count thresholds
- Document common failure scenarios and resolutions
- Consider automated retries for known transient issues

```

### advanced/deduplication.md

```markdown
# Message Deduplication

Prevent duplicate message delivery within a 90-day window using deduplication IDs.

## Why Deduplication?

Duplicate messages can occur when:

- User retries a failed request
- Network issues cause message resubmission
- Application logic triggers multiple publishes for the same event

Deduplication ensures QStash accepts but doesn't enqueue duplicate messages.

## Deduplication Methods

### deduplicationId

Provide a custom identifier to detect duplicates:

```typescript
import { Client } from "@upstash/qstash";

const client = new Client({ token: process.env.QSTASH_TOKEN! });

await client.publishJSON({
  url: "https://api.example.com/webhook",
  deduplicationId: `order-${orderId}-payment`,
  body: { orderId, status: "paid" },
});
```

If a message with the same `deduplicationId` was published in the last 90 days, the new message is accepted but not enqueued.

**Use cases:**

- Order processing: `order-${orderId}`
- User events: `user-${userId}-signup`
- Payment transactions: `payment-${transactionId}`

### contentBasedDeduplication

Automatically generate a deduplication ID from message content:

```typescript
await client.publishJSON({
  url: "https://api.example.com/webhook",
  contentBasedDeduplication: true,
  body: { userId: "123", event: "signup" },
});
```

The hash includes:

- All headers (except authorization)
- Request body
- Destination URL

## Deduplication Window

Deduplication IDs are stored for **90 days**. After this period, a message with the same ID can be delivered again.

## Response Handling

When a duplicate is detected, the response includes the original message ID:

```typescript
const result = await client.publishJSON({
  url: "https://api.example.com/webhook",
  deduplicationId: "order-123",
  body: { order: "data" },
});

if (result.deduplicated) {
  console.log("Duplicate detected");
  console.log("Original message ID:", result.messageId);
}
```

## Best Practices

- Use descriptive, deterministic deduplication IDs
- Include relevant context in custom IDs: `${entity}-${id}-${action}`
- Don't rely on deduplication for critical data consistency
- Monitor deduplicated messages in logs to detect issues
- Document your deduplication strategy for team reference

```

qstash-js | SkillHub