fix(documenso): align webhook receiver with Documenso v1.13 + 2.x protocol

Documenso authenticates outbound webhooks via the X-Documenso-Secret
header carrying the plaintext secret (no HMAC). The previous receiver
verified an HMAC against a non-existent x-documenso-signature header
and switched on parsed.type, neither of which Documenso emits — so
every real delivery was being silently rejected.

- Read X-Documenso-Secret, compare timing-safe to env secret
- Switch on parsed.event with uppercase normalization for both v1.13
  (DOCUMENT_SIGNED) and 2.x (lowercase-dotted UI labels) wire formats
- Alias DOCUMENT_RECIPIENT_COMPLETED to DOCUMENT_SIGNED (same
  semantics across versions)
- Handle DOCUMENT_OPENED / DOCUMENT_REJECTED / DOCUMENT_CANCELLED in
  addition to the existing DOCUMENT_SIGNED + DOCUMENT_COMPLETED paths
- Bypass session middleware for /api/webhooks/* (signature is the auth)

Verified end-to-end against signatures.letsbe.solutions: real
DOCUMENT_RECIPIENT_COMPLETED + DOCUMENT_COMPLETED deliveries now pass
secret verification, dispatch correctly, and the handler updates
state (or warns gracefully when the documensoId is unknown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-27 13:46:48 +02:00
parent 475b051e29
commit c4085265ff
5 changed files with 225 additions and 41 deletions

View File

@@ -1,14 +1,12 @@
import { createHmac } from 'crypto';
import { timingSafeEqual } from 'crypto';
export function verifyDocumensoSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const hmac = createHmac('sha256', secret).update(payload).digest('hex');
// Documenso (v1.13 + 2.x) authenticates outbound webhooks by sending the
// configured secret in plaintext via the `X-Documenso-Secret` header.
// There is no HMAC. Compare the provided value timing-safely to the env secret.
export function verifyDocumensoSecret(provided: string, expected: string): boolean {
if (!provided || provided.length !== expected.length) return false;
try {
return timingSafeEqual(Buffer.from(hmac), Buffer.from(signature));
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
} catch {
return false;
}