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>
14 lines
542 B
TypeScript
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;
|
|
}
|
|
}
|