import { NextRequest, NextResponse } from 'next/server'; import { createHash } from 'crypto'; import { db } from '@/lib/db'; import { verifyDocumensoSignature } from '@/lib/services/documenso-webhook'; import { handleRecipientSigned, handleDocumentCompleted, handleDocumentExpired, } from '@/lib/services/documents.service'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; // BR-024: Dedup via signatureHash unique index on documentEvents // Always return 200 from webhook (webhook best practice) export async function POST(req: NextRequest): Promise { let payload: string; try { payload = await req.text(); } catch { return NextResponse.json({ ok: false }, { status: 200 }); } // Verify HMAC signature const signature = req.headers.get('x-documenso-signature') ?? ''; 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 }); } // Compute deduplication hash const signatureHash = createHash('sha256').update(payload).digest('hex'); let parsed: { type: string; payload: Record }; try { parsed = JSON.parse(payload) as typeof parsed; } 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. try { const existing = await db.query.documentEvents.findFirst({ where: (de, { eq }) => eq(de.signatureHash, signatureHash), }); if (existing) { logger.info({ signatureHash }, 'Duplicate Documenso webhook — skipping'); return NextResponse.json({ ok: true }, { status: 200 }); } } catch (err) { logger.error({ err }, 'Failed to check duplicate webhook'); } try { switch (parsed.type) { case 'RECIPIENT_SIGNED': await handleRecipientSigned({ documentId: parsed.payload.documentId as string, recipientEmail: parsed.payload.recipientEmail as string, 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, }); break; default: logger.info({ type: parsed.type }, 'Unhandled Documenso webhook event type'); } } catch (err) { logger.error({ err, type: parsed.type }, 'Error processing Documenso webhook'); } return NextResponse.json({ ok: true }, { status: 200 }); }