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

@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { verifyDocumensoSignature } from '@/lib/services/documenso-webhook'; import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
import { import {
handleRecipientSigned, handleRecipientSigned,
handleDocumentCompleted, handleDocumentCompleted,
handleDocumentExpired, handleDocumentOpened,
handleDocumentRejected,
handleDocumentCancelled,
} from '@/lib/services/documents.service'; } from '@/lib/services/documents.service';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
@@ -14,39 +16,58 @@ import { logger } from '@/lib/logger';
// BR-024: Dedup via signatureHash unique index on documentEvents // BR-024: Dedup via signatureHash unique index on documentEvents
// Always return 200 from webhook (webhook best practice) // 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<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
let payload: string; let rawBody: string;
try { try {
payload = await req.text(); rawBody = await req.text();
} catch { } catch {
return NextResponse.json({ ok: false }, { status: 200 }); return NextResponse.json({ ok: false }, { status: 200 });
} }
// Verify HMAC signature // Documenso v1.13 + 2.x send the secret in plaintext via X-Documenso-Secret.
const signature = req.headers.get('x-documenso-signature') ?? ''; const providedSecret = req.headers.get('x-documenso-secret') ?? '';
if (!verifyDocumensoSignature(payload, signature, env.DOCUMENSO_WEBHOOK_SECRET)) { if (!verifyDocumensoSecret(providedSecret, env.DOCUMENSO_WEBHOOK_SECRET)) {
logger.warn({ signature }, 'Invalid Documenso webhook signature'); logger.warn({ providedLen: providedSecret.length }, 'Invalid Documenso webhook secret');
return NextResponse.json({ ok: false, error: 'Invalid signature' }, { status: 200 }); return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 200 });
} }
// Compute deduplication hash // 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<string, unknown> }; let parsed: DocumensoWebhookBody;
try { try {
parsed = JSON.parse(payload) as typeof parsed; parsed = JSON.parse(rawBody) as DocumensoWebhookBody;
} catch { } catch {
logger.warn('Failed to parse Documenso webhook payload'); logger.warn('Failed to parse Documenso webhook payload');
return NextResponse.json({ ok: false }, { status: 200 }); return NextResponse.json({ ok: false }, { status: 200 });
} }
// Dedup: try to insert a sentinel documentEvent with signatureHash // Replay guard: if any event with this hash already exists, skip.
// 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.
try { try {
const existing = await db.query.documentEvents.findFirst({ const existing = await db.query.documentEvents.findFirst({
where: (de, { eq }) => eq(de.signatureHash, signatureHash), where: (de, { eq }) => eq(de.signatureHash, signatureHash),
@@ -60,33 +81,69 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
logger.error({ err }, 'Failed to check duplicate webhook'); 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 { try {
switch (parsed.type) { switch (event) {
case 'RECIPIENT_SIGNED': case 'DOCUMENT_SIGNED':
await handleRecipientSigned({ case 'DOCUMENT_RECIPIENT_COMPLETED': {
documentId: parsed.payload.documentId as string, // v1.13 fires DOCUMENT_SIGNED per recipient sign;
recipientEmail: parsed.payload.recipientEmail as string, // 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, signatureHash,
}); });
break; break;
}
case 'DOCUMENT_COMPLETED': case 'DOCUMENT_CANCELLED':
await handleDocumentCompleted({ await handleDocumentCancelled({ documentId: documensoId, signatureHash });
documentId: parsed.payload.documentId as string,
});
break;
case 'DOCUMENT_EXPIRED':
await handleDocumentExpired({
documentId: parsed.payload.documentId as string,
});
break; break;
default: default:
logger.info({ type: parsed.type }, 'Unhandled Documenso webhook event type'); logger.info({ event }, 'Unhandled Documenso webhook event type');
} }
} catch (err) { } 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 }); return NextResponse.json({ ok: true }, { status: 200 });

View File

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

View File

@@ -635,3 +635,128 @@ export async function handleDocumentExpired(eventData: { documentId: string }) {
emitToRoom(`port:${doc.portId}`, 'document:expired', { documentId: doc.id }); 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 });
}

View File

@@ -143,6 +143,9 @@ export interface ServerToClientEvents {
clientName?: string; clientName?: string;
}) => void; }) => void;
'document:expired': (payload: { documentId: 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:reminderSent': (payload: { documentId: string; recipientEmail: string }) => void;
// Document template events // Document template events

View File

@@ -11,6 +11,7 @@ const PUBLIC_PATHS: string[] = [
'/api/auth/', '/api/auth/',
'/api/public/', '/api/public/',
'/api/health', '/api/health',
'/api/webhooks/',
'/scan', '/scan',
'/portal/', '/portal/',
'/api/portal/', '/api/portal/',