
Qstash Js
- 62 installs
- 267 repo stars
- Updated July 22, 2026
- upstash/qstash-js
Helps with ai & agent building tasks during AI-assisted development.
About
qstash-js is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- qstash-js
- AI & Agent Building
- AI-coding skill
Qstash Js by the numbers
- 62 all-time installs (skills.sh)
- Ranked #6,163 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/upstash/qstash-js --skill qstash-jsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 62 |
|---|---|
| repo stars | ★ 267 |
| Last updated | July 22, 2026 |
| Repository | upstash/qstash-js ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
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
npm install @upstash/qstashBasic Publishing
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
- Schedules
- Queues and Flow Control
- URL Groups
For verifying incoming messages:
- Receiver Verification - Core signature verification with the Receiver class
- Platform-Specific Verifiers:
- Next.js - App Router, Pages Router, and Edge Runtime
For advanced features:
- Callbacks
- Dead Letter Queue (DLQ)
- Message Deduplication
- Region migration & multi-region support
- If needed, multi-region env variable setup verification script. 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
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:
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 for more options.
Callback Payload
Success Callback Body
{
"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
{
"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 destinationheader- Response headers from destinationbody- Base64-encoded response body (may be truncated per plan limits)retried- Number of retry attempts mademaxRetries- Maximum retry limitdlqId- Dead Letter Queue ID (failure callbacks only)sourceMessageId- Original message IDtopicName- URL group name (if applicable)endpointName- Endpoint name within URL group (if applicable)url- Destination URLmethod- HTTP method usedsourceHeader- Original message headerssourceBody- Base64-encoded original message bodynotBefore- 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:
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-RetriesUpstash-Callback-Timeout/Upstash-Failure-Callback-TimeoutUpstash-Callback-Delay/Upstash-Failure-Callback-DelayUpstash-Callback-Method/Upstash-Failure-Callback-MethodUpstash-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
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:
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:
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:
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
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
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:
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:
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:
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
await client.dlq.delete("1725323658779-0");
await client.dlq.deleteMany({
dlqIds: ["1725323658779-0", "1725323658780-1", "1725323658781-2"],
});Understanding Failures
Inspect failure details:
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 for more details.
DLQ Retention
Messages remain in the DLQ based on your plan:
- Free: 7 days
- Paid: Check your plan on QStash Pricing
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
Multi-Region Setup
Overview
QStash supports multi-region deployments across EU (EU_CENTRAL_1) and US (US_EAST_1) regions.
Requirements
Multi-region support requires minimum SDK versions:
@upstash/qstash>= 2.9.0@upstash/workflow>= 1.1.0 (if using workflows)
Update your dependencies:
npm install @upstash/qstash@latest
# or if using workflows
npm install @upstash/qstash@latest @upstash/workflow@latestWhen to Use Multi-Region
Consider multi-region QStash when:
- You're migrating from one region to another
Understanding Multi-Region Mode
Multi-region mode is activated by setting the QSTASH_REGION environment variable to your primary region (EU_CENTRAL_1 or US_EAST_1). When active:
- Outgoing messages use region-specific credentials
- Incoming messages are verified using region-specific signing keys
- The SDK automatically handles region detection
Environment Variable Setup
Single-Region Setup (Default)
For single-region deployments (EU only):
# Outgoing messages
QSTASH_TOKEN="your_token"
# Incoming message verification (optional)
QSTASH_CURRENT_SIGNING_KEY="your_current_key"
QSTASH_NEXT_SIGNING_KEY="your_next_key"Optionally specify a custom URL:
QSTASH_URL="https://qstash.upstash.io" # EU region (default)
QSTASH_TOKEN="your_token"
QSTASH_CURRENT_SIGNING_KEY="your_current_key"
QSTASH_NEXT_SIGNING_KEY="your_next_key"Multi-Region Setup
For multi-region deployments with US as primary:
# Enable multi-region mode with US as primary
QSTASH_REGION="US_EAST_1"
# Outgoing messages - US region (primary)
US_EAST_1_QSTASH_URL="https://qstash-us-east-1.upstash.io"
US_EAST_1_QSTASH_TOKEN="your_us_token"
# Outgoing messages - EU region (only needed for Upstash Workflow)
EU_CENTRAL_1_QSTASH_URL="https://qstash.upstash.io"
EU_CENTRAL_1_QSTASH_TOKEN="your_eu_token"
# (Optional) Incoming message verification - US region
US_EAST_1_QSTASH_CURRENT_SIGNING_KEY="your_us_current_key"
US_EAST_1_QSTASH_NEXT_SIGNING_KEY="your_us_next_key"
# (Optional) Incoming message verification - EU region
EU_CENTRAL_1_QSTASH_CURRENT_SIGNING_KEY="your_eu_current_key"
EU_CENTRAL_1_QSTASH_NEXT_SIGNING_KEY="your_eu_next_key"For multi-region with EU as primary, set QSTASH_REGION="EU_CENTRAL_1".
Getting Region-Specific Credentials
Sign in to the Upstash Console to find:
- US Region:
https://qstash-us-east-1.upstash.io - EU Region:
https://qstash.upstash.io
Each region has its own:
- API token for outgoing requests
- Signing keys for incoming request verification
You can get all envrionment variables required for multi region setup using the Migrate button in the region list page.
How Outgoing Messages Work
Single-Region Mode
When QSTASH_REGION is not set:
1. SDK reads QSTASH_URL and QSTASH_TOKEN 2. If QSTASH_URL is not set, defaults to EU region 3. All messages are published through this region
import { Client } from "@upstash/qstash";
// Uses QSTASH_TOKEN and QSTASH_URL (or EU default)
const client = new Client({
token: process.env.QSTASH_TOKEN!,
});
await client.publishJSON({
url: "https://my-api.com/webhook",
body: { message: "hello" },
});Multi-Region Mode
When QSTASH_REGION is set to a valid region:
1. SDK reads region-specific credentials (e.g., US_EAST_1_QSTASH_URL) 2. All messages are published through the specified primary region 3. If region-specific credentials are missing, falls back to default credentials with a warning
import { Client } from "@upstash/qstash";
// Automatically uses US_EAST_1_QSTASH_TOKEN and US_EAST_1_QSTASH_URL
// based on QSTASH_REGION="US_EAST_1"
const client = new Client();
await client.publishJSON({
url: "https://my-api.com/webhook",
body: { message: "hello" },
});Credential Resolution Priority
The SDK resolves credentials in this order:
1. Config overrides: Explicitly passed token and baseUrl 2. Region-specific: Based on QSTASH_REGION (e.g., US_EAST_1_QSTASH_TOKEN) 3. Default credentials: QSTASH_TOKEN and QSTASH_URL 4. Default URL: https://qstash.upstash.io (EU) with token from environment
// Override with explicit credentials
const client = new Client({
token: "custom_token",
baseUrl: "https://qstash-us-east-1.upstash.io",
});How Incoming Messages Work
Understanding the Region Header
QStash includes an upstash-region header with every request indicating the source region:
upstash-region: US-EAST-1The SDK uses this header to determine which signing keys to use for verification.
Single-Region Verification
In single-region mode, the SDK uses default signing keys:
import { Receiver } from "@upstash/qstash";
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
await receiver.verify({
signature: request.headers.get("upstash-signature")!,
body: await request.text(),
});Multi-Region Verification
In multi-region mode, the SDK:
1. Checks the upstash-region header from the request 2. Normalizes it (converts US-EAST-1 → US_EAST_1) 3. Looks for region-specific signing keys (e.g., US_EAST_1_QSTASH_CURRENT_SIGNING_KEY) 4. Falls back to default keys if region-specific keys are missing
import { Receiver } from "@upstash/qstash";
// Auto-detects region from QSTASH_REGION environment
const receiver = new Receiver();
await receiver.verify({
signature: request.headers.get("upstash-signature")!,
body: await request.text(),
upstashRegion: request.headers.get("upstash-region") ?? undefined,
});Signing Key Resolution Priority
The SDK resolves signing keys in this order:
1. Config overrides: Explicitly passed signing keys 2. Region-specific: Based on upstash-region header (e.g., US_EAST_1_QSTASH_CURRENT_SIGNING_KEY) 3. Default keys: QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY
Platform-Specific Verification
On most platforms, verifiers automatically handle multi-region verification.
// Next.js App Router
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
export const POST = verifySignatureAppRouter(async (req) => {
// Automatically handles multi-region verification
const body = await req.json();
return Response.json({ success: true });
});In cloudflare workers, it's not possible right now but it will be supported in the future.
Migration from Single to Multi-Region
Step-by-Step Migration
Step 1: Add Multi-Region Credentials
Keep your existing credentials and add region-specific ones:
# Existing (keep these)
QSTASH_TOKEN="your_eu_token"
QSTASH_CURRENT_SIGNING_KEY="your_eu_current_key"
QSTASH_NEXT_SIGNING_KEY="your_eu_next_key"
# New multi-region credentials
QSTASH_REGION="US_EAST_1" # Set your primary region
US_EAST_1_QSTASH_URL="https://qstash-us-east-1.upstash.io"
US_EAST_1_QSTASH_TOKEN="your_us_token"
US_EAST_1_QSTASH_CURRENT_SIGNING_KEY="your_us_current_key"
US_EAST_1_QSTASH_NEXT_SIGNING_KEY="your_us_next_key"
EU_CENTRAL_1_QSTASH_URL="https://qstash.upstash.io"
EU_CENTRAL_1_QSTASH_TOKEN="your_eu_token" # Can reuse existing
EU_CENTRAL_1_QSTASH_CURRENT_SIGNING_KEY="your_eu_current_key"
EU_CENTRAL_1_QSTASH_NEXT_SIGNING_KEY="your_eu_next_key"Step 2: Update Verification Code
Add region header to verification calls:
// Before (single-region)
await receiver.verify({
signature: request.headers.get("upstash-signature")!,
body: await request.text(),
});
// After (multi-region ready)
await receiver.verify({
signature: request.headers.get("upstash-signature")!,
body: await request.text(),
upstashRegion: request.headers.get("upstash-region") ?? undefined,
});Step 3: Verify Setup
Use the verification script to confirm your env variable setup:
npx tsx skills/advanced/multi-region/verify-multi-region-setup.tsTroubleshooting
Common Issues
"No signing keys available for verification"
Cause: Neither default nor region-specific signing keys are found.
Solution: Verify environment variables are set:
# Single-region
echo $QSTASH_CURRENT_SIGNING_KEY
echo $QSTASH_NEXT_SIGNING_KEY
# Multi-region
echo $QSTASH_REGION
echo $US_EAST_1_QSTASH_CURRENT_SIGNING_KEY
echo $US_EAST_1_QSTASH_NEXT_SIGNING_KEYBest Practices
Always Pass Region Header
When verifying in multi-region mode, always pass the upstash-region header:
// ✅ Good - region-aware verification
await receiver.verify({
signature: request.headers.get("upstash-signature")!,
body: await request.text(),
upstashRegion: request.headers.get("upstash-region") ?? undefined,
});
// ⚠️ Works but may use wrong keys in multi-region
await receiver.verify({
signature: request.headers.get("upstash-signature")!,
body: await request.text(),
// Missing upstashRegion - will use default keys
});Test Both Regions
When setting up multi-region, test messages from both regions:
# Trigger messages from US region
curl -X POST https://qstash-us-east-1.upstash.io/v2/publish/... \
-H "Authorization: Bearer $US_EAST_1_QSTASH_TOKEN"
# Trigger messages from EU region
curl -X POST https://qstash.upstash.io/v2/publish/... \
-H "Authorization: Bearer $EU_CENTRAL_1_QSTASH_TOKEN"Gradual Migration
Migrate gradually to minimize risk:
1. Add multi-region credentials alongside existing ones 2. Update verification code to handle region header 3. Test in staging environment 4. Activate multi-region mode in production 5. Monitor for warnings and errors
Verification Script
Use the provided script to verify your environment setup:
# Option 1: Using bun (automatically loads .env)
bun run skills/advanced/multi-region/verify-multi-region-setup.ts
# Option 2: Using tsx with dotenv
npm install dotenv
npx tsx -r dotenv/config skills/advanced/multi-region/verify-multi-region-setup.tsThe script checks:
- Whether setup is single-region or multi-region
- Which region will be used for outgoing messages
- Whether all required environment variables are present
- If there are any configuration issues
See multi-region/verify-multi-region-setup.ts for implementation details.
Related Documentation
- Receiver Verification - Basic signature verification
- Client Setup - Client initialization
- Platform-Specific Verification - Framework-specific guides
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-magic-numbers */
/* eslint-disable no-console */
/**
* Script to verify QStash environment variable setup
*
* This script checks:
* - Whether the setup is single-region or multi-region
* - Which region will be used for outgoing messages
* - Whether all required environment variables are present
*
* Usage:
* npx tsx skills/advanced/scripts/verify-multi-region-setup.ts
* # or with node
* node skills/advanced/scripts/verify-multi-region-setup.js
*
* To load from .env file, install dotenv first:
* npm install dotenv
* npx tsx -r dotenv/config skills/advanced/scripts/verify-multi-region-setup.ts
*/
// // Try to load dotenv if available
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports, unicorn/prefer-module
require("dotenv").config();
} catch {
// dotenv not available, will use process.env directly
}
type QStashRegion = "EU_CENTRAL_1" | "US_EAST_1";
const VALID_REGIONS = ["EU_CENTRAL_1", "US_EAST_1"] as const;
const DEFAULT_QSTASH_URL = "https://qstash.upstash.io";
type CheckResult = {
status: "success" | "warning" | "error";
message: string;
};
type VerificationResult = {
mode: "single-region" | "multi-region";
primaryRegion?: QStashRegion;
checks: CheckResult[];
outgoingConfig: {
url?: string;
token?: string;
source: string;
};
incomingConfig: {
currentKey?: string;
nextKey?: string;
source: string;
};
};
// Colors for terminal output
const colors = {
reset: "\u001B[0m",
bright: "\u001B[1m",
red: "\u001B[31m",
green: "\u001B[32m",
yellow: "\u001B[33m",
blue: "\u001B[34m",
cyan: "\u001B[36m",
};
function colorize(color: keyof typeof colors, text: string): string {
return `${colors[color]}${text}${colors.reset}`;
}
function printHeader(text: string): void {
console.log("\n" + colorize("bright", colorize("cyan", text)));
console.log(colorize("cyan", "=".repeat(text.length)));
}
function printCheck(result: CheckResult): void {
const icon = result.status === "success" ? "✓" : result.status === "warning" ? "⚠" : "✗";
const color =
result.status === "success" ? "green" : result.status === "warning" ? "yellow" : "red";
console.log(`${colorize(color, icon)} ${result.message}`);
}
function normalizeRegion(region: string | undefined): QStashRegion | undefined {
if (!region) return undefined;
const normalized = region.replaceAll("-", "_").toUpperCase();
if (VALID_REGIONS.includes(normalized as QStashRegion)) {
return normalized as QStashRegion;
}
return undefined;
}
function checkEnvironmentVariable(name: string, required = false): CheckResult {
const value = process.env[name];
if (!value) {
return {
status: required ? "error" : "warning",
message: `${name} is ${required ? "required but " : ""}not set`,
};
}
const maskedValue = value.length > 10 ? `${value.slice(0, 4)}...${value.slice(-4)}` : "***";
return {
status: "success",
message: `${name} = ${maskedValue}`,
};
}
function verifySetup(): VerificationResult {
const checks: CheckResult[] = [];
const qstashRegion = process.env.QSTASH_REGION;
const normalizedRegion = normalizeRegion(qstashRegion);
// Determine mode
const mode = normalizedRegion ? "multi-region" : "single-region";
const primaryRegion = normalizedRegion;
printHeader("QStash Environment Verification");
// Check mode
if (mode === "multi-region" && primaryRegion) {
console.log(colorize("bright", `\nMode: `) + colorize("green", "Multi-Region"));
console.log(colorize("bright", `Primary Region: `) + colorize("green", primaryRegion));
} else {
console.log(colorize("bright", `\nMode: `) + colorize("blue", "Single-Region (Default)"));
if (qstashRegion && !normalizedRegion) {
checks.push({
status: "error",
message: `Invalid QSTASH_REGION="${qstashRegion}". Valid values: ${VALID_REGIONS.join(", ")}`,
});
}
}
let outgoingUrl: string | undefined;
let outgoingToken: string | undefined;
let outgoingSource: string;
if (mode === "multi-region" && primaryRegion) {
// Multi-region mode
const regionUrl = process.env[`${primaryRegion}_QSTASH_URL`];
const regionToken = process.env[`${primaryRegion}_QSTASH_TOKEN`];
if (regionUrl && regionToken) {
outgoingUrl = regionUrl;
outgoingToken = regionToken;
outgoingSource = `${primaryRegion}_QSTASH_*`;
checks.push(
{
status: "success",
message: `Using ${primaryRegion} credentials`,
},
checkEnvironmentVariable(`${primaryRegion}_QSTASH_URL`, true),
checkEnvironmentVariable(`${primaryRegion}_QSTASH_TOKEN`, true)
);
} else {
// In multi-region mode, missing region-specific credentials is an error
checks.push({
status: "error",
message: `${primaryRegion} credentials required in multi-region mode`,
});
if (!regionUrl) {
checks.push(checkEnvironmentVariable(`${primaryRegion}_QSTASH_URL`, true));
}
if (!regionToken) {
checks.push(checkEnvironmentVariable(`${primaryRegion}_QSTASH_TOKEN`, true));
}
// Still set fallback values for summary display
outgoingUrl = process.env.QSTASH_URL ?? DEFAULT_QSTASH_URL;
outgoingToken = process.env.QSTASH_TOKEN;
outgoingSource = "QSTASH_* (fallback - ERROR)";
}
} else {
// Single-region mode
outgoingUrl = process.env.QSTASH_URL ?? DEFAULT_QSTASH_URL;
outgoingToken = process.env.QSTASH_TOKEN;
outgoingSource = "QSTASH_*";
checks.push(
checkEnvironmentVariable("QSTASH_URL", false),
checkEnvironmentVariable("QSTASH_TOKEN", true)
);
}
let currentKey: string | undefined;
let nextKey: string | undefined;
let incomingSource: string;
if (mode === "multi-region" && primaryRegion) {
// In multi-region mode, check for region-specific keys
const usCurrentKey = process.env.US_EAST_1_QSTASH_CURRENT_SIGNING_KEY;
const usNextKey = process.env.US_EAST_1_QSTASH_NEXT_SIGNING_KEY;
const euCurrentKey = process.env.EU_CENTRAL_1_QSTASH_CURRENT_SIGNING_KEY;
const euNextKey = process.env.EU_CENTRAL_1_QSTASH_NEXT_SIGNING_KEY;
const hasUsKeys = usCurrentKey && usNextKey;
const hasEuKeys = euCurrentKey && euNextKey;
if (hasUsKeys && hasEuKeys) {
checks.push(
{
status: "success",
message: "Region-specific signing keys configured for both US and EU",
},
checkEnvironmentVariable("US_EAST_1_QSTASH_CURRENT_SIGNING_KEY", false),
checkEnvironmentVariable("US_EAST_1_QSTASH_NEXT_SIGNING_KEY", false),
checkEnvironmentVariable("EU_CENTRAL_1_QSTASH_CURRENT_SIGNING_KEY", false),
checkEnvironmentVariable("EU_CENTRAL_1_QSTASH_NEXT_SIGNING_KEY", false)
);
currentKey = primaryRegion === "US_EAST_1" ? usCurrentKey : euCurrentKey;
nextKey = primaryRegion === "US_EAST_1" ? usNextKey : euNextKey;
incomingSource = "Region-specific keys";
} else if (hasUsKeys) {
checks.push(
{
status: "warning",
message: "Only US region signing keys configured",
},
checkEnvironmentVariable("US_EAST_1_QSTASH_CURRENT_SIGNING_KEY", false),
checkEnvironmentVariable("US_EAST_1_QSTASH_NEXT_SIGNING_KEY", false),
{
status: "warning",
message: "EU signing keys not configured - EU messages will attempt to use keys",
}
);
currentKey = usCurrentKey;
nextKey = usNextKey;
incomingSource = "US-specific keys";
} else if (hasEuKeys) {
checks.push(
{
status: "warning",
message: "Only EU region signing keys configured",
},
checkEnvironmentVariable("EU_CENTRAL_1_QSTASH_CURRENT_SIGNING_KEY", false),
checkEnvironmentVariable("EU_CENTRAL_1_QSTASH_NEXT_SIGNING_KEY", false),
{
status: "warning",
message: "US signing keys not configured - US messages will attempt to use default keys",
}
);
currentKey = euCurrentKey;
nextKey = euNextKey;
incomingSource = "EU-specific keys";
} else {
checks.push({
status: "warning",
message: "No region-specific signing keys found, using default keys",
});
currentKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
nextKey = process.env.QSTASH_NEXT_SIGNING_KEY;
incomingSource = "QSTASH_* (default)";
checks.push(
checkEnvironmentVariable("QSTASH_CURRENT_SIGNING_KEY", false),
checkEnvironmentVariable("QSTASH_NEXT_SIGNING_KEY", false)
);
}
} else {
// Single-region mode
currentKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
nextKey = process.env.QSTASH_NEXT_SIGNING_KEY;
incomingSource = "QSTASH_*";
checks.push(
checkEnvironmentVariable("QSTASH_CURRENT_SIGNING_KEY", false),
checkEnvironmentVariable("QSTASH_NEXT_SIGNING_KEY", false)
);
if (!currentKey && !nextKey) {
checks.push({
status: "warning",
message:
"Signing keys not set - incoming message verification will fail if verifier is used",
});
}
}
return {
mode,
primaryRegion,
checks,
outgoingConfig: {
url: outgoingUrl,
token: outgoingToken,
source: outgoingSource,
},
incomingConfig: {
currentKey,
nextKey,
source: incomingSource,
},
};
}
function printSummary(result: VerificationResult): void {
console.log(colorize("bright", "\nOutgoing Messages:"));
console.log(` URL: ${result.outgoingConfig.url ?? "not set"}`);
console.log(` Token: ${result.outgoingConfig.token ? "✓ configured" : "✗ not set"}`);
console.log(` Source: ${result.outgoingConfig.source}`);
console.log(colorize("bright", "\nIncoming Messages:"));
console.log(` Current Key: ${result.incomingConfig.currentKey ? "✓ configured" : "✗ not set"}`);
console.log(` Next Key: ${result.incomingConfig.nextKey ? "✓ configured" : "✗ not set"}`);
console.log(` Source: ${result.incomingConfig.source}`);
printHeader("Verification Results");
const errors = result.checks.filter((c) => c.status === "error");
const warnings = result.checks.filter((c) => c.status === "warning");
const successes = result.checks.filter((c) => c.status === "success");
for (const element of result.checks) {
printCheck(element);
}
console.log(
`\n${colorize("bright", "Summary:")} ${colorize("green", `${successes.length} passed`)}, ${colorize("yellow", `${warnings.length} warnings`)}, ${colorize("red", `${errors.length} errors`)}`
);
if (errors.length > 0) {
console.log(
colorize("red", "\n✗ Configuration has errors. Please fix them before using QStash.")
);
process.exit(1);
} else if (warnings.length > 0) {
console.log(
colorize("yellow", "\n⚠ Configuration has warnings. Review them to ensure proper setup.")
);
} else {
console.log(colorize("green", "\n✓ Configuration looks good!"));
}
// Provide recommendations
if (result.mode === "multi-region") {
printHeader("Multi-Region Recommendations");
console.log("For optimal multi-region operation:");
console.log("1. Configure region-specific signing keys for both US and EU");
console.log("2. Always pass the 'upstashRegion' parameter in receiver.verify()");
console.log("3. Monitor SDK warnings in your application logs");
console.log("4. Test message delivery from both regions");
console.log(colorize("cyan", "\nExample verification code:"));
console.log(`
await receiver.verify({
signature: request.headers.get("upstash-signature")!,
body: await request.text(),
upstashRegion: request.headers.get("upstash-region") ?? undefined,
});
`);
} else {
printHeader("Recommendations");
if (!result.incomingConfig.currentKey || !result.incomingConfig.nextKey) {
console.log("Consider adding signing keys for incoming message verification:");
console.log(" QSTASH_CURRENT_SIGNING_KEY=your_current_key");
console.log(" QSTASH_NEXT_SIGNING_KEY=your_next_key");
console.log("\nGet keys from: https://console.upstash.com/qstash");
}
if (!process.env.QSTASH_URL) {
console.log("\nUsing default EU region. To specify a custom URL:");
console.log(" QSTASH_URL=https://qstash.upstash.io");
}
}
console.log(" Multi-Region: skills/advanced/multi-region.md");
console.log(" Verification: skills/verification/receiver.md");
console.log("");
}
// Main execution
try {
const result = verifySetup();
printSummary(result);
} catch (error) {
console.error(colorize("red", "\nError running verification:"), error);
process.exit(1);
}
Publishing Messages
Publish HTTP messages to destinations using the QStash SDK. Messages are delivered asynchronously with built-in retries and monitoring.
Basic Publishing
publishJSON()
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:
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
// 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:
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:
{
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:
[
{
messageId: string;
url: string; // First endpoint URL
deduplicated?: boolean;
},
{
messageId: string;
url: string; // Second endpoint URL
deduplicated?: boolean;
},
// ... one per endpoint
]Queues and Flow Control
Queues
Queues provide FIFO (First-In-First-Out) ordered message delivery with configurable parallelism.
Basic Queue Usage
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:
// 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
// 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
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 for all options.
Flow Control
Control message processing rate and concurrency without queues. More flexible than queue parallelism.
Basic Flow Control
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: 100withperiod: 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
Schedules
Schedule recurring messages using cron expressions.
Creating Schedules
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 namecron: 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 1stFormat: 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
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
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 encodedDelete Schedule
await client.schedules().delete("scd_123...");Pause and Resume
// 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
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
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
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.
Updating Schedules
To update, provide the existing scheduleId:
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:
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.
Schedule Tracking
Schedule metadata includes execution history:
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 stateslastScheduleStates maps message IDs to states:
IN_PROGRESS: Currently deliveringSUCCESS: Successfully deliveredFAIL: Failed after retries
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:
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:
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:
// 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
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
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
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:
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 for all options.
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:
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
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
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:
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
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
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
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
if (!process.env.QSTASH_CURRENT_SIGNING_KEY) {
throw new Error("Missing QSTASH_CURRENT_SIGNING_KEY");
}Related Resources
- General Receiver Documentation
- Multi-Region Setup
- Next.js Documentation
- Vercel Deployment
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 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:
QSTASH_CURRENT_SIGNING_KEY="your_current_key"
QSTASH_NEXT_SIGNING_KEY="your_next_key"Creating a Receiver Instance
Basic Setup
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 for complete details on environment variables, region detection, and verification strategies.
Verifying Incoming Requests
Basic Verification
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:
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:
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 for details.Handling Verification Failures
SignatureError
The verify() method throws SignatureError for invalid signatures:
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
clockToleranceif needed
4. URL mismatch
- Ensure the
urlparameter 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:
// ❌ 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:
// ❌ 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:
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