Tutorial·August 26, 2026·14 min read

Next.js Server Actions for E-Signatures: Streaming & Webhooks (2026)

Build high-performance, legally binding document signing in Next.js 15+ App Router. Master type-safe Server Actions, optimistic UI state transitions, streaming Suspense audit trail verification, and cryptographically secure HMAC webhook ingestion.

Michael Beckett
Michael Beckett

Founder, Signbee · Next.js & Full-Stack Architect

Executive Architecture Summary

Modern e-signatures do not require heavy third-party SDKs, iframe bloat, or complex client-side state managers. In Next.js 15+ and React 19, Server Actions ("use server") execute document mutations safely on the backend without leaking credentials. Combined with useOptimistic for zero-latency UI updates, React Suspense for streaming SHA-256 audit trail verification, and Node.js crypto for timing-safe HMAC webhook validation, you can deploy an end-to-end, legally compliant e-signature engine in under 200 lines of type-safe code.

The 2026 Next.js App Router E-Signature Paradigm

Historically, integrating digital signatures into a React application was a cumbersome endeavor. Developers were forced to embed multi-megabyte vendor SDKs, manage messy OAuth redirect handshakes, and expose intermediary API endpoints vulnerable to client tampering. If a signing ceremony failed or stalled, debugging meant sifting through opaque iframe postMessage events.

With the maturation of the Next.js App Router and React 19 Server Components, this legacy architecture is obsolete. The modern paradigm separates concerns into three distinct execution contexts:

1. Server Actions

Handles outbound document dispatch, server-side Zod validation, template interpolation, and credential isolation. Zero API keys in client bundles.

2. Route Handlers

Ingests inbound webhooks from Signbee. Performs timing-safe HMAC-SHA256 signature verification and triggers automated cache revalidations.

3. Streaming Suspense

Streams tamper-proof SHA-256 cryptographic audit logs directly from the server, verifying ESIGN and eIDAS compliance without blocking main-thread paint.

This tutorial builds a complete, production-grade legal document dispatch system. Before writing code, ensure you have your free Signbee API credentials from the Signbee Developer Portal. We also recommend referencing our introductory Next.js App Router guide and React signing component breakdown for additional foundational context.

Environment Setup & Dependencies

One of the primary benefits of Signbee's API-first architecture is that it relies on standard web APIs (fetch, Web Crypto, and standard Node.js crypto). You only need zod for runtime schema validation:

TerminalTypeScript
npm install zod
# or
pnpm add zod

Create your local environment configuration at .env.local. Never prefix these variables with NEXT_PUBLIC_ because they must remain exclusively accessible within the server runtime:

.env.localTypeScript
# Signbee API Credentials (Server-only)
SIGNBEE_API_KEY=sb_live_a89f31c0e81245789bcde312
SIGNBEE_WEBHOOK_SECRET=whsec_90f23ba56c8712e4d5671a82

# Application Configuration
NEXT_PUBLIC_APP_URL=https://your-domain.com

Step 1: Complete Server Action with Zod Validation

Server Actions allow client forms to trigger backend execution without creating boilerplate REST endpoints. We will build actions/send-agreement.ts to accomplish four critical tasks:

  • Schema Validation: Verify that recipient names, valid RFC 5322 emails, and Markdown templates satisfy structural constraints before touching the network.
  • Credential Isolation: Access process.env.SIGNBEE_API_KEY within an isolated Node.js execution sandbox.
  • Granular Error Handling: Intercept and map HTTP 400 (Bad Request), 401 (Unauthorized), and 429 (Rate Limit with Retry-After parsing) into typed UI responses.
  • Deterministic Response Typing: Return a typed discriminated union enabling seamless optimistic UI state transitions in React 19.
src/actions/send-agreement.ts — Complete Server ActionTypeScript
"use server";

import { z } from "zod";

// 1. Define strict Zod validation schema
const SendAgreementSchema = z.object({
  recipientName: z
    .string()
    .min(2, "Recipient name must be at least 2 characters")
    .max(100, "Recipient name cannot exceed 100 characters")
    .trim(),
  recipientEmail: z
    .string()
    .email("Please provide a valid recipient email address")
    .toLowerCase()
    .trim(),
  agreementTitle: z
    .string()
    .min(3, "Agreement title is required")
    .max(150, "Title is too long"),
  markdownContent: z
    .string()
    .min(20, "Contract content must contain at least 20 characters of Markdown"),
  customVariables: z
    .record(z.string(), z.string())
    .optional()
    .default({}),
});

export type SendAgreementInput = z.infer<typeof SendAgreementSchema>;

export type ActionState = {
  success: boolean;
  documentId?: string;
  signingUrl?: string;
  auditHash?: string;
  error?: string;
  fieldErrors?: Record<string, string[]>;
};

/**
 * Server Action: Dispatches contract markdown for cryptographic e-signature
 */
export async function sendAgreementAction(
  prevState: ActionState | null,
  formData: FormData
): Promise<ActionState> {
  // Extract raw form entries
  const rawData = {
    recipientName: formData.get("recipientName"),
    recipientEmail: formData.get("recipientEmail"),
    agreementTitle: formData.get("agreementTitle"),
    markdownContent: formData.get("markdownContent"),
  };

  // Perform server-side Zod validation
  const validationResult = SendAgreementSchema.safeParse(rawData);
  if (!validationResult.success) {
    return {
      success: false,
      error: "Validation failed. Please correct the highlighted fields.",
      fieldErrors: validationResult.error.flatten().fieldErrors,
    };
  }

  const { recipientName, recipientEmail, agreementTitle, markdownContent } =
    validationResult.data;

  // Verify server environment secret
  const apiKey = process.env.SIGNBEE_API_KEY;
  if (!apiKey) {
    console.error("CRITICAL: SIGNBEE_API_KEY is not defined in environment.");
    return {
      success: false,
      error: "Server configuration error. Signing service temporarily unavailable.",
    };
  }

  try {
    // Dispatch to Signbee API with Markdown payload
    const response = await fetch("https://signb.ee/api/v1/send", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${apiKey}`,
      },
      body: JSON.stringify({
        title: agreementTitle,
        markdown: markdownContent,
        recipient_name: recipientName,
        recipient_email: recipientEmail,
        metadata: {
          source: "nextjs_server_actions",
          dispatched_at: new Date().toISOString(),
        },
      }),
      // Disable Next.js data cache for mutation dispatches
      cache: "no-store",
    });

    if (!response.ok) {
      const errorText = await response.text();

      // Handle rate limits gracefully
      if (response.status === 429) {
        const retryAfter = response.headers.get("Retry-After") || "10";
        return {
          success: false,
          error: `Rate limit reached. Please try again in ${retryAfter} seconds.`,
        };
      }

      if (response.status === 401) {
        return {
          success: false,
          error: "Authentication failed. Check your Signbee API key.",
        };
      }

      return {
        success: false,
        error: `Signbee API Error (${response.status}): ${errorText || "Unknown error"}`,
      };
    }

    const payload = await response.json();

    return {
      success: true,
      documentId: payload.document_id,
      signingUrl: payload.signing_url,
      auditHash: payload.audit_hash ?? undefined,
    };
  } catch (err: unknown) {
    console.error("Network or execution failure during document dispatch:", err);
    return {
      success: false,
      error:
        err instanceof Error
          ? err.message
          : "An unexpected network error occurred while contacting Signbee.",
    };
  }
}

Notice how this action enforces progressive enhancement. When invoked via standard form submission, it processes native FormData and serializes clean state back to the caller. Because the function is stamped with "use server", Next.js compiles an internal RPC handler, ensuring the SIGNBEE_API_KEY never touches client bundles.

Step 2: HMAC Webhook Route Handler & Cache Invalidation

When a recipient receives an email, views the document, signs via canvas or typed signature, or declines the agreement, Signbee dispatches an asynchronous HTTP webhook event. For a complete catalog of payload schemas, review our E-Signature API Webhook Events Guide.

In Next.js App Router, inbound webhooks must be received by a Route Handler (app/api/webhooks/signbee/route.ts). There are three critical engineering requirements when receiving webhook events in Next.js:

  1. Unparsed Raw Body Extraction: You must read the raw payload using req.text(). If you parse JSON first and re-stringify it, subtle whitespace differences will invalidate the cryptographic signature.
  2. Timing-Safe HMAC Verification: Use Node.js crypto.timingSafeEqual to prevent side-channel timing attacks. Never use standard equality operators (===) when validating security tokens.
  3. On-Demand Cache Revalidation: Trigger revalidatePath('/dashboard') or revalidateTag('agreement-status') to instantly bust stale Server Component caches when documents are signed.
src/app/api/webhooks/signbee/route.ts — Webhook Route HandlerTypeScript
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath, revalidateTag } from "next/cache";
import crypto from "crypto";

// Force Node.js runtime for access to crypto module
export const runtime = "nodejs";

interface SignbeeWebhookEvent {
  event:
    | "document.sent"
    | "document.viewed"
    | "document.signed"
    | "document.declined"
    | "audit_trail.sealed";
  timestamp: string;
  data: {
    document_id: string;
    signer_name: string;
    signer_email: string;
    signed_pdf_url?: string;
    audit_hash?: string;
    ip_address?: string;
    user_agent?: string;
    decline_reason?: string;
  };
}

/**
 * Validates the HMAC-SHA256 signature using timing-safe comparison
 */
function verifyHmacSignature(rawBody: string, signatureHeader: string | null): boolean {
  const secret = process.env.SIGNBEE_WEBHOOK_SECRET;
  if (!secret || !signatureHeader) return false;

  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  // Prevent timing attacks via timingSafeEqual
  const signatureBuffer = Buffer.from(signatureHeader, "utf-8");
  const expectedBuffer = Buffer.from(expectedSignature, "utf-8");

  if (signatureBuffer.length !== expectedBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
}

export async function POST(req: NextRequest) {
  try {
    // 1. Read raw body as text before any parsing
    const rawBody = await req.text();
    const signature = req.headers.get("x-signbee-signature");

    // 2. Cryptographic signature check
    if (!verifyHmacSignature(rawBody, signature)) {
      console.warn("⚠️ Unauthorized webhook signature attempt rejected.");
      return NextResponse.json(
        { error: "Invalid HMAC signature" },
        { status: 401 }
      );
    }

    // 3. Parse JSON only after verification
    const payload: SignbeeWebhookEvent = JSON.parse(rawBody);
    const { event, data, timestamp } = payload;

    console.log(`🔔 Verified Signbee Webhook [${event}] for Doc: ${data.document_id}`);

    // 4. Update your database and execute business logic
    switch (event) {
      case "document.sent":
        // await db.agreement.update({
        //   where: { id: data.document_id },
        //   data: { status: "SENT", sentAt: new Date(timestamp) },
        // });
        break;

      case "document.viewed":
        // await db.agreement.update({
        //   where: { id: data.document_id },
        //   data: { status: "VIEWED", viewedAt: new Date(timestamp) },
        // });
        break;

      case "document.signed":
        // Update document status and store audit certificate url
        // await db.agreement.update({
        //   where: { id: data.document_id },
        //   data: {
        //     status: "SIGNED",
        //     signedPdfUrl: data.signed_pdf_url,
        //     auditHash: data.audit_hash,
        //     completedAt: new Date(timestamp),
        //   },
        // });

        // Trigger downstream automations (e.g. Stripe checkout, onboarding)
        console.log(`✅ Document ${data.document_id} signed by ${data.signer_name}`);
        break;

      case "document.declined":
        // await db.agreement.update({
        //   where: { id: data.document_id },
        //   data: { status: "DECLINED", declineReason: data.decline_reason },
        // });
        break;

      case "audit_trail.sealed":
        console.log(`🔒 Cryptographic SHA-256 seal verified: ${data.audit_hash}`);
        break;
    }

    // 5. Bust Next.js App Router cache tags and paths on demand
    revalidatePath("/dashboard");
    revalidatePath(`/agreements/${data.document_id}`);
    revalidateTag("agreement-status");

    return NextResponse.json({ received: true, event });
  } catch (error) {
    console.error("Webhook processing failure:", error);
    return NextResponse.json(
      { error: "Webhook processing error" },
      { status: 500 }
    );
  }
}

Step 3: Streaming Suspense & Audit Trail Verification

Under international electronic signature acts—including the United States ESIGN Act, the Uniform Electronic Transactions Act (UETA), and the EU eIDAS Regulation—an electronic signature is only as strong as its associated audit trail. A valid digital signature requires:

Signer Attribution:

Verified email OTP, signer IP address, and browser User-Agent fingerprint.

Tamper-Proof Integrity:

Cryptographic SHA-256 hash sealing both original Markdown source and compiled signed PDF/A bytes.

RFC 3161 Certified Timestamping:

Immutable chronological log proving exact execution time down to millisecond precision.

In Next.js App Router, computing or fetching deep cryptographic verification summaries can introduce a 200–500ms latency overhead. By utilizing React Suspense streaming, we render the surrounding layout instantly while streaming the audit trail verification block when the cryptographic handshake resolves.

src/components/AuditTrailStreamer.tsx — Streaming Server ComponentTypeScript
import { Suspense } from "react";
import { Lock, ShieldCheck, CheckCircle2, FileText, Clock } from "lucide-react";

interface AuditLog {
  event: string;
  timestamp: string;
  ip: string;
  actor: string;
  sha256Checksum: string;
}

// Asynchronous Server Component that performs cryptographic verification
async function AsyncAuditDetails({ documentId }: { documentId: string }) {
  // Fetch verified audit record from Signbee
  const res = await fetch(`https://signb.ee/api/v1/documents/${documentId}/audit`, {
    headers: {
      Authorization: `Bearer ${process.env.SIGNBEE_API_KEY}`,
    },
    next: { tags: [`doc-${documentId}`], revalidate: 60 },
  });

  if (!res.ok) {
    return (
      <div className="p-4 rounded-lg bg-red-400/10 border border-red-400/20 text-xs text-red-400">
        Audit trail verification unavailable.
      </div>
    );
  }

  const auditData: {
    status: string;
    sha256_seal: string;
    timeline: AuditLog[];
  } = await res.json();

  return (
    <div className="rounded-lg bg-white/[0.02] border border-white/[0.08] p-5 space-y-4">
      <div className="flex items-center justify-between border-b border-white/[0.06] pb-3">
        <div className="flex items-center gap-2">
          <ShieldCheck className="w-5 h-5 text-emerald-400" />
          <span className="text-sm font-semibold text-white/90">
            Cryptographic SHA-256 Seal
          </span>
        </div>
        <span className="text-xs font-mono text-emerald-400/90 bg-emerald-400/10 px-2 py-0.5 rounded">
          ESIGN & eIDAS Verified
        </span>
      </div>

      <div className="text-xs font-mono text-white/50 break-all bg-black/40 p-3 rounded border border-white/5">
        <span className="text-amber-400/80">SHA-256:</span> {auditData.sha256_seal}
      </div>

      <div className="space-y-3 pt-2">
        <p className="text-xs font-medium text-white/70 uppercase tracking-wider">
          Chain of Custody Timeline
        </p>
        {auditData.timeline.map((log, idx) => (
          <div key={idx} className="flex items-start gap-3 text-xs text-white/60">
            <CheckCircle2 className="w-4 h-4 text-emerald-400/70 shrink-0 mt-0.5" />
            <div className="flex-1">
              <div className="flex items-center justify-between">
                <span className="font-medium text-white/80">{log.event}</span>
                <span className="text-[11px] text-white/40 font-mono">
                  {new Date(log.timestamp).toLocaleTimeString()}
                </span>
              </div>
              <p className="text-[11px] text-white/40">
                Actor: {log.actor} · IP: {log.ip}
              </p>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

function AuditSkeleton() {
  return (
    <div className="rounded-lg bg-white/[0.02] border border-white/[0.06] p-5 animate-pulse space-y-4">
      <div className="h-5 bg-white/10 rounded w-1/3" />
      <div className="h-10 bg-white/5 rounded w-full" />
      <div className="space-y-2">
        <div className="h-4 bg-white/5 rounded w-3/4" />
        <div className="h-4 bg-white/5 rounded w-1/2" />
      </div>
    </div>
  );
}

export function AuditTrailViewer({ documentId }: { documentId: string }) {
  return (
    <Suspense fallback={<AuditSkeleton />}>
      <AsyncAuditDetails documentId={documentId} />
    </Suspense>
  );
}

Step 4: Interactive Client Form with React 19 Optimistic UI

When a user clicks "Send for Signature", waiting 1–2 seconds for network dispatch creates perceived sluggishness. In modern Next.js 15+ apps, we combine React 19's useActionState and useOptimistic to provide immediate UI feedback while the Server Action resolves asynchronously.

src/components/AgreementSigningForm.tsx — Interactive Client ComponentTypeScript
"use client";

import { useActionState, useOptimistic, startTransition } from "react";
import { sendAgreementAction, ActionState } from "@/actions/send-agreement";
import { Send, Loader2, CheckCircle, AlertCircle, FileSignature } from "lucide-react";

interface OptimisticStatus {
  isPending: boolean;
  stage: "idle" | "dispatching" | "delivered" | "failed";
}

export function AgreementSigningForm() {
  // React 19 useActionState hook binding the Server Action
  const [state, formAction, isPending] = useActionState(sendAgreementAction, null);

  // Optimistic UI state for instant dispatch feedback
  const [optimisticState, setOptimisticState] = useOptimistic<
    OptimisticStatus,
    OptimisticStatus
  >(
    { isPending, stage: state?.success ? "delivered" : "idle" },
    (current, update) => ({ ...current, ...update })
  );

  const handleSubmit = async (formData: FormData) => {
    // 1. Trigger instant optimistic state transition
    startTransition(() => {
      setOptimisticState({ isPending: true, stage: "dispatching" });
    });

    // 2. Execute Server Action
    formAction(formData);
  };

  return (
    <form action={handleSubmit} className="space-y-5 bg-white/[0.02] border border-white/[0.08] p-6 rounded-xl">
      <div className="flex items-center gap-2 pb-3 border-b border-white/[0.06]">
        <FileSignature className="w-5 h-5 text-amber-400" />
        <h3 className="text-base font-semibold text-white/90">
          Dispatch Legal Service Agreement
        </h3>
      </div>

      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <div>
          <label htmlFor="recipientName" className="block text-xs font-medium text-white/70 mb-1.5">
            Signer Full Name
          </label>
          <input
            id="recipientName"
            name="recipientName"
            type="text"
            required
            defaultValue="Alex Morgan"
            placeholder="Alex Morgan"
            className="w-full px-3 py-2 bg-zinc-900 border border-zinc-700/80 rounded-lg text-sm text-white focus:outline-none focus:border-amber-400 transition-colors"
          />
          {state?.fieldErrors?.recipientName && (
            <p className="text-xs text-red-400 mt-1">{state.fieldErrors.recipientName[0]}</p>
          )}
        </div>

        <div>
          <label htmlFor="recipientEmail" className="block text-xs font-medium text-white/70 mb-1.5">
            Signer Email Address
          </label>
          <input
            id="recipientEmail"
            name="recipientEmail"
            type="email"
            required
            defaultValue="alex@company.com"
            placeholder="alex@company.com"
            className="w-full px-3 py-2 bg-zinc-900 border border-zinc-700/80 rounded-lg text-sm text-white focus:outline-none focus:border-amber-400 transition-colors"
          />
          {state?.fieldErrors?.recipientEmail && (
            <p className="text-xs text-red-400 mt-1">{state.fieldErrors.recipientEmail[0]}</p>
          )}
        </div>
      </div>

      <div>
        <label htmlFor="agreementTitle" className="block text-xs font-medium text-white/70 mb-1.5">
          Agreement Document Title
        </label>
        <input
          id="agreementTitle"
          name="agreementTitle"
          type="text"
          required
          defaultValue="Master Consulting & IP Agreement"
          className="w-full px-3 py-2 bg-zinc-900 border border-zinc-700/80 rounded-lg text-sm text-white focus:outline-none focus:border-amber-400 transition-colors"
        />
      </div>

      <div>
        <label htmlFor="markdownContent" className="block text-xs font-medium text-white/70 mb-1.5">
          Contract Content (Markdown)
        </label>
        <textarea
          id="markdownContent"
          name="markdownContent"
          rows={7}
          required
          defaultValue={`# Master Services Agreement

This Agreement is entered into on ${new Date().toLocaleDateString()} between **Provider** and **Signer**.

## Scope of Engagement
1. **Deliverables:** Full-stack Next.js 15 App Router architecture with Server Actions and Signbee API integration.
2. **Compensation:** $8,500 due upon milestone verification.
3. **Intellectual Property:** All custom code and repository deliverables transfer unconditionally upon receipt of final settlement.

**Signer Acceptance:**
Please execute below to seal this agreement.`}
          className="w-full px-3 py-2 bg-zinc-900 border border-zinc-700/80 rounded-lg text-xs font-mono text-white/90 focus:outline-none focus:border-amber-400 transition-colors"
        />
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-amber-400 text-black font-semibold text-sm rounded-lg hover:bg-amber-300 disabled:opacity-50 transition-colors cursor-pointer"
      >
        {optimisticState.stage === "dispatching" ? (
          <>
            <Loader2 className="w-4 h-4 animate-spin" />
            Dispatching Cryptographic Envelope...
          </>
        ) : (
          <>
            <Send className="w-4 h-4" />
            Send for E-Signature
          </>
        )}
      </button>

      {/* Real-time result feedback */}
      {state?.success && (
        <div className="bg-emerald-400/10 border border-emerald-400/20 rounded-lg p-4 text-xs text-emerald-400 flex items-start gap-3">
          <CheckCircle className="w-4 h-4 shrink-0 mt-0.5" />
          <div>
            <p className="font-semibold">Agreement Dispatched Successfully</p>
            <p className="text-white/60 mt-0.5">
              Document ID: <code className="text-emerald-300">{state.documentId}</code>
            </p>
            {state.signingUrl && (
              <a
                href={state.signingUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="inline-block mt-2 text-amber-400 underline underline-offset-2 hover:text-amber-300"
              >
                Open Signer Ceremony URL →
              </a>
            )}
          </div>
        </div>
      )}

      {state?.error && (
        <div className="bg-red-400/10 border border-red-400/20 rounded-lg p-4 text-xs text-red-400 flex items-start gap-3">
          <AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
          <div>
            <p className="font-semibold">Action Failed</p>
            <p className="text-white/60 mt-0.5">{state.error}</p>
          </div>
        </div>
      )}
    </form>
  );
}

Live Signing Status Component & Real-Time UX

Once a contract is sent, signers interact with their unique signing URL. To surface live status changes without requiring constant page reloads, we implement a lightweight polling and badge reconciliation component:

src/components/SigningStatusBadge.tsxTypeScript
"use client";

import { useEffect, useState } from "react";
import { Clock, Eye, CheckCircle2, XCircle, ShieldCheck } from "lucide-react";

type SigningStatus = "pending" | "sent" | "viewed" | "signed" | "declined";

const statusBadges: Record<
  SigningStatus,
  { label: string; bg: string; text: string; icon: any }
> = {
  pending: {
    label: "Awaiting Dispatch",
    bg: "bg-zinc-800",
    text: "text-zinc-400",
    icon: Clock,
  },
  sent: {
    label: "Delivered to Signer",
    bg: "bg-blue-950/60 border border-blue-800/40",
    text: "text-blue-400",
    icon: Clock,
  },
  viewed: {
    label: "Signer Reviewing",
    bg: "bg-amber-950/60 border border-amber-800/40",
    text: "text-amber-400",
    icon: Eye,
  },
  signed: {
    label: "Executed & Sealed",
    bg: "bg-emerald-950/60 border border-emerald-800/40",
    text: "text-emerald-400",
    icon: CheckCircle2,
  },
  declined: {
    label: "Declined by Recipient",
    bg: "bg-red-950/60 border border-red-800/40",
    text: "text-red-400",
    icon: XCircle,
  },
};

export function SigningStatusBadge({
  documentId,
  initialStatus = "sent",
}: {
  documentId: string;
  initialStatus?: SigningStatus;
}) {
  const [status, setStatus] = useState<SigningStatus>(initialStatus);

  useEffect(() => {
    // If terminal state, no polling needed
    if (status === "signed" || status === "declined") return;

    const interval = setInterval(async () => {
      try {
        const res = await fetch(`/api/documents/${documentId}/status`);
        if (res.ok) {
          const data = await res.json();
          if (data.status && data.status !== status) {
            setStatus(data.status);
          }
        }
      } catch {
        // Silently tolerate transient network glitches
      }
    }, 4000);

    return () => clearInterval(interval);
  }, [documentId, status]);

  const config = statusBadges[status] || statusBadges.pending;
  const Icon = config.icon;

  return (
    <div
      className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}
    >
      <Icon className="w-3.5 h-3.5" />
      <span>{config.label}</span>
    </div>
  );
}

Production Deployment & Security Checklist

Before shipping your Next.js e-signature integration to production on Vercel, AWS ECS, or Docker, ensure you have audited these essential configurations:

Security / Config TargetRecommended Production ValueArchitectural Rationale
SIGNBEE_API_KEYEncrypted Env Var (No NEXT_PUBLIC_)Prevents API credential leakage into browser client bundle.
Route Handler Runtimeexport const runtime = "nodejs"Provides native access to Node.js crypto for timingSafeEqual.
Webhook Verificationreq.text() + HMAC-SHA256Guarantees zero mutation of payload formatting prior to hash audit.
Cache InvalidationrevalidatePath / revalidateTagInstantly purges stale document cache on signature completion.

Key Takeaways & Next Steps

By combining Next.js 15+ App Router primitives with Signbee's REST API, you eliminate hundreds of lines of legacy middleware, avoid cumbersome third-party dependencies, and deliver a blazing-fast, cryptographically verifiable signing experience:

  • Zero NPM Bloat: No vendor SDK needed; standard fetch and Zod handle everything.
  • Rock-Solid Security: API keys remain isolated within Server Actions, and webhooks enforce timing-safe HMAC checks.
  • Unmatched UX: React 19 optimistic updates remove perceived latency, while Suspense streams court-admissible audit trails without freezing the DOM.

Ready to automate legally binding e-signatures?

Create an account on Signbee to get your API key. Includes 5 free legally binding documents every month with full SHA-256 audit trails.

Frequently Asked Questions

How do Next.js Server Actions securely handle e-signature creation and API keys without leaking secrets?

Next.js Server Actions execute strictly on the server runtime and are never included in client JavaScript bundles. When you invoke a Server Action from a client component, Next.js performs an automated POST request with serialized arguments across an internal protocol endpoint. Sensitive environment variables like process.env.SIGNBEE_API_KEY remain isolated within the server process without the NEXT_PUBLIC_ prefix, preventing any client-side exposure. Furthermore, Server Actions allow you to run server-side Zod validation, sanitize dynamic Markdown contract templates, check user authentication session tokens, and enforce rate limits before initiating outbound requests to the Signbee REST API (https://signb.ee/api/v1/send). This eliminates the need to expose raw API credentials or build intermediary public API routes for standard document mutations.

How do you verify Signbee webhook HMAC signatures and revalidate Next.js App Router cache paths?

In the Next.js App Router, incoming Signbee webhooks are handled by a dedicated Route Handler at app/api/webhooks/signbee/route.ts. To securely verify the payload, you must read the raw unparsed request body as text using req.text() prior to any JSON decoding. Next, extract the x-signbee-signature header and compute an HMAC-SHA256 hash of the raw string using your SIGNBEE_WEBHOOK_SECRET and Node.js crypto.createHmac. You must use crypto.timingSafeEqual to compare the computed digest against the header Buffer to prevent side-channel timing attacks. Once verified and your database status is updated, you call Next.js cache revalidation primitives such as revalidatePath('/dashboard') or revalidateTag('agreement-status') to immediately purge stale cached views across server components without requiring manual client refreshes.

How does cryptographic SHA-256 audit trail verification work in Next.js streaming Suspense components?

Cryptographic audit trail verification in Next.js App Router pairs asynchronous React Server Components (RSC) with React Suspense streaming boundaries. When a signed document is accessed, the outer page renders immediately with cached metadata while a suspended server component asynchronously fetches the raw document payload, inspects Signbee's cryptographic certificate, and recalculates the SHA-256 checksum across document revisions, signer IP addresses, and RFC 3161 timestamps. The computed digest is matched against the public ledger hash returned by the Signbee API. Because this calculation runs within a streamed Server Component, users see an instant interactive layout with skeleton loaders, while the CPU-intensive hash verification streams directly into the DOM upon completion, guaranteeing tamper-proof legal validity without degrading Core Web Vitals (INP or LCP).

Last updated: August 26, 2026 · Michael Beckett is the founder of Signbee and B2bee Ltd.

Related resources