From f6d3f4ca31c20715f8582674dadbc115c4bdd4ce Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Mon, 5 Oct 2026 23:51:17 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Fixing=20verifyWebhook=20to?= =?UTF-8?q?=20return=20the=20payloads=20PDFMonkey=20actually=20sends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/document-card-filters.md | 2 +- .changeset/webhook-payloads.md | 5 ++ CHANGELOG.md | 3 +- README.md | 24 +++++---- src/__tests__/index.test.ts | 13 +++-- src/__tests__/webhooks.test.ts | 68 +++++++++++------------ src/index.ts | 10 ++-- src/webhooks.ts | 83 +++++++++-------------------- 8 files changed, 88 insertions(+), 120 deletions(-) create mode 100644 .changeset/webhook-payloads.md diff --git a/.changeset/document-card-filters.md b/.changeset/document-card-filters.md index f68d9d9..6117a42 100644 --- a/.changeset/document-card-filters.md +++ b/.changeset/document-card-filters.md @@ -4,4 +4,4 @@ Adding the `search` filter to `documentCards.list()` (exact document ID or partial filename) and letting `status` take several statuses. Query values now accept arrays, sent as `key[]=a&key[]=b`. Documenting the accepted `folders` and `sort` values on `documentTemplates.list()`. -Fixing `workspaceCards.update()` to send `PUT` instead of `PATCH`, matching the API and the other update endpoints. Removing the `'error'` value from `DocumentStatus` (and the `document.error` webhook payload status): the API never returns it. +Fixing `workspaceCards.update()` to send `PUT` instead of `PATCH`, matching the API and the other update endpoints. Removing the `'error'` value from `DocumentStatus`: the API never returns it. diff --git a/.changeset/webhook-payloads.md b/.changeset/webhook-payloads.md new file mode 100644 index 0000000..b829d41 --- /dev/null +++ b/.changeset/webhook-payloads.md @@ -0,0 +1,5 @@ +--- +"pdfmonkey": minor +--- + +Fixing `verifyWebhook()`, which rejected every real PDFMonkey webhook: it expected a `{ type, data, timestamp }` envelope the API never sends. It now returns the delivered body as a `WebhookPayload`, either `{ document }` (the document card, for `documents.generation.success`/`failure`) or the `quota.warning` usage figures. Narrow with `'document' in payload` and check `payload.document.status`. Removing the `WebhookEvent`, `WebhookEventType`, `DocumentDoneEvent`, `DocumentErrorEvent`, their `*Data` types and `UnknownWebhookEvent`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9630b93..d37f584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ ### Minor Changes - Adding the `search` filter to `documentCards.list()` (exact document ID or partial filename) and letting `status` take several statuses. Query values now accept arrays, sent as `key[]=a&key[]=b`. Documenting the accepted `folders` and `sort` values on `documentTemplates.list()`. +- Fixing `verifyWebhook()`, which rejected every real PDFMonkey webhook: it expected a `{ type, data, timestamp }` envelope the API never sends. It now returns the delivered body as a `WebhookPayload`, either `{ document }` (the document card, for `documents.generation.success`/`failure`) or the `quota.warning` usage figures. Narrow with `'document' in payload` and check `payload.document.status`. Removing the `WebhookEvent`, `WebhookEventType`, `DocumentDoneEvent`, `DocumentErrorEvent`, their `*Data` types and `UnknownWebhookEvent`. ### Patch Changes -- Fixing `workspaceCards.update()` to send `PUT` instead of `PATCH`, matching the API and the other update endpoints. Removing the `'error'` value from `DocumentStatus` (and the `document.error` webhook payload status): the API never returns it. +- Fixing `workspaceCards.update()` to send `PUT` instead of `PATCH`, matching the API and the other update endpoints. Removing the `'error'` value from `DocumentStatus`: the API never returns it. - ad9d7cd: Fixing `documents.waitForGeneration()` so its `timeout` is a true total budget: in-flight polls and their retries are now aborted when it expires, instead of resolving with a late success or reporting the timeout only after a slow response came back. ## 1.3.0 diff --git a/README.md b/README.md index 6b41cff..6fa3e95 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ Verify incoming webhook signatures (Svix HMAC-SHA256): ```ts import { verifyWebhook } from 'pdfmonkey'; -const event = await verifyWebhook( +const payload = await verifyWebhook( rawBody, { 'svix-id': req.headers['svix-id'], @@ -214,11 +214,15 @@ const event = await verifyWebhook( process.env.WEBHOOK_SECRET, ); -// `WebhookEvent` is a discriminated union — narrow on `type` -if (event.type === 'document.done') { - console.log(event.data.download_url); -} else if (event.type === 'document.error') { - console.log(event.data.failure_cause); +// Generation events carry the document card; `quota.warning` carries usage figures +if ('document' in payload) { + if (payload.document.status === 'success') { + console.log(payload.document.download_url); + } else { + console.log(payload.document.failure_cause); + } +} else { + console.log(`${payload.available_documents} documents left`); } ``` @@ -239,7 +243,7 @@ app.post( express.raw({ type: 'application/json' }), async (req, res) => { try { - const event = await verifyWebhook( + const payload = await verifyWebhook( req.body.toString('utf8'), { 'svix-id': req.header('svix-id') ?? '', @@ -248,7 +252,7 @@ app.post( }, process.env.WEBHOOK_SECRET ?? '', ); - // handle event + // handle payload res.status(204).end(); } catch { res.status(400).send('Invalid signature'); @@ -268,7 +272,7 @@ export const runtime = 'nodejs'; export async function POST(request: Request): Promise { const rawBody = await request.text(); try { - const event = await verifyWebhook( + const payload = await verifyWebhook( rawBody, { 'svix-id': request.headers.get('svix-id') ?? '', @@ -277,7 +281,7 @@ export async function POST(request: Request): Promise { }, process.env.WEBHOOK_SECRET ?? '', ); - // handle event + // handle payload return new Response(null, { status: 204 }); } catch { return new Response('Invalid signature', { status: 400 }); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index c791119..c1996da 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { DocumentStatus, HttpMethod, QueryValue, WebhookEventType } from '../index.js'; +import type { DocumentStatus, HttpMethod, QueryValue, WebhookPayload } from '../index.js'; import { APIConnectionError, APIError, @@ -82,8 +82,13 @@ describe('Barrel exports', () => { expect(value).toBe(42); }); - it('exports WebhookEventType type', () => { - const eventType: WebhookEventType = 'document.done'; - expect(eventType).toBe('document.done'); + it('exports WebhookPayload type', () => { + const payload: WebhookPayload = { + period_start: '2026-10-01T00:00:00Z', + period_end: '2026-11-01T00:00:00Z', + available_documents: 100, + threshold: 80, + }; + expect('document' in payload).toBe(false); }); }); diff --git a/src/__tests__/webhooks.test.ts b/src/__tests__/webhooks.test.ts index e892ae1..b2e322c 100644 --- a/src/__tests__/webhooks.test.ts +++ b/src/__tests__/webhooks.test.ts @@ -104,8 +104,7 @@ describe('RestHooks', () => { // ── Webhook Verification ────────────────────────────────────────────────── describe('verifyWebhook', () => { - const payload = - '{"type":"document.done","data":{"id":"doc_1"},"timestamp":"2026-01-01T00:00:00Z"}'; + const payload = '{"document":{"id":"doc_1","status":"success"}}'; const msgId = 'msg_123'; it('verifies a valid signature', async () => { @@ -122,8 +121,7 @@ describe('verifyWebhook', () => { SECRET, ); - expect(event.type).toBe('document.done'); - expect(event.data.id).toBe('doc_1'); + expect(event).toEqual({ document: { id: 'doc_1', status: 'success' } }); }); it('accepts secret without whsec_ prefix', async () => { @@ -140,7 +138,7 @@ describe('verifyWebhook', () => { SECRET_RAW, ); - expect(event.type).toBe('document.done'); + expect('document' in event).toBe(true); }); it('rejects an invalid signature', async () => { @@ -235,7 +233,7 @@ describe('verifyWebhook', () => { SECRET, ); - expect(event.type).toBe('document.done'); + expect('document' in event).toBe(true); }); it('rejects signature with v2 prefix (not v1)', async () => { @@ -307,41 +305,35 @@ describe('verifyWebhook', () => { ).rejects.toThrow('Webhook payload is not valid JSON'); }); - it('rejects payload missing required WebhookEvent fields', async () => { + it('rejects a JSON payload that is not an object', async () => { const timestamp = String(Math.floor(Date.now() / 1000)); - // Missing type - const noType = '{"data":{},"timestamp":"2026-01-01T00:00:00Z"}'; - const sig1 = await sign(msgId, timestamp, noType); - await expect( - verifyWebhook( - noType, - { 'svix-id': msgId, 'svix-timestamp': timestamp, 'svix-signature': sig1 }, - SECRET, - ), - ).rejects.toThrow('Webhook payload does not match expected WebhookEvent structure'); + for (const body of ['[]', '"text"', 'null']) { + const signature = await sign(msgId, timestamp, body); + await expect( + verifyWebhook( + body, + { 'svix-id': msgId, 'svix-timestamp': timestamp, 'svix-signature': signature }, + SECRET, + ), + ).rejects.toThrow('Webhook payload is not a JSON object'); + } + }); - // Missing data - const noData = '{"type":"document.done","timestamp":"2026-01-01T00:00:00Z"}'; - const sig2 = await sign(msgId, timestamp, noData); - await expect( - verifyWebhook( - noData, - { 'svix-id': msgId, 'svix-timestamp': timestamp, 'svix-signature': sig2 }, - SECRET, - ), - ).rejects.toThrow('Webhook payload does not match expected WebhookEvent structure'); + it('returns a quota.warning payload as-is', async () => { + const body = + '{"period_start":"2026-10-01T00:00:00Z","period_end":"2026-11-01T00:00:00Z","available_documents":100,"threshold":80}'; + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = await sign(msgId, timestamp, body); - // Missing timestamp - const noTs = '{"type":"document.done","data":{}}'; - const sig3 = await sign(msgId, timestamp, noTs); - await expect( - verifyWebhook( - noTs, - { 'svix-id': msgId, 'svix-timestamp': timestamp, 'svix-signature': sig3 }, - SECRET, - ), - ).rejects.toThrow('Webhook payload does not match expected WebhookEvent structure'); + const result = await verifyWebhook( + body, + { 'svix-id': msgId, 'svix-timestamp': timestamp, 'svix-signature': signature }, + SECRET, + ); + + expect('document' in result).toBe(false); + expect(result).toMatchObject({ available_documents: 100, threshold: 80 }); }); it('rejects tolerance <= 0', async () => { @@ -408,6 +400,6 @@ describe('verifyWebhook', () => { SECRET, ); - expect(event.type).toBe('document.done'); + expect('document' in event).toBe(true); }); }); diff --git a/src/index.ts b/src/index.ts index f6fdcd1..bede977 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,14 +87,10 @@ export type { Workspace, WorkspaceListParams } from './resources/workspaces.js'; export { Workspaces } from './resources/workspaces.js'; export { VERSION } from './version.js'; export type { - DocumentDoneEvent, - DocumentDoneEventData, - DocumentErrorEvent, - DocumentErrorEventData, - UnknownWebhookEvent, + DocumentWebhookPayload, + QuotaWarningWebhookPayload, VerifyWebhookOptions, - WebhookEvent, - WebhookEventType, WebhookHeaders, + WebhookPayload, } from './webhooks.js'; export { verifyWebhook } from './webhooks.js'; diff --git a/src/webhooks.ts b/src/webhooks.ts index a7c043c..eb80b01 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -1,4 +1,5 @@ import { PDFMonkeyError } from './error.js'; +import type { DocumentCard } from './resources/document-cards.js'; // ── Types ────────────────────────────────────────────────────────────────── @@ -8,53 +9,25 @@ export interface WebhookHeaders { 'svix-signature': string; } -export type WebhookEventType = 'document.done' | 'document.error' | (string & {}); - -/** Payload shape for a `document.done` webhook event. */ -export interface DocumentDoneEventData { - readonly id: string; - readonly status: 'success'; - readonly download_url: string; - readonly filename: string | null; - readonly preview_url?: string; - readonly checksum?: string; - readonly app_id?: string; - readonly document_template_id?: string; - readonly meta?: string | null; - readonly [key: string]: unknown; -} - -/** Payload shape for a `document.error` webhook event. */ -export interface DocumentErrorEventData { - readonly id: string; - readonly status: 'failure'; - readonly failure_cause: string | null; - readonly app_id?: string; - readonly document_template_id?: string; - readonly meta?: string | null; - readonly [key: string]: unknown; -} - -export interface DocumentDoneEvent { - readonly type: 'document.done'; - readonly data: DocumentDoneEventData; - readonly timestamp: string; -} - -export interface DocumentErrorEvent { - readonly type: 'document.error'; - readonly data: DocumentErrorEventData; - readonly timestamp: string; +/** + * Body of a `documents.generation.success` or `documents.generation.failure` + * webhook: the document card, as returned by `documentCards.get()`. Svix does + * not put the event type in the body, so tell them apart with `document.status`. + */ +export interface DocumentWebhookPayload { + readonly document: DocumentCard; } -/** Catch-all for forward-compatible event types. */ -export interface UnknownWebhookEvent { - readonly type: string & {}; - readonly data: Readonly>; - readonly timestamp: string; +/** Body of a `quota.warning` webhook. */ +export interface QuotaWarningWebhookPayload { + readonly period_start: string; + readonly period_end: string; + readonly available_documents: number; + readonly threshold: number; } -export type WebhookEvent = DocumentDoneEvent | DocumentErrorEvent | UnknownWebhookEvent; +/** Body of a verified webhook. Narrow with `'document' in payload`. */ +export type WebhookPayload = DocumentWebhookPayload | QuotaWarningWebhookPayload; export interface VerifyWebhookOptions { /** Tolerance in seconds for timestamp validation. Default: 300 (5 minutes). */ @@ -75,12 +48,12 @@ const WHSEC_PREFIX = 'whsec_'; * @param headers - The Svix webhook headers * @param secret - The webhook signing secret (with or without `whsec_` prefix) * @param options - Optional verification options (tolerance) - * @returns The parsed webhook event + * @returns The parsed webhook payload * @throws PDFMonkeyError if verification fails * * @example * ```ts - * const event = await verifyWebhook( + * const payload = await verifyWebhook( * rawBody, * { * 'svix-id': req.headers['svix-id'], @@ -89,8 +62,8 @@ const WHSEC_PREFIX = 'whsec_'; * }, * process.env.WEBHOOK_SECRET, * ); - * if (event.type === 'document.done') { - * console.log(event.data.download_url); + * if ('document' in payload && payload.document.status === 'success') { + * console.log(payload.document.download_url); * } * ``` */ @@ -99,7 +72,7 @@ export async function verifyWebhook( headers: WebhookHeaders, secret: string, options?: VerifyWebhookOptions, -): Promise { +): Promise { const msgId = headers['svix-id']; const msgTimestamp = headers['svix-timestamp']; const msgSignature = headers['svix-signature']; @@ -156,18 +129,10 @@ export async function verifyWebhook( } catch { throw new PDFMonkeyError('Webhook payload is not valid JSON'); } - const record = parsed as Record; - if ( - typeof parsed !== 'object' || - parsed === null || - typeof record.type !== 'string' || - typeof record.data !== 'object' || - record.data === null || - typeof record.timestamp !== 'string' - ) { - throw new PDFMonkeyError('Webhook payload does not match expected WebhookEvent structure'); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new PDFMonkeyError('Webhook payload is not a JSON object'); } - return parsed as WebhookEvent; + return parsed as WebhookPayload; } } From c43d91077f391b0c4a55f76adc8104b5b553353f Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Tue, 6 Oct 2026 00:01:20 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20Testing=20verifyWebhook=20again?= =?UTF-8?q?st=20full=20document=20card=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/webhooks.test.ts | 39 ++++++++++++++++++++++++++++++++-- src/webhooks.ts | 6 ++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/__tests__/webhooks.test.ts b/src/__tests__/webhooks.test.ts index b2e322c..ff8943c 100644 --- a/src/__tests__/webhooks.test.ts +++ b/src/__tests__/webhooks.test.ts @@ -104,7 +104,24 @@ describe('RestHooks', () => { // ── Webhook Verification ────────────────────────────────────────────────── describe('verifyWebhook', () => { - const payload = '{"document":{"id":"doc_1","status":"success"}}'; + // Real delivery body: the document card under a `document` key + const card = { + id: 'a5e86d72-f5b7-43d4-a04e-8b7e08e6741c', + app_id: 'd6b4e8f2-7a3c-4d1e-9f5b-2c8a1d3e6f90', + created_at: '2050-03-13T12:34:56.181+02:00', + document_template_id: '2903f5b4-623b-4e10-b2e3-dc7e2e67ea39', + document_template_identifier: 'My Invoice Template', + download_url: 'https://pdfmonkey.s3.eu-west-1.amazonaws.com/doc.pdf', + failure_cause: null, + filename: '2050-03-14 Peter Parker.pdf', + meta: '{"_filename":"2050-03-14 Peter Parker.pdf","clientRef":"spidey-616"}', + output_type: 'pdf', + preview_url: 'https://preview.pdfmonkey.io/doc', + public_share_link: null, + status: 'success', + updated_at: '2050-03-13T12:34:59.412+02:00', + }; + const payload = JSON.stringify({ document: card }); const msgId = 'msg_123'; it('verifies a valid signature', async () => { @@ -121,7 +138,7 @@ describe('verifyWebhook', () => { SECRET, ); - expect(event).toEqual({ document: { id: 'doc_1', status: 'success' } }); + expect(event).toEqual({ document: card }); }); it('accepts secret without whsec_ prefix', async () => { @@ -320,6 +337,24 @@ describe('verifyWebhook', () => { } }); + it('returns a failure payload with its failure_cause', async () => { + const body = JSON.stringify({ + document: { ...card, status: 'failure', download_url: null, failure_cause: 'Template error' }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = await sign(msgId, timestamp, body); + + const result = await verifyWebhook( + body, + { 'svix-id': msgId, 'svix-timestamp': timestamp, 'svix-signature': signature }, + SECRET, + ); + + if (!('document' in result)) throw new Error('expected a document payload'); + expect(result.document.status).toBe('failure'); + expect(result.document.failure_cause).toBe('Template error'); + }); + it('returns a quota.warning payload as-is', async () => { const body = '{"period_start":"2026-10-01T00:00:00Z","period_end":"2026-11-01T00:00:00Z","available_documents":100,"threshold":80}'; diff --git a/src/webhooks.ts b/src/webhooks.ts index eb80b01..f611d68 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -11,8 +11,10 @@ export interface WebhookHeaders { /** * Body of a `documents.generation.success` or `documents.generation.failure` - * webhook: the document card, as returned by `documentCards.get()`. Svix does - * not put the event type in the body, so tell them apart with `document.status`. + * webhook: the document card, as returned by `documentCards.get()` (no + * `payload`, `generation_logs` or `checksum`; `meta` is a JSON string). Svix + * does not put the event type in the body, so tell them apart with + * `document.status`. */ export interface DocumentWebhookPayload { readonly document: DocumentCard;