Back to Articles
Edge Reliability & Upstash QStash
•15 min read•@upstash/workflow + Redis Idempotency Verified

Upstash Workflow & QStash + Signbee: Guaranteed Serverless E-Signature Delivery (2026)

Dropped contract dispatches and unrecovered webhook spikes break customer onboarding. Learn how to combine @upstash/workflow, @upstash/qstash Dead Letter Queues, and atomic Redis SET NX idempotency keys with Signbee for zero-loss e-signature pipelines on Next.js and Cloudflare Workers.

Quick Answer (TL;DR)

Edge-Native Guaranteed E-Signature Architecture • Upstash QStash & Redis Blueprint

To achieve 100% delivery guarantees for serverless e-signatures on Next.js or Cloudflare Workers, use Upstash Workflow (@upstash/workflow/nextjs) to offload POST https://signb.ee/api/v1/send via context.call() and suspend execution with context.waitForEvent("wait-for-signature", "signbee-signed-" + docId, { timeout: "7d" }). On your inbound Signbee webhook route, verify X-Signbee-Signature, acquire an atomic Redis idempotency lock via redis.set(key, "1", { nx: true, ex: 86400 }), and trigger workflowClient.notify(). Any failed downstream ERP or database step automatically parks in the QStash Dead Letter Queue (DLQ) for 1-click replay.

100% HTTP/Edge isolate compatible
Atomic Redis SET NX deduplication
Automatic QStash DLQ recovery

1. The Two Failure Modes of Unbuffered Serverless E-Signature Pipelines

When engineering teams build automated contract workflows on serverless and edge runtimes (such as Vercel Functions, Cloudflare Workers, or AWS Lambda), they typically wire their application directly to an external e-signature API on the outbound path and expose a raw database-mutating webhook on the inbound path. At scale, this unbuffered architecture suffers from two critical failure modes:

  • Outbound Dispatch Fragility & Double-Click Race Conditions: If a user clicks “Generate & Send Contract” twice in rapid succession—or if a serverless function retries after a downstream database commit fails—your backend can dispatch two identical legal contracts to the customer's inbox. Conversely, if an outbound DNS spike occurs during a synchronous checkout request, the contract dispatch drops completely unless buffered by a durable message queue.
  • Inbound Webhook Thundering Herds & Downstream Outages: When a customer completes a signing ceremony and your webhook endpoint receives document.signed, your handler often needs to update PostgreSQL, provision a Stripe subscription, upload the SHA-256 certified PDF to S3, and sync Salesforce. If Salesforce or your primary database is undergoing a 10-minute maintenance window, synchronous webhook handlers fail and risk losing the completion event—or worse, partially execute the first two steps and duplicate them on retry.

When evaluating the best e-signature APIs for developers in 2026, combining Signbee with Upstash Workflow, Upstash QStash, and Upstash Redis solves both problems using pure HTTP primitives that run natively at the edge.

“Moving our contract dispatch and post-signature provisioning onto Upstash Workflow and Signbee gave us sub-5ms Redis idempotency locks on Cloudflare Workers and zero lost signatures during a 45-minute downstream CRM outage.”— VP of Infrastructure, Global Contractor Payroll Platform

2. Edge-Native Reliability Architecture: QStash + Upstash Workflow + Signbee

Unlike traditional TCP-based queues (RabbitMQ, Kafka, or BullMQ on standard Redis) that require persistent socket connections and cannot run inside stateless V8 edge isolates, the Upstash + Signbee stack communicates entirely over cryptographically signed HTTP requests:

01

Delegated Zero-Compute Dispatch (context.call("dispatch-signbee"))

Instead of keeping your Next.js or Cloudflare Worker instance alive while waiting for an external HTTP call, context.call() hands the POST https://signb.ee/api/v1/send request to QStash. QStash executes the call with automatic retries and deduplication, then returns the parsed document_id to your workflow step log.

02

HTTP Event Suspension (context.waitForEvent())

The workflow registers an event key signbee-signed-${documentId} with a 7d timeout and exits the edge isolate immediately. Zero compute resources are consumed while the recipient reviews the contract.

03

Atomic Redis SET NX Lock & workflowClient.notify()

When Signbee delivers the document.signed webhook, your ingress route validates X-Signbee-Signature, executes redis.set(lockKey, "1", { nx: true, ex: 86400 }) to guarantee strict once-only processing, and notifies the sleeping Upstash Workflow.

04

Dead Letter Queue (DLQ) Safety Net

If any post-signature step (such as syncing an on-premise ERP or accounting ledger) fails across all retry attempts, QStash moves the workflow invocation into its Dead Letter Queue (DLQ) and fires a failure callback so no signed contract ever disappears silently.

Serverless Queue & Workflow Matrix: Upstash QStash/Workflow vs. AWS SQS vs. Redis BullMQ

Comparing queuing and workflow primitives for edge and serverless e-signature delivery illustrates why HTTP-pushed state machines eliminate connection-pooling bottlenecks:

Architecture CriterionUpstash Workflow + QStash + SignbeeAWS SQS + EventBridge PipesBullMQ on TCP Redis
Cloudflare Workers / Edge Support100% Native (Pure HTTP/REST)Requires AWS SDK v3 SigV4 signingIncompatible (Requires persistent TCP)
Zero-Compute External API CallYes via context.call()Only inside Step FunctionsNo (Worker thread blocks on fetch)
Multi-Day Wait for SignatureBuilt-in context.waitForEvent()SQS max visibility timeout is 12 hoursRequires manual delayed job promotion
Deduplication & IdempotencyUpstash-Deduplication-Id + Redis SET NX5-minute FIFO deduplication window onlyJob ID deduplication while job in Redis
Dead Letter Queue Replay API1-line SDK client.dlq.listMessages()SQS Redrive to source queueManual failed-set iteration

3. Building the Durable Upstash Workflow Route in Next.js App Router

Let's implement the complete durable workflow endpoint at src/app/api/workflows/contract-signing/route.ts using @upstash/workflow/nextjs. Notice a critical optimization here that makes our affordable e-signature API integration even more efficient: instead of calling fetch("https://signb.ee/api/v1/send") inside context.run(), we use context.call("dispatch-contract-via-signbee", ...).

With context.call(), your Next.js serverless function does not even stay open while Signbee compiles the CommonMark markdown into an A4 PDF and dispatches the recipient email. QStash performs the HTTP request directly from Upstash's edge infrastructure and wakes your Next.js route with the response body once complete.

src/app/api/workflows/contract-signing/route.ts — Upstash Workflow Endpoint@upstash/workflow/nextjs
import { serve } from "@upstash/workflow/nextjs";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

export interface ContractWorkflowPayload {
  orderId: string;
  companyName: string;
  signerName: string;
  signerEmail: string;
  senderName: string;
  senderEmail: string;
  contractValueUsd: number;
}

export interface SignbeeSendApiResult {
  id: string;
  status: "sent" | "pending_verification";
  title: string;
  signing_url: string;
  verify_url: string;
  expires_at: string;
}

export interface SignbeeWebhookEventData {
  document_id: string;
  title: string;
  status: "signed";
  recipient_name: string;
  recipient_email: string;
  signed_at: string;
  sha256_hash: string;
  signed_pdf_url: string;
  verify_url: string;
}

export const { POST } = serve<ContractWorkflowPayload>(
  async (context) => {
    const payload = context.requestPayload;

    // Step 1: Prepare CommonMark contract payload
    const markdownBody = await context.run("compile-contract-markdown", async () => {
      const today = new Date().toISOString().split("T")[0];
      return [
        `# Commercial Service Order — ${payload.companyName}`,
        ``,
        `This Order Form ("Agreement") is effective as of **${today}** between **Acme Edge Inc.** and **${payload.companyName}**.`,
        ``,
        `| Order Reference | Authorized Signer | Total Annual Commitment |`,
        `|---|---|---|`,
        `| ${payload.orderId} | ${payload.signerName} (${payload.signerEmail}) | $${payload.contractValueUsd.toLocaleString()} USD |`,
        ``,
        `### Cryptographic Execution Clause`,
        `Both parties agree that electronic execution sealed with a SHA-256 certificate via Signbee carries the same legal effect as a handwritten signature.`,
      ].join("\n");
    });

    // Step 2: Delegate HTTP POST /api/v1/send directly to QStash via context.call (zero active Lambda wait)
    const { status: httpStatus, body: dispatch } = await context.call<SignbeeSendApiResult>(
      "dispatch-contract-via-signbee",
      {
        url: "https://signb.ee/api/v1/send",
        method: "POST",
        headers: {
          "Authorization": `Bearer ${process.env.SIGNBEE_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: {
          title: `Commercial Order ${payload.orderId} — ${payload.companyName}`,
          markdown: markdownBody,
          sender_name: payload.senderName,
          sender_email: payload.senderEmail,
          recipient_name: payload.signerName,
          recipient_email: payload.signerEmail,
          expires_in_days: 7,
          webhook_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/signbee-upstash`,
        },
        retries: 3,
      }
    );

    if (httpStatus < 200 || httpStatus >= 300 || !dispatch?.id) {
      throw new Error(`Signbee dispatch failed with HTTP ${httpStatus}`);
    }

    // Step 3: Suspend workflow for up to 7 days waiting for Signbee document.signed webhook
    const { eventData, timeout } = await context.waitForEvent<SignbeeWebhookEventData>(
      "wait-for-recipient-signature",
      `signbee-signed-${dispatch.id}`,
      { timeout: "7d" }
    );

    // Step 4: Handle 7-day timeout cleanly if recipient never signs
    if (timeout || !eventData) {
      await context.run("handle-expired-contract", async () => {
        await redis.hset(`order:${payload.orderId}`, {
          contractStatus: "EXPIRED",
          documentId: dispatch.id,
          updatedAt: new Date().toISOString(),
        });
      });
      return { outcome: "EXPIRED", documentId: dispatch.id };
    }

    // Step 5: Persist SHA-256 verification hash and unlock customer order
    await context.run("activate-signed-order", async () => {
      await redis.hset(`order:${payload.orderId}`, {
        contractStatus: "SIGNED",
        documentId: eventData.document_id,
        sha256Hash: eventData.sha256_hash,
        signedPdfUrl: eventData.signed_pdf_url,
        verifyUrl: eventData.verify_url,
        signedAt: eventData.signed_at,
      });
    });

    return {
      outcome: "SIGNED_AND_ACTIVATED",
      orderId: payload.orderId,
      documentId: eventData.document_id,
      sha256Hash: eventData.sha256_hash,
    };
  },
  {
    retries: 3,
    failureFunction: async ({ context, failStatus, failResponse }) => {
      // Automatic DLQ & alert hook if downstream activation fails after all retries
      await redis.lpush(
        "dlq:contract-workflows",
        JSON.stringify({
          orderId: context.requestPayload.orderId,
          failStatus,
          failResponse,
          failedAt: new Date().toISOString(),
        })
      );
    },
  }
);

4. Inbound Webhook Hardening: HMAC Verification + Redis SET NX Idempotency Lock

Now let's build the ingress webhook handler at src/app/api/webhooks/signbee-upstash/route.ts. Even though Upstash Workflow deduplicates step executions internally, adding an explicit atomic Redis SET key value NX EX 86400 lock at the edge ingress layer protects every downstream system listening to your webhook from duplicate deliveries.

Notice how we also store a processing state ("PROCESSING" vs. "COMPLETED") and release the lock in the catch block if the network call to workflowClient.notify() throws an unexpected error—allowing Signbee's automatic webhook retry to succeed while blocking true duplicates:

src/app/api/webhooks/signbee-upstash/route.ts — Redis SET NX + Workflow NotifyNext.js App Router
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { Redis } from "@upstash/redis";
import { Client as WorkflowClient } from "@upstash/workflow";

const redis = Redis.fromEnv();
const workflowClient = new WorkflowClient({
  token: process.env.QSTASH_TOKEN!,
});

function verifySignbeeHmac(rawBody: string, signature: string | null, secret: string): boolean {
  if (!signature) return false;
  const cleanHex = signature.replace(/^sha256=/, "");
  const expectedHex = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");

  const sigBuf = Buffer.from(cleanHex, "hex");
  const expBuf = Buffer.from(expectedHex, "hex");
  return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
}

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const sigHeader = req.headers.get("x-signbee-signature");
  const secret = process.env.SIGNBEE_WEBHOOK_SECRET;

  if (!secret || !verifySignbeeHmac(rawBody, sigHeader, secret)) {
    return NextResponse.json({ error: "Invalid X-Signbee-Signature" }, { status: 401 });
  }

  const payload = JSON.parse(rawBody);
  const documentId: string | undefined = payload?.data?.document_id;

  if (!documentId) {
    return NextResponse.json({ error: "Missing document_id" }, { status: 400 });
  }

  // Atomic Redis SET NX Idempotency Gate (24-hour TTL)
  const idempotencyKey = `idempotency:signbee:${payload.event}:${documentId}`;
  const lockAcquired = await redis.set(idempotencyKey, "PROCESSING", {
    nx: true,
    ex: 86400,
  });

  if (!lockAcquired) {
    // Duplicate webhook delivery detected; acknowledge with 200 OK immediately
    return NextResponse.json({ status: "duplicate_ignored", documentId }, { status: 200 });
  }

  try {
    if (payload.event === "document.signed") {
      await workflowClient.notify({
        eventId: `signbee-signed-${documentId}`,
        eventData: payload.data,
      });
    }

    await redis.set(idempotencyKey, "COMPLETED", { ex: 86400 });
    return NextResponse.json({ status: "notified", documentId }, { status: 200 });
  } catch (err) {
    // Release lock so Signbee's automatic webhook retry can re-attempt delivery
    await redis.del(idempotencyKey);
    throw err;
  }
}

5. Direct QStash Outbound Buffering & Dead Letter Queue (DLQ) Replay

What if you don't need a multi-step workflow for a particular contract type (such as a one-click NDA or terms update), and simply want to fire-and-forget a POST https://signb.ee/api/v1/send request from an edge route with 100% delivery assurance? You can use @upstash/qstash directly as an outbound HTTP proxy with deduplicationId.

And if any inbound webhook processing job ever lands in the QStash Dead Letter Queue during a database migration, you can build an administrative replay script in 25 lines of TypeScript:

src/lib/qstash-signbee-dlq.ts — Outbound Proxy & DLQ Recovery Utility@upstash/qstash
import { Client as QStashClient } from "@upstash/qstash";

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

/**
 * Buffers an outbound Signbee POST /api/v1/send request through QStash
 * with strict deduplication and automatic exponential backoff.
 */
export async function enqueueGuaranteedSignbeeDispatch(params: {
  orderId: string;
  title: string;
  markdown: string;
  senderName: string;
  senderEmail: string;
  recipientName: string;
  recipientEmail: string;
}) {
  const result = await qstash.publishJSON({
    url: "https://signb.ee/api/v1/send",
    headers: {
      "Authorization": `Bearer ${process.env.SIGNBEE_API_KEY}`,
    },
    body: {
      title: params.title,
      markdown: params.markdown,
      sender_name: params.senderName,
      sender_email: params.senderEmail,
      recipient_name: params.recipientName,
      recipient_email: params.recipientEmail,
      expires_in_days: 7,
      webhook_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/signbee-upstash`,
    },
    deduplicationId: `signbee-dispatch-${params.orderId}`,
    retries: 4,
  });

  return { qstashMessageId: result.messageId };
}

/**
 * Inspects and replays any failed messages sitting in the QStash Dead Letter Queue (DLQ)
 * after a downstream database outage is resolved.
 */
export async function replaySignbeeDeadLetterQueue() {
  const dlqResponse = await qstash.dlq.listMessages();
  const replayedIds: string[] = [];

  for (const msg of dlqResponse.messages) {
    await qstash.publish({
      url: msg.url,
      body: msg.body,
      headers: msg.header,
      retries: 3,
    });
    await qstash.dlq.delete(msg.dlqId);
    replayedIds.push(msg.dlqId);
  }

  return { replayedCount: replayedIds.length, replayedIds };
}

6. Total Cost of Ownership: Why Edge Teams Are Replacing Per-Envelope APIs

In our analysis of DocuSign API pricing explained and our broader e-signature API pricing comparison 2026, legacy e-signature providers penalize high-volume automated workflows with $1.50 to $4.80 per-envelope charges and strict hourly API polling rate limits. Meanwhile, self-managing Docker clusters as examined in our open-source e-signature API comparison introduces 24/7 DevOps overhead.

By pairing Upstash's pay-per-request serverless pricing ($1 per 100,000 QStash messages) with Signbee's white-label e-signature API—which offers a free e-signature API tier for testing and a flat $20/month unlimited Pro tier—you can process 10,000 guaranteed contract workflows per month for less than the cost of six legacy envelopes.

Monthly Volume: 10,000 ContractsSignbee Pro + Upstash Workflow & RedisLegacy Envelope API + Managed RedisSelf-Hosted Open Source + AWS ECS
E-Signature API Cost (10k docs)$20.00 / mo (Flat Unlimited)$18,000+ / mo ($1.80–$3.80/doc)$0 license ($160/mo KMS & SES)
Queue & Idempotency Store Cost~$1.20 / mo (Pay-per-request QStash/Redis)$95.00 / mo (Fixed Redis cluster)$140.00 / mo (ECS + ElastiCache)
Edge Isolate CompatibilityYes (Cloudflare Workers & Vercel Edge)Node.js runtime required for SDKFull Docker container required
Redis Idempotency Lock Latency1.8ms global edge read/write15–60ms single-region TCP10–45ms VPC internal

7. Production Security Checklist: Dual Signature Verification & Key Rotation

Before deploying your Upstash + Signbee pipeline to production, verify that these four security controls are active in your environment variables:

  1. Set QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY: The serve() handler from @upstash/workflow/nextjs automatically inspects these environment variables and validates the Upstash-Signature JWT header on every step invocation so external attackers cannot spoof workflow steps.
  2. Enforce Timing-Safe HMAC Verification on X-Signbee-Signature: Always validate the raw webhook request body using crypto.timingSafeEqual before touching Redis or calling workflowClient.notify().
  3. Scope Redis Idempotency Keys by Event Type and Document ID: Use the composite pattern idempotency:signbee:${event}:${document_id} so that an initial document.sent or document.viewed webhook never blocks the subsequent document.signed webhook.

8. Frequently Asked Questions: Upstash Workflow, QStash & Signbee

How do Upstash Workflow context.run and context.waitForEvent work with Signbee on Cloudflare Workers and Vercel Edge?

Upstash Workflow is built on top of HTTP-native QStash messaging, making it 100% compatible with V8 isolates like Cloudflare Workers and Vercel Edge Functions without requiring persistent TCP connections or heavy container runtimes. When your workflow calls context.run('dispatch-signbee', ...) or context.call(), QStash records the returned Signbee document_id in its durable HTTP log. When execution reaches context.waitForEvent('wait-signature', 'signbee-signed-' + docId, { timeout: '7d' }), the edge function terminates immediately. When Signbee fires the document.signed webhook, your webhook handler calls workflowClient.notify({ eventId: 'signbee-signed-' + docId, eventData }), waking the workflow on the edge.

How do I prevent duplicate post-signature provisioning using Upstash Redis SET NX idempotency locks?

Webhook providers guarantee at-least-once delivery, meaning transient network timeouts can cause a document.signed webhook to be delivered more than once. By calling await redis.set('idempotency:signbee:signed:' + documentId, 'PROCESSING', { nx: true, ex: 86400 }) inside your Next.js webhook route before executing downstream provisioning, Upstash Redis performs an atomic Set-If-Not-Exists operation at the edge in under 3 milliseconds. If a duplicate webhook arrives while the first is processing or after it completes, redis.set returns null, and your handler safely returns HTTP 200 OK without double-provisioning.

Can QStash proxy and buffer outbound POST /api/v1/send requests to Signbee?

Yes. Instead of calling fetch('https://signb.ee/api/v1/send') synchronously during a user-facing checkout request—where a sudden traffic spike or network blip could slow down the user's browser response—you can publish the contract payload to QStash targeting https://signb.ee/api/v1/send (or via context.call() in Upstash Workflow) with an Upstash-Deduplication-Id header. QStash durably buffers the request, forwards your Authorization: Bearer signbee_... header, automatically retries any 5xx network blips with exponential backoff, and routes permanent failures to your Dead Letter Queue (DLQ) for inspection.

How does Upstash QStash Dead Letter Queue (DLQ) protect against lost e-signature webhooks?

If your downstream database or ERP system experiences an extended outage when a customer signs a contract, an unbuffered webhook could exhaust its retry attempts and be dropped. By ingesting Signbee's document.signed webhook into QStash (or an Upstash Workflow endpoint), failed downstream steps are automatically retried with configurable backoff and then parked permanently in the QStash Dead Letter Queue (DLQ). Once your database recovers, your engineering team can programmatically list and replay every parked DLQ message via the @upstash/qstash SDK or console with zero lost legal signatures.

Why use context.call() instead of context.run() when calling external APIs in Upstash Workflow?

In Upstash Workflow, context.run() executes the HTTP fetch inside your Next.js or Cloudflare Worker instance, meaning your serverless function stays active and accrues wall-clock execution time while waiting for the external API response. Conversely, context.call('call-signbee-api', { url: 'https://signb.ee/api/v1/send', method: 'POST', ... }) delegates the HTTP request directly to Upstash's QStash servers. Your serverless function exits immediately and is only re-invoked once QStash receives Signbee's HTTP 200 response, reducing your Vercel or Worker active compute time to near zero.

How do I verify both X-Signbee-Signature and Upstash-Signature in a multi-route Next.js architecture?

Separate your public ingress route from your internal QStash worker route. Your public webhook endpoint (/api/webhooks/signbee-upstash) receives the callback directly from Signbee, verifies the X-Signbee-Signature HMAC-SHA256 header using SIGNBEE_WEBHOOK_SECRET, acquires an Upstash Redis idempotency lock, and calls workflowClient.notify(). Meanwhile, your durable workflow endpoint (/api/workflows/contract-signing) is invoked exclusively by Upstash QStash and verifies the Upstash-Signature JWT header automatically using QStash's serve() wrapper configured with QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY.

Deploy Guaranteed Serverless E-Signatures at the Edge

Pair Upstash Workflow and QStash with Signbee's simple REST API. Send 3 documents/month free or unlock unlimited documents for $20/month flat.