Guides

Embedded signing URL expired? Get a fresh one, not a fresh document

A signer who closes the tab comes back to a dead link. The tempting fix - send the template again - quietly creates two copies of the same contract. The right one is a single POST. Next.js App Router example included.

Akbar Ali · 21 October 2026

Here's a person who means well. Dana opens your app, gets to the signing step, and thinks "I'll do this after lunch." The tab closes. After lunch the link is dead, and your app shrugs.

Nobody did anything wrong. Embed URLs live for 30 minutes on purpose: the URL in your page is not the real signing link, just a short-lived stand-in for it, so one that leaks is not much use to anyone. Short life, small blast radius.

The question is what your app does next. And the first answer most of us reach for is the one that makes a mess.

Trap 1: "just send it again"

The user came back and the link is dead. You already have code that produces a working link: the send call. So you call it again.

It works. That's the problem.

Sending the template again does not freshen the old document. It creates a second one. Now your dashboard has two copies of the same NDA, both waiting on Dana. Dana signs one of them. Which one? Whichever. The other sits there forever, or worse, gets signed too - and now you hold two signed copies of one agreement and get to explain to someone which one counts.

Your webhooks don't save you here. Each document fires its own document.completed, with its own id, and both look perfectly legitimate.

Gru's Plan meme. Step one: "Signer closes the tab". Step two: "The link dies in 30 minutes". Step three: "Send the template again". Step four: "Two copies of the same contract".
The fourth panel is your dashboard.

Trap 2: minting the link too early

The quieter version of the same bug. Your page loads at 2:00, your code creates the signing session at 2:00, and Dana reads your carefully written terms until 2:41. Clicks sign. Dead link.

Thirty minutes is plenty for someone who is ready, and nothing for someone who is still deciding. So the rule is: create the session at the moment of intent. The user pressed the button - that is when the clock should start, not when the page rendered.

In a Next.js App Router app this falls out naturally once you stop pre-baking the URL into the page: the button calls your server, the server talks to the API, the iframe mounts with what comes back. The 30 minutes almost never comes up.

Almost.

Trap 3: treating the URL as something to keep

Once you know the link dies, the next instinct is to save it. Put it in the database next to the user, refresh it on a cron, keep one warm just in case.

Don't. The thing worth keeping is the document id. That is the stable handle: it is the same before the first URL, after the tenth, and after the signer finally signs. The URL is a paper cup. The document id is the cupholder.

The fix: ask for a fresh batch

This is exactly what POST /v1/documents/:id/embed-sessions is for. Nothing is created. The document already exists, the endpoint just mints a new set of 30-minute URLs for whoever still needs to sign. Call it twice in a row and nothing changes - no duplicates, no side effects, just two fresh answers to the same question.

curl -X POST https://putmysign.com/v1/documents/doc_2f81.../embed-sessions \
  -H "Authorization: Bearer $PUTMYSIGN_KEY"
{
  "document_id": "doc_2f81...",
  "expires_in": 1800,
  "sessions": [
    {
      "recipient_id": "rcp_b4c1...",
      "role_id": "role_a1",
      "email": "dana@acme.com",
      "url": "https://putmysign.com/embed/sign/GfT9…"
    }
  ]
}

You get one URL per recipient who can still sign. If Dana already signed and only the countersignature is left, Dana is not in the list - which is also your answer to "wait, did Dana sign already?"

In a Next.js app, the whole thing is one small route handler:

// app/api/signing-url/route.ts
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { documentId } = await req.json();

  const res = await fetch(
    `https://putmysign.com/v1/documents/${documentId}/embed-sessions`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.PUTMYSIGN_KEY}` },
    },
  );

  // Completed, declined, or the signing window closed: show that
  // state in your UI instead of an iframe.
  if (res.status === 422) {
    return NextResponse.json({ state: "closed" });
  }
  if (!res.ok) {
    return NextResponse.json({ state: "error" }, { status: 502 });
  }

  const batch = await res.json();
  // One signer here. With several, pick the session whose email
  // belongs to the person using this browser session.
  return NextResponse.json({ state: "ready", url: batch.sessions[0].url });
}

And the button Dana clicks after lunch:

"use client";

export function ContinueSigning({ documentId }: { documentId: string }) {
  async function handleClick() {
    const res = await fetch("/api/signing-url", {
      method: "POST",
      body: JSON.stringify({ documentId }),
    });
    const data = await res.json();
    if (data.url) window.location.href = data.url;
    // data.state === "closed": the document is done - render that instead.
  }

  return <button onClick={handleClick}>Continue signing</button>;
}

The API key never leaves the server. The browser only ever holds a URL that is useless in 30 minutes anyway.

The endpoint answers 422 invalid_request for anything no longer open for signing - a document still in draft, one that completed, one that was declined, one whose signing window closed. Each of those is a screen your app already has, or should: a receipt, a polite "this was declined", a "this offer expired". All better than a blank iframe.

The calls above run against the real endpoint handler in PutMySign's own test suite - the 200, the 422s, the 404s, the repeat-call idempotency. The Next.js glue is the smallest version that works; add your own auth around it the way you would for any route that touches a user's documents.

The short list

  • Embed URLs die after 30 minutes. That is a feature: a leaked URL is a small problem.
  • Create the session at the moment of intent, not at page load.
  • Store the document id, never the URL.
  • A dead link gets POST /v1/documents/:id/embed-sessions, never a second send.
  • A 422 is a UI state, not an error page.

The API reference has every field on the endpoint. If you are still setting up the signing step itself, start with embedded signing in Next.js - this guide is the chapter that comes after lunch. And the security guide covers why the URL is built to expire in the first place.

Try it on one document

Create a template, send it with embed: true from a test key, and let the link sit for half an hour. Then call the endpoint and watch the same document hand you a fresh URL. Test keys skip email, so the only person in your inbox is you.