Embedded signing in Next.js (App Router), minus the double-send
Add embedded signing to a Next.js App Router app: create the session in a Server Component, mount the signing iframe in a client component, and treat the webhook, not onSigned, as the real done.
Akbar Ali · 5 October 2026
You're building in Next.js. Somewhere in the flow, the user has to sign something. Terms, an agreement, an NDA.
And you'd like them to sign right there. Not in their inbox. Not on another company's page with a different logo.
That's embedded signing. The signing page runs in an iframe inside your app, and your code finds out when they're done.
In the App Router it's three pieces: a server call that makes the session, a client component that shows it, and a webhook route that says when it's really finished. The first two are easy. The third is where people get it wrong, and the App Router adds one extra way to send two documents by accident. Here's all of it.
Before you write any code
Three things in the dashboard:
- A template. The PDF, who signs it, and where. You build it once. Your app only says who the signers are this time.
- An API key under Developers. It stays on the server.
- Your site's origin on that key's embed origins list.
Number three is the one that gets skipped, so I'll say it now to get it out of the way.

A key with no origins set can't be embedded anywhere. Not even your own site. That's on purpose. Add your production origin and your dev one, like http://localhost:3000. Use exactly what's in the address bar: scheme, host, port.
You also need the template id and the signer's role id. Both come from GET /v1/templates.
1. Make the session on the server
Not in the browser. The key can send documents on your account, and anything you ship to the client is public.
In the App Router you don't even need an API route for this. A Server Component can call it directly. First, a small helper:
// lib/signing.ts
import "server-only";
export async function createSigningSession(user: { name: string; email: string }) {
const res = await fetch(
`https://putmysign.com/v1/templates/${process.env.AGREEMENT_TEMPLATE_ID}/send`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PUTMYSIGN_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
embed: true,
title: `Service agreement - ${user.name}`,
recipients: [
{ role_id: process.env.SIGNER_ROLE_ID, email: user.email, name: user.name },
],
}),
cache: "no-store",
},
);
if (!res.ok) {
const { error } = await res.json();
throw new Error(error.code); // switch on the code, not the message
}
const doc = await res.json();
const session = doc.embed.sessions.find((s: { email: string }) => s.email === user.email);
return { documentId: doc.id as string, url: session.url as string };
}embed: true skips the signing email and hands you a URL per recipient instead. The browser gets the url and the documentId. Nothing else. The rest of that response is yours.
Embed URLs last 30 minutes. They're a short-lived stand-in for the real signing link, on purpose. So create the session when the user reaches the signing step. Not at signup, not in a job an hour earlier.
2. The page: don't fetch this in a useEffect
You know the instinct. Client component, useEffect, fetch("/api/signing-session"). Don't.
React Strict Mode runs effects twice in dev, on purpose. Here each run is a real send. Two documents on your account every time you open the page. Fun to find in the dashboard.

Make the session in a Server Component instead. It renders once per request, so it sends once per visit:
// app/agreement/page.tsx
import { createSigningSession } from "@/lib/signing";
import { SignBox } from "@/components/SignBox";
import { getCurrentUser } from "@/lib/auth"; // your auth, whatever it is
export const dynamic = "force-dynamic"; // a new session per visit, never a cached one
export default async function AgreementPage() {
const user = await getCurrentUser();
const { url } = await createSigningSession(user);
return <SignBox url={url} />;
}Use a test key while you build. It runs the whole flow and never emails anyone.
3. The client component
Load the script once, in your root layout:
// app/layout.tsx
import Script from "next/script";
// inside <body>, after {children}:
<Script src="https://putmysign.com/embed.js" strategy="afterInteractive" />Now the component. Here's the version everyone writes first:
// Don't ship this one.
"use client";
function SignBox({ url, onSigned }) {
const box = useRef(null);
useEffect(() => {
window.Putmysign.mount(box.current, { url, onSigned });
}, [url, onSigned]);
return <div ref={box} />;
}Looks fine. Neither bug throws.
First, no destroy(). mount gives you a handle. If you never call destroy(), the old iframe and the old listener stay alive every time the effect runs again. Next session, every handler fires twice. Strict Mode makes it visible in dev as two iframes stacked in the box. At least you'll see that one.

Second, onSigned in the dependency array. Your parent passes a new function on every render, so the effect re-runs, the iframe reloads, and whatever the signer drew is gone. The parent re-rendered because a toast popped up? Signature gone.
Don't make the effect care about anything except the URL:
// components/SignBox.tsx
"use client";
import { useEffect, useRef } from "react";
export function SignBox({
url,
onSigned,
}: {
url: string;
onSigned?: (documentId: string) => void;
}) {
const box = useRef<HTMLDivElement>(null);
const cb = useRef(onSigned);
cb.current = onSigned; // always the latest, never a reason to remount
useEffect(() => {
let session: { destroy(): void } | undefined;
const t = setInterval(() => {
if (!window.Putmysign || !box.current) return; // script not ready yet
clearInterval(t);
session = window.Putmysign.mount(box.current, {
url,
height: 800,
onSigned: (e: { documentId: string }) => cb.current?.(e.documentId),
});
}, 50);
return () => {
clearInterval(t);
session?.destroy();
};
}, [url]);
return <div ref={box} />;
}The interval is there because next/script loads the script after the page is interactive, so window.Putmysign might not exist the first time the effect runs. If you want something tidier, load it with your own promise like the React guide does. Same job.
The iframe should only ever reload because the URL changed. If anything else can reload it, something eventually will, right when someone is halfway through a signature.
You'll want a declare global for window.Putmysign if you're on TypeScript. Two lines.
4. onSigned is not "done"
Two things people get wrong here, and both only show up in production.
First, onSigned means this person signed. If someone else is on the template, say your legal team countersigning, the document isn't finished. For "it's actually done now", wait for the document.completed webhook.
Second, onSigned runs in the browser. It's great for moving your UI along. It's not proof of anything. Anyone can open devtools and call your next(). So whatever unlocks something real, like activating the account or starting billing, has to be decided on your server.

Add an endpoint under Developers and you get a signing secret. Every request we send carries X-Signature: t=<unix seconds>,v1=<hex hmac>. It's an HMAC-SHA256 of ${t}.${rawBody} with your secret.
// app/api/putmysign-webhook/route.ts
import crypto from "node:crypto";
import { NextResponse } from "next/server";
function verify(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
// Older than five minutes? Reject. Nobody gets to replay a captured request tomorrow.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
export async function POST(req: Request) {
const rawBody = await req.text(); // exactly what arrived, not re-stringified
const ok = verify(rawBody, req.headers.get("x-signature") ?? "", process.env.PUTMYSIGN_WEBHOOK_SECRET!);
if (!ok) return new NextResponse("bad signature", { status: 400 });
const event = JSON.parse(rawBody);
// The same event can arrive twice. Check event.id and skip ones you've handled.
// if (await alreadyHandled(event.id)) return NextResponse.json({ ok: true });
if (event.event === "document.completed") {
// event.data.document.download_url is the finished PDF.
// Save it, unlock the account, mark the user as signed.
}
return NextResponse.json({ ok: true }); // 2xx, and quickly
}The classic failure is verifying a body you parsed and stringified again. Use req.text(). If you call req.json() first and turn it back into a string, the signature won't match, and you'll stare at correct-looking code for an hour.

Reply with a 2xx fast. Anything else and we retry five times over about a day, waiting longer each time. That's why the same event can reach you twice, and why you check the id.
5. Get the finished PDF
When the last person signs, document.completed arrives with a download_url. You can also call GET /v1/documents/:id/file. Once everyone has signed you get the finished copy, with the signatures, the signing certificate and the audit trail already in it. There's no second file to download.
The whole thing, short
- Session made on the server, in a Server Component. Not in a
useEffect. - Only
urlanddocumentIdgo to the browser. - Origins set on the key. If the box is blank, it's the origins.
destroy()on cleanup, callback in a ref.- Session created when the user reaches the step, not before. 30 minutes.
onSignedfor the UI.document.completedfor anything real.- Webhook checked against the raw body, timestamp checked, event id de-duplicated.
The API reference has every field, and the embedded signing page shows what your user actually sees. On React without Next? There's a guide for that. And if you'd rather not build any of this and just email a link, that works too: embedded vs remote signing.
Curious what people do when a user wanders off and comes back after an hour. Re-create the session quietly on the same step, or send them back a step so it feels intentional?