From c4085265ffdfe34ea94006591318dec2c91e7a77 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Mon, 27 Apr 2026 13:46:48 +0200 Subject: [PATCH] fix(documenso): align webhook receiver with Documenso v1.13 + 2.x protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/webhooks/documenso/route.ts | 123 ++++++++++++++++------- src/lib/services/documenso-webhook.ts | 14 ++- src/lib/services/documents.service.ts | 125 ++++++++++++++++++++++++ src/lib/socket/events.ts | 3 + src/middleware.ts | 1 + 5 files changed, 225 insertions(+), 41 deletions(-) diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index beab695..6d24c68 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { createHash } from 'crypto'; import { db } from '@/lib/db'; -import { verifyDocumensoSignature } from '@/lib/services/documenso-webhook'; +import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook'; import { handleRecipientSigned, handleDocumentCompleted, - handleDocumentExpired, + handleDocumentOpened, + handleDocumentRejected, + handleDocumentCancelled, } from '@/lib/services/documents.service'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; @@ -14,39 +16,58 @@ import { logger } from '@/lib/logger'; // BR-024: Dedup via signatureHash unique index on documentEvents // Always return 200 from webhook (webhook best practice) +// Documenso emits Prisma enum names on the wire (e.g. "DOCUMENT_SIGNED"). +// The UI displays them as lowercase-dotted ("document.signed") but the JSON +// body uses the enum value as-is. Normalize both forms in case 2.x ever flips. +function canonicalizeEvent(event: string): string { + return event.toUpperCase().replace(/\./g, '_'); +} + +type DocumensoRecipient = { + email: string; + signingStatus?: string; + readStatus?: string; + signedAt?: string | null; +}; + +type DocumensoWebhookBody = { + event: string; + payload: { + id: number | string; + recipients?: DocumensoRecipient[]; + }; +}; + export async function POST(req: NextRequest): Promise { - let payload: string; + let rawBody: string; try { - payload = await req.text(); + rawBody = await req.text(); } catch { return NextResponse.json({ ok: false }, { status: 200 }); } - // Verify HMAC signature - const signature = req.headers.get('x-documenso-signature') ?? ''; + // Documenso v1.13 + 2.x send the secret in plaintext via X-Documenso-Secret. + const providedSecret = req.headers.get('x-documenso-secret') ?? ''; - if (!verifyDocumensoSignature(payload, signature, env.DOCUMENSO_WEBHOOK_SECRET)) { - logger.warn({ signature }, 'Invalid Documenso webhook signature'); - return NextResponse.json({ ok: false, error: 'Invalid signature' }, { status: 200 }); + if (!verifyDocumensoSecret(providedSecret, env.DOCUMENSO_WEBHOOK_SECRET)) { + logger.warn({ providedLen: providedSecret.length }, 'Invalid Documenso webhook secret'); + return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 200 }); } // Compute deduplication hash - const signatureHash = createHash('sha256').update(payload).digest('hex'); + const signatureHash = createHash('sha256').update(rawBody).digest('hex'); - let parsed: { type: string; payload: Record }; + let parsed: DocumensoWebhookBody; try { - parsed = JSON.parse(payload) as typeof parsed; + parsed = JSON.parse(rawBody) as DocumensoWebhookBody; } catch { logger.warn('Failed to parse Documenso webhook payload'); return NextResponse.json({ ok: false }, { status: 200 }); } - // Dedup: try to insert a sentinel documentEvent with signatureHash - // We need a documentId — if dedup fails at this stage we can't easily check. - // Instead, store the hash lookup on the first real documentEvent insert in handlers. - // Here we just check if this hash was already seen in any event. + // Replay guard: if any event with this hash already exists, skip. try { const existing = await db.query.documentEvents.findFirst({ where: (de, { eq }) => eq(de.signatureHash, signatureHash), @@ -60,33 +81,69 @@ export async function POST(req: NextRequest): Promise { logger.error({ err }, 'Failed to check duplicate webhook'); } + const event = canonicalizeEvent(parsed.event); + const documensoId = String(parsed.payload?.id ?? ''); + const recipients = parsed.payload?.recipients ?? []; + + if (!documensoId) { + logger.warn({ event }, 'Documenso webhook missing payload.id'); + return NextResponse.json({ ok: true }, { status: 200 }); + } + try { - switch (parsed.type) { - case 'RECIPIENT_SIGNED': - await handleRecipientSigned({ - documentId: parsed.payload.documentId as string, - recipientEmail: parsed.payload.recipientEmail as string, + switch (event) { + case 'DOCUMENT_SIGNED': + case 'DOCUMENT_RECIPIENT_COMPLETED': { + // v1.13 fires DOCUMENT_SIGNED per recipient sign; + // 2.x fires DOCUMENT_RECIPIENT_COMPLETED for the same semantics. + const signedRecipients = recipients.filter( + (r) => r.signingStatus === 'SIGNED' || Boolean(r.signedAt), + ); + for (const r of signedRecipients) { + await handleRecipientSigned({ + documentId: documensoId, + recipientEmail: r.email, + signatureHash: `${signatureHash}:signed:${r.email}`, + }); + } + break; + } + + case 'DOCUMENT_OPENED': { + const openedRecipients = recipients.filter((r) => r.readStatus === 'OPENED'); + for (const r of openedRecipients) { + await handleDocumentOpened({ + documentId: documensoId, + recipientEmail: r.email, + signatureHash: `${signatureHash}:opened:${r.email}`, + }); + } + break; + } + + case 'DOCUMENT_COMPLETED': + await handleDocumentCompleted({ documentId: documensoId }); + break; + + case 'DOCUMENT_REJECTED': { + const rejecting = recipients.find((r) => r.signingStatus === 'REJECTED'); + await handleDocumentRejected({ + documentId: documensoId, + recipientEmail: rejecting?.email, signatureHash, }); break; + } - case 'DOCUMENT_COMPLETED': - await handleDocumentCompleted({ - documentId: parsed.payload.documentId as string, - }); - break; - - case 'DOCUMENT_EXPIRED': - await handleDocumentExpired({ - documentId: parsed.payload.documentId as string, - }); + case 'DOCUMENT_CANCELLED': + await handleDocumentCancelled({ documentId: documensoId, signatureHash }); break; default: - logger.info({ type: parsed.type }, 'Unhandled Documenso webhook event type'); + logger.info({ event }, 'Unhandled Documenso webhook event type'); } } catch (err) { - logger.error({ err, type: parsed.type }, 'Error processing Documenso webhook'); + logger.error({ err, event }, 'Error processing Documenso webhook'); } return NextResponse.json({ ok: true }, { status: 200 }); diff --git a/src/lib/services/documenso-webhook.ts b/src/lib/services/documenso-webhook.ts index b62bee9..181d85e 100644 --- a/src/lib/services/documenso-webhook.ts +++ b/src/lib/services/documenso-webhook.ts @@ -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; } diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 794f979..f26962b 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -635,3 +635,128 @@ export async function handleDocumentExpired(eventData: { documentId: string }) { emitToRoom(`port:${doc.portId}`, 'document:expired', { documentId: doc.id }); } + +export async function handleDocumentOpened(eventData: { + documentId: string; + recipientEmail: string; + signatureHash?: string; +}) { + const doc = await db.query.documents.findFirst({ + where: eq(documents.documensoId, eventData.documentId), + }); + if (!doc) { + logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); + return; + } + + const [signer] = await db + .select() + .from(documentSigners) + .where( + and( + eq(documentSigners.documentId, doc.id), + eq(documentSigners.signerEmail, eventData.recipientEmail), + ), + ); + + await db.insert(documentEvents).values({ + documentId: doc.id, + eventType: 'viewed', + signerId: signer?.id ?? null, + signatureHash: eventData.signatureHash ?? null, + eventData: { recipientEmail: eventData.recipientEmail }, + }); + + emitToRoom(`port:${doc.portId}`, 'document:signer:opened', { + documentId: doc.id, + signerEmail: eventData.recipientEmail, + }); +} + +export async function handleDocumentRejected(eventData: { + documentId: string; + recipientEmail?: string; + signatureHash?: string; +}) { + const doc = await db.query.documents.findFirst({ + where: eq(documents.documensoId, eventData.documentId), + }); + if (!doc) { + logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); + return; + } + + let signerId: string | null = null; + if (eventData.recipientEmail) { + const [signer] = await db + .update(documentSigners) + .set({ status: 'declined' }) + .where( + and( + eq(documentSigners.documentId, doc.id), + eq(documentSigners.signerEmail, eventData.recipientEmail), + ), + ) + .returning(); + signerId = signer?.id ?? null; + } + + await db + .update(documents) + .set({ status: 'rejected', updatedAt: new Date() }) + .where(eq(documents.id, doc.id)); + + if (doc.interestId && doc.documentType === 'eoi') { + await db + .update(interests) + .set({ eoiStatus: 'rejected', updatedAt: new Date() }) + .where(eq(interests.id, doc.interestId)); + } + + await db.insert(documentEvents).values({ + documentId: doc.id, + eventType: 'rejected', + signerId, + signatureHash: eventData.signatureHash ?? null, + eventData: { recipientEmail: eventData.recipientEmail ?? null }, + }); + + emitToRoom(`port:${doc.portId}`, 'document:rejected', { + documentId: doc.id, + signerEmail: eventData.recipientEmail ?? null, + }); +} + +export async function handleDocumentCancelled(eventData: { + documentId: string; + signatureHash?: string; +}) { + const doc = await db.query.documents.findFirst({ + where: eq(documents.documensoId, eventData.documentId), + }); + if (!doc) { + logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); + return; + } + + await db + .update(documents) + .set({ status: 'cancelled', updatedAt: new Date() }) + .where(eq(documents.id, doc.id)); + + if (doc.interestId && doc.documentType === 'eoi') { + await db + .update(interests) + .set({ eoiStatus: 'cancelled', updatedAt: new Date() }) + .where(eq(interests.id, doc.interestId)); + } + + await db.insert(documentEvents).values({ + documentId: doc.id, + eventType: 'cancelled', + signatureHash: eventData.signatureHash ?? null, + eventData: { documensoId: eventData.documentId }, + }); + + emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id }); +} diff --git a/src/lib/socket/events.ts b/src/lib/socket/events.ts index e806e73..f81e468 100644 --- a/src/lib/socket/events.ts +++ b/src/lib/socket/events.ts @@ -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 diff --git a/src/middleware.ts b/src/middleware.ts index 82211a6..b81581a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -11,6 +11,7 @@ const PUBLIC_PATHS: string[] = [ '/api/auth/', '/api/public/', '/api/health', + '/api/webhooks/', '/scan', '/portal/', '/api/portal/',