Sessão de Assinatura Expirada: Reenviar, Cancelar ou Recriar (Runbook da API)

Toda sessão de assinatura tem prazo. Quando ele vence, o link deixa de funcionar, a plataforma marca a sessão como EXPIRED e avisa o seu sistema por webhook. A partir daí só existe um caminho: uma sessão nova. Este runbook organiza as decisões que a sua integração precisa tomar antes e depois desse momento: quando reenviar o convite, quando cancelar, quando recriar, o que dizer ao signatário e como detectar que a taxa de expiração está alta antes que vire um problema comercial.

O erro típico não é deixar sessões expirarem, isso é normal em qualquer fluxo com pessoas. O erro é o sistema não saber o que fazer quando isso acontece: jobs que tentam reenviar um link morto, contratos que ficam "pendentes" para sempre no CRM, signatários que recebem um link vencido. Cada um desses tem uma ação correta, e ela depende do estado exato da sessão.

O ciclo de vida, em uma linha

Uma sessão nasce ACTIVE na resposta de POST /v1/signing-sessions, com um campo expiresAt. Por padrão a janela é de 72 horas; você pode definir expiresInMinutes entre 5 minutos e 7 dias (10080) na criação, tanto em sessões quanto em envelopes. A partir daí há exatamente quatro desfechos, e os três últimos são finais:

Estado Como chega lá Webhook Reversível?
ACTIVE Criação; o link está válido. SIGNING_SESSION.CREATED É o único estado em que você ainda age.
COMPLETED O signatário concluiu todas as etapas do perfil. SIGNING_SESSION.COMPLETED Não. Baixe o documento e as evidências.
CANCELLED Você chamou POST /v1/signing-sessions/{id}/cancel. SIGNING_SESSION.CANCELLED Não.
EXPIRED expiresAt passou e a varredura da plataforma marcou a sessão. SIGNING_SESSION.EXPIRED Não. Só uma sessão nova.

A expiração não é instantânea no segundo exato de expiresAt: um processo agendado roda a cada 15 minutos, encontra as sessões vencidas, marca-as como EXPIRED, invalida o link e dispara o webhook. A transação correspondente também passa a EXPIRED. Na prática isso significa duas coisas: o webhook pode chegar até um quarto de hora depois do prazo, e o seu sistema não deve calcular "expirou" só pelo relógio; a fonte de verdade é o status na API.

Por que EXPIRED é final

Um link de assinatura é, ao mesmo tempo, o endereço da página e a credencial que dá acesso a ela (o clientSecret vai na URL). Reabrir uma sessão vencida significaria reativar uma credencial que já circulou por e-mail, WhatsApp ou SMS por dias, e que pode ter sido encaminhada, copiada ou vazada. A plataforma prefere o comportamento previsível: o prazo que você definiu na criação é o prazo, e o que vem depois é uma sessão nova, com link novo, secret novo e trilha de auditoria própria. O mesmo raciocínio vale para CANCELLED.

Não existe "estender" nem "reativar". Nenhum endpoint altera expiresAt de uma sessão existente. Se a janela padrão de 72 horas está curta para o seu caso (contratos que dependem de uma reunião, signatários que viajam), a solução é criar a próxima sessão com expiresInMinutes maior, até o limite de 7 dias, e não tentar prolongar a atual.

Árvore de decisão

Antes de qualquer ação, consulte o estado real com GET /v1/transactions/{id} (ou o status da sessão). Depois siga o ramo correspondente.

Ainda ACTIVE: o signatário não assinou, mas o prazo não venceu

  • Reenviar o convite: POST /v1/signing-sessions/{id}/resend-invite. Dispara novamente o e-mail com o mesmo link. Exige que a sessão tenha o e-mail do signatário registrado (sem ele, a API devolve 409 dizendo isso) e é limitado a uma chamada por minuto: uma segunda tentativa antes disso devolve 409 com a mensagem "Aguarde Ns antes de reenviar". Trate o 409 como informação, não como erro a retentar em loop.
  • Entregar o link por outro canal: se o convite por e-mail não está chegando, o link (url?cs=clientSecret) continua válido e pode ser enviado pelo seu próprio canal. Lembre-se de que, no perfil CLICK_ONLY, o link é a autenticação inteira: só entregue por um canal que você já sabe ser do signatário.
  • Desistir: POST /v1/signing-sessions/{id}/cancel. Use quando o documento mudou, o negócio caiu ou você vai recriar com outro perfil de autenticação. Cancelar antes de recriar evita duas sessões vivas para o mesmo contrato.

EXPIRED: o prazo venceu

  • Recriar a sessão. Uma nova chamada a POST /v1/signing-sessions, com um X-Idempotency-Key novo. Se você reaproveitar a chave da sessão original, a API devolve a resposta antiga (a sessão expirada) em vez de criar outra: a chave é lembrada por 24 horas. Uma convenção que funciona é contrato-{id}-tentativa-{n}.
  • O documento precisa ir de novo. A sessão nova gera uma transação nova, e ela precisa do seu próprio documento: reenvie o mesmo PDF em document.content ou pelo fluxo de upload pré-assinado. Reenvie o arquivo original que você guardou, e não um PDF baixado de algum lugar; o hash que você registrou no primeiro envio deve ser o mesmo.
  • Considere uma janela maior ou um perfil diferente. Se a sessão expirou porque o signatário não conseguiu completar a biometria, por exemplo, a segunda tentativa pode ser criada com BIOMETRIC_DOCUMENT_FALLBACK ou, para casos de menor risco, CLICK_PLUS_OTP. A escolha do perfil é sua, por signatário.
  • Feche o registro antigo. No seu sistema, a tentativa expirada deve ficar registrada como tal (com o transactionId), e o contrato deve apontar para a nova. Não sobrescreva; o histórico de tentativas é útil quando o cliente pergunta "por que recebi dois links".

CANCELLED ou COMPLETED

Nada a reenviar. Em COMPLETED, o próximo passo é o download do documento e das evidências. Em CANCELLED, o cancelamento foi seu; se foi indevido, o caminho é o mesmo do expirado: sessão nova.

Envelopes: expiração do conjunto versus de um signatário

Em um envelope (POST /v1/envelopes), o expiresInMinutes vale para o envelope inteiro. Quando ele vence, a plataforma emite ENVELOPE.EXPIRED, e as sessões que ainda estavam ACTIVE deixam de valer. Quem já tinha assinado não perde a assinatura como evidência, mas o documento consolidado com todas as partes não existe: o envelope não chegou a ENVELOPE.ALL_SIGNED.

As decisões mudam um pouco:

  • Um signatário atrasado, envelope ainda válido: resend-invite na sessão dele, com a mesma regra de um reenvio por minuto. No modo SEQUENTIAL, lembre-se de que o convite do próximo só sai quando o anterior assina; reenviar para quem ainda não é a vez não adianta.
  • Envelope expirado: um envelope novo, com todos os signatários de novo, inclusive os que já tinham assinado. É o preço de um documento único assinado por todos. Dimensione expiresInMinutes pensando no signatário mais lento da cadeia, não no primeiro; em fluxos sequenciais com três ou mais partes, os 7 dias costumam ser necessários.
  • Cancelar cedo: se um signatário informa que não vai assinar, cancele o envelope em vez de esperar a expiração. Isso libera o seu fluxo e evita convites a quem não deveria mais recebê-los.

Os detalhes de ordem, signerIndex e comportamento de cada modo estão em múltiplos signatários e ordem de assinatura.

O que dizer ao signatário

A mensagem que acompanha o segundo link decide se ele será assinado. Três regras:

  1. Diga que o link anterior venceu e que este é o novo. Quem recebe dois e-mails sem explicação tende a abrir o primeiro, ver um erro e desistir.
  2. Informe o prazo novo em data e hora locais, calculado a partir do expiresAt devolvido na criação. "Válido até sexta, 18h" converte melhor que "72 horas".
  3. Não peça para "tentar de novo o mesmo link". Ele está morto por desenho; a página hospedada informa isso ao signatário, mas o convite do seu sistema não deve contradizer.

Se o mesmo signatário expira duas vezes, a causa raramente é técnica: e-mail errado, spam, ou a pessoa não reconhece o remetente. Vale um contato humano antes da terceira sessão.

Monitoramento: descobrir antes do cliente

Dois sinais valem o alerta:

  • Taxa de SIGNING_SESSION.EXPIRED sobre o total de sessões criadas, por dia e por tipo de documento. Um salto costuma indicar problema de entrega de e-mail (o seu domínio caiu em spam, o remetente mudou) ou um perfil de autenticação mais exigente do que o público consegue cumprir, e não signatários desinteressados.
  • Sessões ACTIVE com mais de dois terços da janela consumida. É o momento de reenviar o convite ou fazer contato, enquanto ainda há o que fazer. Como a plataforma não emite um aviso por sessão prestes a vencer, esse alerta é seu: um job periódico que lê expiresAt das sessões pendentes no seu banco.

Há também o evento TRANSACTION.DEADLINE_APPROACHING, emitido por uma verificação diária quando a transação tem um prazo de submissão registrado e faltam menos de 48 horas para ele. Ele vale para os fluxos que carregam esse prazo; não o use como substituto do seu próprio monitoramento de expiresAt.

Código: o ramo de reconciliação

O trecho abaixo é o worker que trata um contrato pendente, seja porque chegou SIGNING_SESSION.EXPIRED, seja porque o job de reconciliação encontrou um contrato parado há tempo demais. Ele consulta o estado real e escolhe o ramo. O desenho de fila e dedupe que o envolve está em webhooks em fila e reprocessamento.

// worker de reconciliação: decide o que fazer com um contrato ainda não assinado const API = 'https://api.signdocs.com.br'; const H = (token, extra = {}) => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...extra }); export async function reconcile(contract, token) { const tx = await (await fetch(`${API}/v1/transactions/${contract.transactionId}`, { headers: H(token) })).json(); switch (tx.status) { case 'COMPLETED': return queue.add('archive', { transactionId: tx.transactionId }); // webhook perdido; baixar e arquivar case 'IN_PROGRESS': case 'DOCUMENT_UPLOADED': { // ainda ACTIVE do lado da sessão: reenviar se passou de 2/3 da janela e ainda não reenviamos hoje const left = new Date(contract.expiresAt) - Date.now(); if (left > 0 && left < contract.windowMs / 3 && !contract.resentToday) { const r = await fetch(`${API}/v1/signing-sessions/${contract.sessionId}/resend-invite`, { method: 'POST', headers: H(token) }); if (r.status === 409) return log('resend recusado', await r.json()); // throttle de 60 s ou sem e-mail: não retentar em loop return db.contracts.update(contract.id, { resentToday: true }); } return; } case 'EXPIRED': case 'CANCELLED': { if (contract.attempts >= 3) return escalate(contract); // contato humano antes da 4ª sessão const attempt = contract.attempts + 1; const r = await fetch(`${API}/v1/signing-sessions`, { method: 'POST', headers: H(token, { 'X-Idempotency-Key': `contrato-${contract.id}-tentativa-${attempt}` }), // chave NOVA body: JSON.stringify({ purpose: 'DOCUMENT_SIGNATURE', policy: { profile: contract.profile }, signer: { name: contract.signer.name, email: contract.signer.email, cpf: contract.signer.cpf }, document: { content: await readOriginalBase64(contract), filename: contract.filename }, // o mesmo original guardado no 1º envio expiresInMinutes: 10080, // 7 dias: janela maior na 2ª tentativa returnUrl: contract.returnUrl }) }); const s = await r.json(); await db.contracts.update(contract.id, { attempts: attempt, previousTransactionIds: [...contract.previousTransactionIds, contract.transactionId], transactionId: s.transactionId, sessionId: s.sessionId, expiresAt: s.expiresAt, resentToday: false }); return notifySigner(contract, `${s.url}?cs=${s.clientSecret}`, s.expiresAt); // "o link anterior venceu; este vale até ..." } } }

Repare que o worker nunca decide pelo relógio local: ele lê o status na API e só então age. Isso o torna seguro para reprocessamento e indiferente ao atraso de até 15 minutos da varredura de expiração. Os códigos 409 aparecem em três lugares (reenvio dentro do minuto, sessão sem e-mail, sessão que não está mais ACTIVE), e nos três a resposta certa é registrar e seguir, não retentar; a lista completa está em códigos de erro e tratamento de falhas.

Checklist

  • Guardo expiresAt de cada sessão no meu banco e tenho um job que olha para ele.
  • Meu tratamento de SIGNING_SESSION.EXPIRED e ENVELOPE.EXPIRED fecha a tentativa e abre a próxima, sem sobrescrever o histórico.
  • Toda recriação usa um X-Idempotency-Key novo e reenvia o documento original guardado.
  • Reenvio de convite respeita o intervalo de um minuto e trata 409 como informação.
  • Envelopes são criados com janela pensada para o signatário mais lento; em sequenciais longos, 7 dias.
  • A mensagem ao signatário diz que o link anterior venceu e informa o prazo novo em hora local.
  • Tenho um alerta para a taxa diária de expiração por tipo de documento.

Perguntas Frequentes

Quanto tempo um link de assinatura fica válido?

Por padrão, 72 horas a partir da criação da sessão. Na chamada POST /v1/signing-sessions (e em POST /v1/envelopes) você pode definir expiresInMinutes entre 5 minutos e 7 dias (10080). A resposta devolve expiresAt, que é o valor a guardar no seu banco e a mostrar ao signatário em hora local. Passado o prazo, um processo agendado que roda a cada 15 minutos marca a sessão como EXPIRED, invalida o link e dispara o webhook SIGNING_SESSION.EXPIRED; por isso o aviso pode chegar alguns minutos depois do horário exato.

Dá para estender ou reativar uma sessão que expirou?

Não. Nenhum endpoint altera o expiresAt de uma sessão existente, e uma sessão EXPIRED (ou CANCELLED) não volta a ACTIVE. O link carrega a credencial de acesso na própria URL e já circulou por e-mail ou mensagem; reativá-lo seria reativar uma credencial potencialmente encaminhada ou vazada. O caminho é criar uma sessão nova, com link e secret novos, e, se a janela padrão foi curta para o seu caso, criá-la com expiresInMinutes maior, até 7 dias.

Quando devo usar o resend-invite?

Somente enquanto a sessão está ACTIVE e o signatário ainda não assinou. POST /v1/signing-sessions/{id}/resend-invite reenvia o e-mail de convite com o mesmo link. Ele exige que a sessão tenha o e-mail do signatário registrado e aceita no máximo uma chamada por minuto; fora dessas condições a API devolve 409 com a explicação. Trate o 409 como informação e não retente em loop. Se a sessão já expirou, o reenvio devolve 409 também: o remédio é recriar, não reenviar.

Como recrio a sessão sem duplicar por engano?

Use um X-Idempotency-Key novo para a nova sessão. A chave da chamada original é lembrada por 24 horas: se você a reaproveitar, a API devolve a resposta antiga (a sessão expirada) em vez de criar outra. Uma convenção que funciona é contrato-{id}-tentativa-{n}. A nova sessão gera uma transação nova, então o documento precisa ser enviado de novo, de preferência o mesmo arquivo original que você guardou no primeiro envio, para que o hash registrado continue batendo.

O que acontece com um envelope quando ele expira?

O expiresInMinutes vale para o envelope inteiro. Ao vencer, a plataforma emite ENVELOPE.EXPIRED e as sessões ainda ACTIVE deixam de valer. Quem já assinou não perde a assinatura como evidência, mas o documento único com todas as partes não existe, porque o envelope não chegou a ENVELOPE.ALL_SIGNED. Para concluir, é preciso um envelope novo com todos os signatários. Por isso a janela deve ser dimensionada pelo signatário mais lento da cadeia; em fluxos sequenciais longos, os 7 dias costumam ser necessários.

Como percebo que muitas sessões estão expirando?

Acompanhe a taxa diária de SIGNING_SESSION.EXPIRED sobre o total de sessões criadas, por tipo de documento. Um salto costuma apontar entrega de e-mail (domínio em spam, remetente alterado) ou um perfil de autenticação mais exigente do que o público consegue cumprir. Complemente com um job que lê o expiresAt das sessões pendentes e alerta quando dois terços da janela passaram: é a hora de reenviar o convite ou fazer contato, enquanto ainda há o que fazer. A plataforma não emite um aviso por sessão prestes a vencer; esse monitoramento é seu.

Simule expiração e reenvio no sandbox

Crie sessões com expiresInMinutes de 5 minutos no ambiente de homologação, receba o SIGNING_SESSION.EXPIRED e teste o seu worker de reconciliação de ponta a ponta, sem cartão. Em produção, o plano é dimensionado com o time comercial.

Criar credenciais de homologação Fale com o time comercial