Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/document-card-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions .changeset/webhook-payloads.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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`);
}
```

Expand All @@ -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') ?? '',
Expand All @@ -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');
Expand All @@ -268,7 +272,7 @@ export const runtime = 'nodejs';
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text();
try {
const event = await verifyWebhook(
const payload = await verifyWebhook(
rawBody,
{
'svix-id': request.headers.get('svix-id') ?? '',
Expand All @@ -277,7 +281,7 @@ export async function POST(request: Request): Promise<Response> {
},
process.env.WEBHOOK_SECRET ?? '',
);
// handle event
// handle payload
return new Response(null, { status: 204 });
} catch {
return new Response('Invalid signature', { status: 400 });
Expand Down
13 changes: 9 additions & 4 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
});
103 changes: 65 additions & 38 deletions src/__tests__/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,24 @@ describe('RestHooks', () => {
// ── Webhook Verification ──────────────────────────────────────────────────

describe('verifyWebhook', () => {
const payload =
'{"type":"document.done","data":{"id":"doc_1"},"timestamp":"2026-01-01T00:00:00Z"}';
// 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 () => {
Expand All @@ -122,8 +138,7 @@ describe('verifyWebhook', () => {
SECRET,
);

expect(event.type).toBe('document.done');
expect(event.data.id).toBe('doc_1');
expect(event).toEqual({ document: card });
});

it('accepts secret without whsec_ prefix', async () => {
Expand All @@ -140,7 +155,7 @@ describe('verifyWebhook', () => {
SECRET_RAW,
);

expect(event.type).toBe('document.done');
expect('document' in event).toBe(true);
});

it('rejects an invalid signature', async () => {
Expand Down Expand Up @@ -235,7 +250,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 () => {
Expand Down Expand Up @@ -307,41 +322,53 @@ 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 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);

// 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,
);

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}';
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,
);

expect('document' in result).toBe(false);
expect(result).toMatchObject({ available_documents: 100, threshold: 80 });
});

it('rejects tolerance <= 0', async () => {
Expand Down Expand Up @@ -408,6 +435,6 @@ describe('verifyWebhook', () => {
SECRET,
);

expect(event.type).toBe('document.done');
expect('document' in event).toBe(true);
});
});
10 changes: 3 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading