Assinatura Digital em Ruby on Rails: Integração via REST Puro

Sejamos diretos: não há SDK oficial da SignDocs para Ruby — os seis SDKs cobrem Node, Python, PHP, Java, C# e Go. A boa notícia é que a API REST foi desenhada para dispensar SDK: endpoints convencionais, JSON simples, OAuth2 padrão. Este guia entrega a integração Rails completa em REST puro — token com cache thread-safe, sessão em uma chamada, webhook com raw_post e a verificação HMAC manual com o esquema exato ({timestamp}.{corpo}, headers X-SignDocs-Signature e X-SignDocs-Timestamp) — sem mistério e sem dependências além da stdlib.

O client de serviço: token com cache + criação de sessão

# app/services/signdocs_client.rb require "net/http" require "json" class SigndocsClient BASE_URL = ENV.fetch("SIGNDOCS_BASE_URL", "https://api-hml.signdocs.com.br") TOKEN_MUTEX = Mutex.new # Token OAuth2: form-urlencoded no /oauth2/token (com o "2"), expira em 900s def self.access_token TOKEN_MUTEX.synchronize do return @token if @token && @expires_at > Time.now + 60 res = Net::HTTP.post_form( URI("#{BASE_URL}/oauth2/token"), "grant_type" => "client_credentials", "client_id" => ENV.fetch("SIGNDOCS_CLIENT_ID"), "client_secret" => ENV.fetch("SIGNDOCS_CLIENT_SECRET") ) body = JSON.parse(res.body) @token = body.fetch("access_token") @expires_at = Time.now + body.fetch("expires_in") # 900 — respeite a resposta @token end end def self.create_signing_session(signer:, pdf_base64:, filename:, idempotency_key:) uri = URI("#{BASE_URL}/v1/signing-sessions") req = Net::HTTP::Post.new(uri, { "Authorization" => "Bearer #{access_token}", "Content-Type" => "application/json", "X-Idempotency-Key" => idempotency_key # retry de rede não duplica }) req.body = { purpose: "DOCUMENT_SIGNATURE", policy: { profile: "CLICK_PLUS_OTP" }, signer: signer, # { name:, email:, cpf: } document: { content: pdf_base64, filename: filename } }.to_json res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } JSON.parse(res.body) # => sessionId, transactionId, url, clientSecret... end end

Dois detalhes que economizam debugging: o endpoint é /oauth2/token (com o "2") em form-urlencoded, não JSON; e o expires_in é 900 — o mutex em torno do cache evita a corrida de threads do Puma renovando em paralelo. O link do signatário é url + "?cs=" + clientSecret.

O webhook: raw_post e o esquema exato do HMAC

A SignDocs assina cada entrega com HMAC-SHA256 sobre a string {timestamp}.{corpo bruto} e envia dois headers: X-SignDocs-Signature (hex) e X-SignDocs-Timestamp (Unix, segundos), com tolerância anti-replay de 5 minutos. Em Rails:

# app/controllers/webhooks/signdocs_controller.rb class Webhooks::SigndocsController < ActionController::Base # CSRF é para navegadores; webhook se protege com HMAC skip_before_action :verify_authenticity_token TOLERANCE = 300 # segundos def create raw = request.raw_post # corpo BRUTO — nunca params re-serializados signature = request.headers["X-SignDocs-Signature"].to_s timestamp = request.headers["X-SignDocs-Timestamp"].to_s # 1. Anti-replay: timestamp fresco return head :unauthorized if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE # 2. HMAC sobre "timestamp.corpo", comparação timing-safe expected = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("SIGNDOCS_WEBHOOK_SECRET"), "#{timestamp}.#{raw}") return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, signature) event = JSON.parse(raw) # 3. Idempotência: reentregas retornam 200 sem efeito envio = EnvioAssinatura.find_by(transaction_id: event.dig("data", "transactionId")) return head :ok if envio.nil? || envio.ultimo_evento_id == event["id"] case event["eventType"] when "SIGNING_SESSION.COMPLETED" envio.update!(status: "completed", ultimo_evento_id: event["id"]) BaixarDocumentosJob.perform_later(envio.id) # pesado vai para o job when "TRANSACTION.EXPIRED" envio.update!(status: "expired", ultimo_evento_id: event["id"]) end head :ok # 200 rápido, sempre end end

O BaixarDocumentosJob (ActiveJob + Sidekiq/GoodJob/SolidQueue) consulta GET /v1/transactions/{id}/download e /evidence, baixa o PDF assinado e o pacote .p7m pelas URLs temporárias e anexa via ActiveStorage. O controller nunca faz esse trabalho — a plataforma reenvia eventos se você demorar, e sem a checagem de ultimo_evento_id a reentrega processaria em dobro.

O caminho de teste

  • Unidade: a verificação HMAC acima é função pura — teste com fixtures: assinatura válida, inválida, timestamp velho, evento duplicado, tipo desconhecido.
  • Contrato: uma suíte pequena contra o sandbox (credenciais de HML, dados fictícios; TTL de 7 dias — cada teste cria o que consome).
  • Tubulação: POST /v1/webhooks/{webhookId}/test envia um payload assinado de verdade ao seu endpoint — em desenvolvimento, exponha o servidor local com um túnel. A estratégia completa está em como testar do sandbox ao CI.

E antes do go-live, os 12 erros mais comuns — o código deste guia já esquiva os cinco piores por construção.

Perguntas Frequentes

Existe SDK oficial da SignDocs para Ruby?

Não — os SDKs oficiais cobrem TypeScript/Node, Python, PHP, Java, C#/.NET e Go. Para Ruby, o caminho é a API REST pura, e a boa notícia é que ela foi desenhada para isso: endpoints convencionais, JSON simples, autenticação OAuth2 padrão e erros estruturados (RFC 7807). A integração completa em Ruby cabe em três blocos de código — token, criação de sessão e webhook — todos mostrados neste guia. A ausência de SDK muda o volume de código em algumas dezenas de linhas, não a viabilidade.

Como obtenho o token OAuth2 em Ruby?

Um POST application/x-www-form-urlencoded para /oauth2/token (atenção ao 2 no caminho) com grant_type=client_credentials, client_id e client_secret — em Ruby puro, Net::HTTP.post_form resolve; com Faraday, um .post com url_encoded. A resposta traz access_token (JWT ES256) e expires_in de 900 segundos: cacheie o token no processo e renove antes de expirar — um mutex simples em torno do cache evita a corrida de múltiplas threads renovando ao mesmo tempo em servidores como o Puma.

Como verifico a assinatura do webhook manualmente em Rails?

O esquema exato: a SignDocs envia os headers X-SignDocs-Signature (HMAC-SHA256 em hex) e X-SignDocs-Timestamp (Unix, em segundos), e a assinatura cobre a string '{timestamp}.{corpo bruto}'. No controller: leia request.raw_post (nunca params re-serializados), rejeite se o timestamp estiver a mais de 300 segundos do relógio (anti-replay), calcule OpenSSL::HMAC.hexdigest('SHA256', segredo, timestamp + '.' + raw) e compare com ActiveSupport::SecurityUtils.secure_compare — comparação timing-safe. Só então faça o JSON.parse e processe.

O webhook em Rails precisa de algum ajuste de CSRF?

Sim — o mesmo da comunidade Django com outro nome: o protect_from_forgery do Rails bloqueia POSTs externos sem token CSRF. No controller do webhook, use skip_before_action :verify_authenticity_token (ou herde de ActionController::API, que não tem CSRF). Isso é seguro porque a verificação HMAC substitui o CSRF com uma garantia mais forte para este caso: prova criptográfica de que a requisição veio da SignDocs. A combinação proibida é pular o CSRF sem verificar o HMAC.

Onde processar o download do PDF assinado no Rails?

Num job — ActiveJob com Sidekiq, GoodJob ou SolidQueue. O controller do webhook confirma rápido (200) e enfileira; o job consulta GET /v1/transactions/{id}/download e /evidence, baixa o PDF assinado e o pacote .p7m pelas URLs temporárias e anexa ao seu registro (ActiveStorage funciona bem). O par transaction_id + event_id no seu modelo dá a idempotência: reentregas do webhook reencontram o registro já processado e retornam 200 sem efeito.

Vale a pena escrever uma gem interna para isso?

Se você tem mais de um app Rails consumindo a API, sim — um pequeno client interno (uma classe com token cache, create_session, transaction e um verificador de webhook) concentra o conhecimento e os testes num lugar. Para um app só, uma classe de serviço em app/services basta. Em ambos os casos, aponte a base URL por credencial de ambiente (api-hml.signdocs.com.br em desenvolvimento) e rode a suíte de contrato contra o sandbox — cada teste criando o que consome, por causa do TTL de 7 dias.

REST puro, primeira assinatura ainda hoje

Credenciais de homologação gratuitas, endpoints convencionais e o esquema de webhook documentado até o byte — a integração Ruby completa no sandbox, sem cartão.

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