Files
pn-new-crm/src/lib/services/documenso-webhook.ts
Matt Ciaccio c4085265ff 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>
2026-04-27 13:46:48 +02:00

14 lines
542 B
TypeScript

import { timingSafeEqual } from 'crypto';
// 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(provided), Buffer.from(expected));
} catch {
return false;
}
}