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

@@ -143,6 +143,9 @@ export interface ServerToClientEvents {
clientName?: string;
}) => void;
'document:expired': (payload: { documentId: string }) => void;
'document:cancelled': (payload: { documentId: string }) => void;
'document:rejected': (payload: { documentId: string; signerEmail?: string | null }) => void;
'document:signer:opened': (payload: { documentId: string; signerEmail?: string }) => void;
'document:reminderSent': (payload: { documentId: string; recipientEmail: string }) => void;
// Document template events