Ask Anvil

Answers to questions about automating PDFs, e-signatures, Webforms, and other paperwork problems.
E-signatures
Categories

Why does my embedded signing URL expire before the signer opens it, and how do I fix it?

You create an embedded signing session on your server, save the URL it returns on the record, and render it into an iframe or drop it into an email. It works while you are testing, because you click within seconds. In production it fails for exactly the people you care about: the signer who opens the email an hour later, the one who refreshes the page, the one who comes back after lunch. They get an error screen instead of a document.

The cause: that URL is a one-time session token

The URL an e-signature API hands back is not a durable link to the document. It is a credential for one signing session, and the clocks are short. Docusign's API reference for EnvelopeViews:createRecipientView says the returned URL can be used only once, expires after 5 minutes, and should not be stored or emailed. Dropbox Sign gives you more room with the same shape: a sign_url is valid for 60 minutes after you request it, and expires as soon as it is accessed.

So any design that mints the URL early and uses it later is racing a clock you do not control. Writing it to a database column, baking it into server-rendered HTML, holding it in frontend state across a refresh, or emailing it to the signer all produce the same failure.

The fix: mint the URL at the moment of use

Store the durable identifiers instead: the envelope or signature request ID, plus the per-signer ID. Those do not expire. Then put a route in your own app that trades them for a fresh signing URL on every request, and give signers that route rather than the vendor's. This is what Docusign's own documentation recommends for email invitations: point the link at your application, have it request a recipient view URL, and redirect the signer to it.

// GET /sign/:token  ->  mint a fresh signing session, then redirect the signer in.
app.get('/sign/:token', async (req, res) => {
  const session = await db.signingSessions.findByToken(req.params.token)
  if (!session) return res.sendStatus(404)
  if (session.completedAt) return res.status(410).send('This document is already signed.')

  // esign is your own thin wrapper over the vendor SDK. The stored IDs are durable;
  // the URL it returns is not, so it is created here and used immediately.
  const signUrl = await esign.createSigningUrl({
    requestId: session.requestId,
    signerId: session.signerId,
    returnUrl: `${process.env.APP_URL}/sign/${req.params.token}/done`,
  })

  res.redirect(signUrl)
})

Every visit gets a new session, so a refresh, a back button, or a link opened tomorrow all still work. Nothing long-lived leaves your server except your own token, which you control and can revoke.

Two things to get right

Make your token random and unguessable, not a sequential row ID. Whoever holds it can open a signing session, so treat it as a bearer credential: give it your own expiry, and pair it with a login check when the signer is already an authenticated user in your app.

Handle "cannot mint right now" as its own state. Regeneration is not always allowed. Dropbox Sign returns HTTP 409 when that signer has already signed, or when ordered signing means it is not their turn yet. Docusign reports a used or timed-out token back to your returnUrl as event=ttl_expired. Both deserve a real message about what happened rather than a generic error page.

Back to All Questions

The fastest way to build software for documents

Anvil Document SDK is a comprehensive toolbox for product teams launching document flows where PDF filling, signing, and complex conditional scenarios are necessary.
Explore Anvil
Anvil Webforms