Guides

Embedded signing in React, minus the bug where everything fires twice

Add embedded signing to a React app: create the session on your server, mount the signing iframe in a component, and handle onSigned without the remounts and leaked listeners that bite everyone.

Akbar Ali · 25 September 2026

You've got a React app. Somewhere in it there's a step where the user has to sign something. Terms, an agreement, whatever.

And you'd really like them to sign it right there. Not in their inbox. Not on some other company's website with a different logo and a different font.

That's embedded signing. The signing page runs in an iframe inside your app, and your code finds out when they're done.

The idea is simple. The React part has about three ways to go quietly wrong, and I've watched all three happen. So here's the whole thing, with the traps marked.

The three pieces

  1. Your server asks us for a signing session. It gets a URL back.
  2. Your page loads a tiny script, once.
  3. A component puts that URL in a box and listens for "signed".

That's all of it. No SDK, no provider wrapping your app, no build plugin.

1. Make the session on your server

Not in the browser. Your API key can send documents on your account, and anything you ship to the browser is public. Everybody knows this, and I'm still saying it, because I've seen the network tab.

You send one of your saved templates with embed: true. We skip the emails and give you a URL per recipient:

// server: POST /api/signing-session
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 },
        ],
      }),
    },
  );

  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) => s.email === user.email);
  return { documentId: doc.id, url: session.url };
}

Hand the browser the url and the documentId. Nothing else. The rest of that response is yours, keep it on the server.

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 actually reaches the signing step. Not at signup, not in a background job an hour earlier.

2. Load the script once

The script is honestly just an iframe and a message listener. You could skip it and render the iframe yourself. But then you don't find out when they've signed, which is kind of the point.

The laziest correct way is a script tag in your index.html:

<script src="https://putmysign.com/embed.js" defer></script>

If you'd rather only load it on the one page that needs it, wrap it in a promise so two components asking at the same time don't add it twice:

let loading: Promise<void> | undefined;

export function loadPutmysign() {
  if (window.Putmysign) return Promise.resolve();
  loading ??= new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://putmysign.com/embed.js";
    script.onload = () => resolve();
    script.onerror = () => {
      loading = undefined; // let the next attempt actually retry
      reject(new Error("Could not load embed.js"));
    };
    document.head.appendChild(script);
  });
  return loading;
}

3. The component

Here's the version you'll write first. I know because it's the version in everyone's first draft:

// Don't ship this one.
function SignBox({ url, onSigned }) {
  const box = useRef(null);

  useEffect(() => {
    window.Putmysign.mount(box.current, { url, onSigned });
  }, [url, onSigned]);

  return <div ref={box} />;
}

Looks fine. It has two bugs, and neither of them throws.

Bug one: no destroy(). mount hands you back a handle, and that handle has a destroy(). If you don't call it, the old iframe and the old listener hang around every time the effect runs again. Next session, every handler fires twice. You go "why did it save twice" and lose an afternoon. I found this out the way everybody finds this out.

Spider-Man pointing at Spider-Man meme. Spider-Man on the left: "onSigned". Spider-Man on the right: "onSigned from the session you never destroyed".
Both of them are real. Both of them will save.

React's StrictMode makes it worse, in a useful way. In development it runs your effect, cleans it up, and runs it again, on purpose, to catch exactly this. No cleanup means two iframes stacked in the box. At least you'll see that one.

Bug two: onSigned in the dependency array. Your parent almost certainly passes it like this:

<SignBox url={url} onSigned={(e) => markSigned(e.documentId)} />

That's a brand new function on every render. So every render re-runs the effect. With destroy() in place you don't get duplicates any more. Instead the iframe reloads, and the signer loses whatever they'd drawn. Parent re-renders because a toast popped up? Signature gone.

You could tell everyone to wrap it in useCallback. People won't. So don't let the effect depend on it at all. Keep the callbacks in a ref and let the effect care about one thing, the URL:

Drake Hotline Bling meme. Drake rejecting: "Ask every caller to wrap onSigned in useCallback". Drake approving: "Keep the callbacks in a ref".
import { useEffect, useRef } from "react";
import { loadPutmysign } from "./load-putmysign";

type Props = {
  url: string;
  onSigned?: (e: { documentId: string }) => void;
  onDeclined?: (e: { documentId: string }) => void;
  height?: number;
};

export function SignBox({ url, onSigned, onDeclined, height = 800 }: Props) {
  const box = useRef<HTMLDivElement>(null);

  // Always the latest callbacks, without them being a reason to remount.
  const handlers = useRef({ onSigned, onDeclined });
  handlers.current = { onSigned, onDeclined };

  useEffect(() => {
    let session: { destroy(): void } | undefined;
    let cancelled = false;

    loadPutmysign().then(() => {
      if (cancelled || !box.current) return;
      session = window.Putmysign.mount(box.current, {
        url,
        height,
        onSigned: (e) => handlers.current.onSigned?.(e),
        onDeclined: (e) => handlers.current.onDeclined?.(e),
      });
    });

    return () => {
      cancelled = true;
      session?.destroy();
    };
  }, [url, height]);

  return <div ref={box} />;
}

That cancelled flag is the third trap, by the way. The script loads async. If the component unmounts before it finishes, you'd mount an iframe into a box that's already gone.

The iframe should only ever reload because the URL changed. If anything else can reload it, something eventually will, right when someone's halfway through their signature.

Using it:

function AgreementStep({ next }) {
  const [session, setSession] = useState(null);

  useEffect(() => {
    fetch("/api/signing-session", { method: "POST" })
      .then((r) => r.json())
      .then(setSession);
  }, []);

  if (!session) return <p>Getting your agreement ready…</p>;

  return (
    <SignBox
      url={session.url}
      onSigned={() => next()}
      onDeclined={() => next({ declined: true })}
    />
  );
}

Careful with that useEffect fetch, though. Same StrictMode double-run, except this time each run is a real send. That's two documents on your account every time you open the page in dev. Fetch the session in your router's loader, or through whatever data library you already use, so it happens once per visit. And use a test key while you're building.

onSigned is not "done"

Two things people get wrong here, and both only show up in production.

First, onSigned means this person signed. If the template has someone else on it, your legal team countersigning say, the document isn't finished yet. For "it's actually done now", listen 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 the billing, should check on your server. Either the webhook, or ask the API:

curl https://putmysign.com/v1/documents/doc_2f81... \
  -H "Authorization: Bearer $PUTMYSIGN_KEY"

Let the browser be fast, let the server be right.

And if the box is just blank

It's the origins. It's always the origins.

Always Has Been meme. Astronaut looking at Earth: "Wait, it's the origins?". Astronaut behind him: "Always has been". Earth: "My blank iframe".

Every key has a list of sites allowed to frame it, under Developers. A key with no origins set can't be embedded anywhere. Not even on your own site. That's deliberate. Defaulting to open would mean a half-finished setup is a signing page any website can wrap.

So add your production origin, and your dev one too, like http://localhost:5173. Use exactly what's in the address bar: scheme, host, port.


That's the whole integration. A server call, one script, one component, and a ref so it doesn't fall over. The API reference has every field, and the embedded signing page shows what your user actually sees.

Curious how people handle the "they wandered off and came back an hour later" case in their flows. Do you keep them on the step and quietly re-create the session, or bounce them back a step so it feels intentional?