diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts
index b5dca6fd6..ee603c04f 100644
--- a/packages/corsair/core/constants.ts
+++ b/packages/corsair/core/constants.ts
@@ -153,6 +153,7 @@ export const BaseProviders = [
'oura',
'outlook',
'pagerduty',
+ 'pdfmonkey',
'perplexityai',
'posthog',
'razorpay',
@@ -341,6 +342,7 @@ export const ProviderDisplayNames = {
oura: 'Oura',
outlook: 'Outlook',
pagerduty: 'PagerDuty',
+ pdfmonkey: 'PDFMonkey',
perplexityai: 'Perplexity AI',
posthog: 'PostHog',
razorpay: 'Razorpay',
@@ -536,6 +538,7 @@ export type AllProviders =
| 'oura'
| 'outlook'
| 'pagerduty'
+ | 'pdfmonkey'
| 'perplexityai'
| 'posthog'
| 'razorpay'
diff --git a/packages/pdfmonkey/api.test.ts b/packages/pdfmonkey/api.test.ts
new file mode 100644
index 000000000..3f30cb6c2
--- /dev/null
+++ b/packages/pdfmonkey/api.test.ts
@@ -0,0 +1,484 @@
+import { AuthMissingError, logEventFromContext } from 'corsair/core';
+import { ApiError, request } from 'corsair/http';
+import { PDFMONKEY_API_BASE } from './client';
+import type { PDFMonkeyContext } from './index';
+import { pdfmonkey } from './index';
+
+jest.mock('corsair/core', () => ({
+ ...jest.requireActual('corsair/core'),
+ logEventFromContext: jest.fn(async () => undefined),
+}));
+
+jest.mock('corsair/http', () => ({
+ ...jest.requireActual('corsair/http'),
+ request: jest.fn(),
+}));
+
+const mockRequest = request as jest.Mock;
+const mockLog = logEventFromContext as jest.MockedFunction<
+ typeof logEventFromContext
+>;
+
+const DOCUMENT = {
+ id: 'doc-1',
+ app_id: 'app-1',
+ document_template_id: 'tpl-1',
+ status: 'pending' as const,
+ payload: { clientName: 'Ada' },
+ meta: null,
+ filename: null,
+ download_url: null,
+ preview_url: null,
+ public_share_link: null,
+ checksum: null,
+ generation_logs: [],
+ failure_cause: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+};
+
+const DOCUMENT_CARD = {
+ id: 'doc-1',
+ app_id: 'app-1',
+ document_template_id: 'tpl-1',
+ status: 'success' as const,
+ download_url: 'https://files.example.com/doc.pdf',
+ preview_url: 'https://preview.pdfmonkey.io/doc',
+ public_share_link: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+};
+
+const TEMPLATE = {
+ id: 'tpl-1',
+ app_id: 'app-1',
+ identifier: 'invoice',
+ body: '
Hello
',
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+};
+
+const LIST_META = {
+ current_page: 1,
+ next_page: 2,
+ prev_page: null,
+ total_pages: 4,
+};
+
+function testContext(): PDFMonkeyContext {
+ return {
+ key: 'test-key',
+ options: { authType: 'api_key' },
+ db: {},
+ logEvent: jest.fn(),
+ } as unknown as PDFMonkeyContext;
+}
+
+function lastCall() {
+ expect(mockRequest).toHaveBeenCalled();
+ return mockRequest.mock.calls[mockRequest.mock.calls.length - 1] as [
+ { BASE: string; HEADERS?: Record },
+ {
+ method: string;
+ url: string;
+ body?: unknown;
+ query?: unknown;
+ },
+ ];
+}
+
+describe('PDFMonkey plugin shape', () => {
+ it('registers 12 endpoints, generation webhooks, and api_key auth', () => {
+ const plugin = pdfmonkey();
+ expect(plugin.id).toBe('pdfmonkey');
+ expect(plugin.options?.authType).toBe('api_key');
+ expect(plugin.authConfig).toEqual({
+ api_key: { account: ['tenant_external_id'] },
+ });
+ expect(Object.keys(plugin.endpointMeta ?? {}).sort()).toEqual([
+ 'documents.createDocument',
+ 'documents.createDocumentSync',
+ 'documents.deleteDocument',
+ 'documents.getDocument',
+ 'documents.getDocumentCard',
+ 'documents.listDocumentCards',
+ 'documents.updateDocument',
+ 'templates.createTemplate',
+ 'templates.deleteTemplate',
+ 'templates.getTemplate',
+ 'templates.listTemplateCards',
+ 'templates.updateTemplate',
+ ]);
+ expect(Object.keys(plugin.webhookSchemas ?? {}).sort()).toEqual([
+ 'documents.generationFailure',
+ 'documents.generationSuccess',
+ ]);
+ });
+
+ it('throws AuthMissingError when no API key is available', async () => {
+ const plugin = pdfmonkey();
+ await expect(
+ plugin.keyBuilder?.(
+ {
+ authType: 'api_key',
+ keys: {
+ get_api_key: async () => undefined,
+ },
+ } as never,
+ 'endpoint',
+ ),
+ ).rejects.toBeInstanceOf(AuthMissingError);
+ });
+
+ it('throws AuthMissingError when the webhook signature is missing', async () => {
+ const plugin = pdfmonkey();
+ await expect(
+ plugin.keyBuilder?.(
+ {
+ authType: 'api_key',
+ keys: {
+ get_webhook_signature: async () => undefined,
+ },
+ } as never,
+ 'webhook',
+ ),
+ ).rejects.toMatchObject({
+ name: 'AuthMissingError',
+ pluginId: 'pdfmonkey',
+ authType: 'webhook_signature',
+ });
+ });
+});
+
+describe('PDFMonkey endpoints', () => {
+ const ctx = testContext();
+ const plugin = pdfmonkey({ key: 'test-key' });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('lists template cards with page[number] and q[workspace_id]', async () => {
+ mockRequest.mockResolvedValueOnce({
+ document_template_cards: [
+ {
+ id: 'tpl-1',
+ app_id: 'app-1',
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ },
+ ],
+ meta: LIST_META,
+ });
+
+ const result = await plugin.endpoints!.templates.listTemplateCards(ctx, {
+ q: { workspace_id: 'ws-1' },
+ });
+
+ expect(result.meta).toEqual(LIST_META);
+ expect(result.document_template_cards[0]?.id).toBe('tpl-1');
+ const [config, options] = lastCall();
+ expect(config.BASE).toBe(PDFMONKEY_API_BASE);
+ expect(config.HEADERS).toEqual(
+ expect.objectContaining({ Authorization: 'Bearer test-key' }),
+ );
+ expect(options).toEqual(
+ expect.objectContaining({
+ method: 'GET',
+ url: '/api/v1/document_template_cards',
+ query: {
+ page: { number: 1 },
+ q: { workspace_id: 'ws-1', folders: undefined },
+ sort: undefined,
+ },
+ }),
+ );
+ expect(mockLog).toHaveBeenCalled();
+ });
+
+ it('gets a wrapped template', async () => {
+ mockRequest.mockResolvedValueOnce({ document_template: TEMPLATE });
+
+ const result = await plugin.endpoints!.templates.getTemplate(ctx, {
+ id: 'tpl-1',
+ });
+
+ expect(result.document_template.id).toBe('tpl-1');
+ expect(lastCall()[1]).toEqual(
+ expect.objectContaining({
+ method: 'GET',
+ url: '/api/v1/document_templates/tpl-1',
+ }),
+ );
+ });
+
+ it('creates a template', async () => {
+ mockRequest.mockResolvedValueOnce({ document_template: { id: 'tpl-2' } });
+
+ const result = await plugin.endpoints!.templates.createTemplate(ctx, {
+ document_template: {
+ app_id: 'app-1',
+ identifier: 'invoice',
+ body: 'Hi
',
+ },
+ });
+
+ expect(result.document_template.id).toBe('tpl-2');
+ const [, options] = lastCall();
+ expect(options.method).toBe('POST');
+ expect(options.url).toBe('/api/v1/document_templates');
+ expect(options.body).toEqual(
+ expect.objectContaining({
+ document_template: expect.objectContaining({
+ identifier: 'invoice',
+ edition_mode: 'code',
+ output_type: 'pdf',
+ }),
+ }),
+ );
+ });
+
+ it('updates a template and rejects a missing body', async () => {
+ mockRequest.mockResolvedValueOnce({ document_template: { id: 'tpl-1' } });
+
+ await expect(
+ plugin.endpoints!.templates.updateTemplate(ctx, {
+ document_template_id: 'tpl-1',
+ } as never),
+ ).rejects.toThrow();
+ expect(mockRequest).not.toHaveBeenCalled();
+
+ const result = await plugin.endpoints!.templates.updateTemplate(ctx, {
+ document_template_id: 'tpl-1',
+ document_template: { identifier: 'updated' },
+ });
+ expect(result.document_template.id).toBe('tpl-1');
+ expect(lastCall()[1]).toEqual(
+ expect.objectContaining({
+ method: 'PUT',
+ url: '/api/v1/document_templates/tpl-1',
+ body: { document_template: { identifier: 'updated' } },
+ }),
+ );
+ });
+
+ it('maps template DELETE 204 to { success: true }', async () => {
+ mockRequest.mockResolvedValueOnce(undefined);
+
+ const result = await plugin.endpoints!.templates.deleteTemplate(ctx, {
+ id: 'tpl-1',
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(lastCall()[1]).toEqual(
+ expect.objectContaining({
+ method: 'DELETE',
+ url: '/api/v1/document_templates/tpl-1',
+ }),
+ );
+ });
+
+ it('creates a document and unwraps { document }', async () => {
+ mockRequest.mockResolvedValueOnce({ document: DOCUMENT });
+
+ const result = await plugin.endpoints!.documents.createDocument(ctx, {
+ document: {
+ document_template_id: 'tpl-1',
+ status: 'pending',
+ payload: { clientName: 'Ada' },
+ },
+ });
+
+ expect(result.id).toBe('doc-1');
+ expect(result.document_template_id).toBe('tpl-1');
+ expect(lastCall()[1]).toEqual(
+ expect.objectContaining({
+ method: 'POST',
+ url: '/api/v1/documents',
+ body: {
+ document: {
+ document_template_id: 'tpl-1',
+ status: 'pending',
+ payload: { clientName: 'Ada' },
+ },
+ },
+ }),
+ );
+ });
+
+ it('creates a sync document and unwraps { document_card }', async () => {
+ mockRequest.mockResolvedValueOnce({ document_card: DOCUMENT_CARD });
+
+ const result = await plugin.endpoints!.documents.createDocumentSync(ctx, {
+ document: { document_template_id: 'tpl-1' },
+ });
+
+ expect(result.id).toBe('doc-1');
+ expect(result.status).toBe('success');
+ expect(lastCall()[1]).toEqual(
+ expect.objectContaining({
+ method: 'POST',
+ url: '/api/v1/documents/sync',
+ body: {
+ document: {
+ document_template_id: 'tpl-1',
+ status: 'pending',
+ },
+ },
+ }),
+ );
+ });
+
+ it('gets a document card and unwraps { document_card }', async () => {
+ mockRequest.mockResolvedValueOnce({ document_card: DOCUMENT_CARD });
+
+ const result = await plugin.endpoints!.documents.getDocumentCard(ctx, {
+ id: 'doc-1',
+ });
+
+ expect(result.id).toBe('doc-1');
+ expect(lastCall()[1].url).toBe('/api/v1/document_cards/doc-1');
+ });
+
+ it('lists document cards with nested page and q filters', async () => {
+ mockRequest.mockResolvedValueOnce({
+ document_cards: [DOCUMENT_CARD],
+ meta: LIST_META,
+ });
+
+ const result = await plugin.endpoints!.documents.listDocumentCards(ctx, {
+ page: 3,
+ q: { status: 'success', document_template_id: 'tpl-1' },
+ });
+
+ expect(result.document_cards[0]?.id).toBe('doc-1');
+ expect(result.meta?.next_page).toBe(2);
+ expect(lastCall()[1]).toEqual(
+ expect.objectContaining({
+ method: 'GET',
+ url: '/api/v1/document_cards',
+ query: {
+ page: { number: 3 },
+ q: {
+ document_template_id: 'tpl-1',
+ status: 'success',
+ workspace_id: undefined,
+ updated_since: undefined,
+ search: undefined,
+ },
+ },
+ }),
+ );
+ });
+
+ it('gets a full document and unwraps { document }', async () => {
+ mockRequest.mockResolvedValueOnce({ document: DOCUMENT });
+
+ const result = await plugin.endpoints!.documents.getDocument(ctx, {
+ id: 'doc-1',
+ });
+
+ expect(result.id).toBe('doc-1');
+ expect(result.payload).toEqual({ clientName: 'Ada' });
+ expect(lastCall()[1].url).toBe('/api/v1/documents/doc-1');
+ });
+
+ it('updates a document', async () => {
+ mockRequest.mockResolvedValueOnce({
+ document: { ...DOCUMENT, status: 'draft' },
+ });
+
+ const result = await plugin.endpoints!.documents.updateDocument(ctx, {
+ document_id: 'doc-1',
+ document: { status: 'draft' },
+ });
+
+ expect(result.status).toBe('draft');
+ expect(lastCall()[1]).toEqual(
+ expect.objectContaining({
+ method: 'PUT',
+ url: '/api/v1/documents/doc-1',
+ body: { document: { status: 'draft' } },
+ }),
+ );
+ });
+
+ it('maps document DELETE 204 to { success: true }', async () => {
+ mockRequest.mockResolvedValueOnce(undefined);
+
+ const result = await plugin.endpoints!.documents.deleteDocument(ctx, {
+ id: 'doc-1',
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(lastCall()[1].method).toBe('DELETE');
+ });
+
+ it('verifies Svix signatures on generation webhooks', async () => {
+ const crypto = await import('crypto');
+ const secretBytes = Buffer.from('pdfmonkey-test-secret', 'utf8');
+ const secret = `whsec_${secretBytes.toString('base64')}`;
+ const timestamp = String(Math.floor(Date.now() / 1000));
+ const payload = {
+ document: {
+ id: 'doc-1',
+ app_id: 'app-1',
+ status: 'success' as const,
+ download_url: 'https://files.example.com/doc.pdf',
+ preview_url: null,
+ public_share_link: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ },
+ };
+ const rawBody = JSON.stringify(payload);
+ const signature = `v1,${crypto
+ .createHmac('sha256', secretBytes)
+ .update(`msg_1.${timestamp}.${rawBody}`)
+ .digest('base64')}`;
+ const webhookPlugin = pdfmonkey({ webhookSecret: secret });
+ const webhookCtx = {
+ ...ctx,
+ key: secret,
+ } as unknown as PDFMonkeyContext;
+
+ const result =
+ await webhookPlugin.webhooks!.documents.generationSuccess.handler(
+ webhookCtx,
+ {
+ payload,
+ headers: {
+ 'svix-id': 'msg_1',
+ 'svix-timestamp': timestamp,
+ 'svix-signature': signature,
+ },
+ rawBody,
+ },
+ );
+
+ expect(result).toMatchObject({
+ success: true,
+ data: { document: { id: 'doc-1', status: 'success' } },
+ });
+ });
+
+ it('surfaces ApiError from the client so 429 handlers can match', async () => {
+ const error = new ApiError(
+ { method: 'GET', url: '/api/v1/documents' },
+ {
+ url: 'https://api.pdfmonkey.io/api/v1/documents',
+ ok: false,
+ status: 429,
+ statusText: 'Too Many Requests',
+ body: {},
+ },
+ 'Too Many Requests',
+ { retryAfter: 2000 },
+ );
+ mockRequest.mockRejectedValueOnce(error);
+
+ await expect(
+ plugin.endpoints!.documents.getDocument(ctx, { id: 'doc-1' }),
+ ).rejects.toBe(error);
+ });
+});
diff --git a/packages/pdfmonkey/client.ts b/packages/pdfmonkey/client.ts
new file mode 100644
index 000000000..c006c96b1
--- /dev/null
+++ b/packages/pdfmonkey/client.ts
@@ -0,0 +1,63 @@
+import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
+import { ApiError, request } from 'corsair/http';
+
+export const PDFMONKEY_API_BASE = 'https://api.pdfmonkey.io';
+
+export type PdfMonkeyQueryValue =
+ | string
+ | number
+ | boolean
+ | undefined
+ | Record;
+
+export type PdfMonkeyRequestOptions = {
+ apiKey?: string;
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
+ body?: Record;
+ query?: Record;
+};
+
+function buildConfig(apiKey?: string, isWrite = false): OpenAPIConfig {
+ return {
+ BASE: PDFMONKEY_API_BASE,
+ VERSION: '1.0.0',
+ WITH_CREDENTIALS: false,
+ CREDENTIALS: 'omit',
+ TOKEN: undefined,
+ HEADERS: {
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
+ ...(isWrite ? { 'Content-Type': 'application/json' } : {}),
+ },
+ };
+}
+
+async function handleRequestError(error: unknown): Promise {
+ if (error instanceof ApiError || error instanceof Error) {
+ throw error;
+ }
+ throw new Error('Unknown PDFMonkey error');
+}
+
+export async function makePdfMonkeyRequest(
+ endpoint: string,
+ options: PdfMonkeyRequestOptions = {},
+): Promise {
+ const { apiKey, method = 'GET', body, query = {} } = options;
+ const isWrite = method === 'POST' || method === 'PUT' || method === 'PATCH';
+
+ const config = buildConfig(apiKey, isWrite);
+
+ const requestOptions: ApiRequestOptions = {
+ method,
+ url: endpoint,
+ body: isWrite ? body : undefined,
+ mediaType: isWrite ? 'application/json; charset=utf-8' : undefined,
+ query,
+ };
+
+ try {
+ return await request(config, requestOptions);
+ } catch (error) {
+ return handleRequestError(error);
+ }
+}
diff --git a/packages/pdfmonkey/endpoints/documents.ts b/packages/pdfmonkey/endpoints/documents.ts
new file mode 100644
index 000000000..b991f0091
--- /dev/null
+++ b/packages/pdfmonkey/endpoints/documents.ts
@@ -0,0 +1,208 @@
+import { logEventFromContext } from 'corsair/core';
+import { makePdfMonkeyRequest } from '../client';
+import type { PDFMonkeyEndpoints } from '../index';
+import {
+ CreateDocumentInputSchema,
+ CreateDocumentSyncInputSchema,
+ DeleteDocumentInputSchema,
+ DocumentCardResponseSchema,
+ DocumentResponseSchema,
+ GetDocumentCardInputSchema,
+ GetDocumentInputSchema,
+ ListDocumentCardsInputSchema,
+ ListDocumentCardsOutputSchema,
+ PDFMonkeyEndpointOutputSchemas,
+ UpdateDocumentInputSchema,
+} from './types';
+
+export const createDocument: PDFMonkeyEndpoints['createDocument'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = CreateDocumentInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest('/api/v1/documents', {
+ apiKey: ctx.key,
+ method: 'POST',
+ body: {
+ document: parsed.document,
+ },
+ });
+
+ const document = DocumentResponseSchema.parse(response).document;
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.documents.createDocument',
+ {
+ document_template_id: parsed.document.document_template_id,
+ status: parsed.document.status,
+ },
+ 'completed',
+ );
+
+ return document;
+};
+
+export const createDocumentSync: PDFMonkeyEndpoints['createDocumentSync'] =
+ async (ctx, input) => {
+ const parsed = CreateDocumentSyncInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/documents/sync',
+ {
+ apiKey: ctx.key,
+ method: 'POST',
+ body: {
+ document: parsed.document,
+ },
+ },
+ );
+
+ const documentCard =
+ DocumentCardResponseSchema.parse(response).document_card;
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.documents.createDocumentSync',
+ {
+ document_template_id: parsed.document.document_template_id,
+ status: parsed.document.status,
+ },
+ 'completed',
+ );
+
+ return documentCard;
+ };
+
+export const getDocumentCard: PDFMonkeyEndpoints['getDocumentCard'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = GetDocumentCardInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/document_cards/' + parsed.id,
+ {
+ apiKey: ctx.key,
+ method: 'GET',
+ },
+ );
+
+ const documentCard = DocumentCardResponseSchema.parse(response).document_card;
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.documents.getDocumentCard',
+ { id: parsed.id },
+ 'completed',
+ );
+
+ return documentCard;
+};
+
+export const listDocumentCards: PDFMonkeyEndpoints['listDocumentCards'] =
+ async (ctx, input) => {
+ const parsed = ListDocumentCardsInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/document_cards',
+ {
+ apiKey: ctx.key,
+ method: 'GET',
+ query: {
+ page: { number: parsed.page },
+ q: {
+ document_template_id: parsed.q?.document_template_id,
+ status: parsed.q?.status,
+ workspace_id: parsed.q?.workspace_id,
+ updated_since: parsed.q?.updated_since,
+ search: parsed.q?.search,
+ },
+ },
+ },
+ );
+
+ const output = ListDocumentCardsOutputSchema.parse(response);
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.documents.listDocumentCards',
+ {
+ page: parsed.page,
+ status: parsed.q?.status,
+ },
+ 'completed',
+ );
+
+ return output;
+ };
+
+export const getDocument: PDFMonkeyEndpoints['getDocument'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = GetDocumentInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/documents/' + parsed.id,
+ {
+ apiKey: ctx.key,
+ method: 'GET',
+ },
+ );
+
+ const document = DocumentResponseSchema.parse(response).document;
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.documents.getDocument',
+ { id: parsed.id },
+ 'completed',
+ );
+
+ return document;
+};
+
+export const updateDocument: PDFMonkeyEndpoints['updateDocument'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = UpdateDocumentInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/documents/' + parsed.document_id,
+ {
+ apiKey: ctx.key,
+ method: 'PUT',
+ body: {
+ document: parsed.document,
+ },
+ },
+ );
+
+ const document = DocumentResponseSchema.parse(response).document;
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.documents.updateDocument',
+ { document_id: parsed.document_id },
+ 'completed',
+ );
+
+ return document;
+};
+
+export const deleteDocument: PDFMonkeyEndpoints['deleteDocument'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = DeleteDocumentInputSchema.parse(input);
+ await makePdfMonkeyRequest('/api/v1/documents/' + parsed.id, {
+ apiKey: ctx.key,
+ method: 'DELETE',
+ });
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.documents.deleteDocument',
+ { id: parsed.id },
+ 'completed',
+ );
+
+ return PDFMonkeyEndpointOutputSchemas.deleteDocument.parse({ success: true });
+};
diff --git a/packages/pdfmonkey/endpoints/index.ts b/packages/pdfmonkey/endpoints/index.ts
new file mode 100644
index 000000000..03bcba6ea
--- /dev/null
+++ b/packages/pdfmonkey/endpoints/index.ts
@@ -0,0 +1,22 @@
+import * as Documents from './documents';
+import * as Templates from './templates';
+
+export const Template = {
+ listTemplateCards: Templates.listTemplateCards,
+ getTemplate: Templates.getTemplate,
+ createTemplate: Templates.createTemplate,
+ updateTemplate: Templates.updateTemplate,
+ deleteTemplate: Templates.deleteTemplate,
+};
+
+export const Document = {
+ createDocument: Documents.createDocument,
+ createDocumentSync: Documents.createDocumentSync,
+ getDocumentCard: Documents.getDocumentCard,
+ listDocumentCards: Documents.listDocumentCards,
+ getDocument: Documents.getDocument,
+ updateDocument: Documents.updateDocument,
+ deleteDocument: Documents.deleteDocument,
+};
+
+export * from './types';
diff --git a/packages/pdfmonkey/endpoints/templates.ts b/packages/pdfmonkey/endpoints/templates.ts
new file mode 100644
index 000000000..2951aea79
--- /dev/null
+++ b/packages/pdfmonkey/endpoints/templates.ts
@@ -0,0 +1,153 @@
+import { logEventFromContext } from 'corsair/core';
+import { makePdfMonkeyRequest } from '../client';
+import type { PDFMonkeyEndpoints } from '../index';
+import {
+ CreateTemplateInputSchema,
+ CreateTemplateOutputSchema,
+ DeleteTemplateInputSchema,
+ GetTemplateInputSchema,
+ GetTemplateOutputSchema,
+ ListTemplateCardsInputSchema,
+ ListTemplateCardsOutputSchema,
+ PDFMonkeyEndpointOutputSchemas,
+ UpdateTemplateInputSchema,
+ UpdateTemplateOutputSchema,
+} from './types';
+
+export const listTemplateCards: PDFMonkeyEndpoints['listTemplateCards'] =
+ async (ctx, input) => {
+ const parsed = ListTemplateCardsInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/document_template_cards',
+ {
+ apiKey: ctx.key,
+ method: 'GET',
+ query: {
+ page: { number: parsed.page },
+ q: {
+ workspace_id: parsed.q.workspace_id,
+ folders: parsed.q.folders,
+ },
+ sort: parsed.sort,
+ },
+ },
+ );
+
+ const output = ListTemplateCardsOutputSchema.parse(response);
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.templates.listTemplateCards',
+ {
+ workspace_id: parsed.q.workspace_id,
+ page: parsed.page,
+ },
+ 'completed',
+ );
+
+ return output;
+ };
+
+export const getTemplate: PDFMonkeyEndpoints['getTemplate'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = GetTemplateInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/document_templates/' + parsed.id,
+ {
+ apiKey: ctx.key,
+ method: 'GET',
+ },
+ );
+
+ const output = GetTemplateOutputSchema.parse(response);
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.templates.getTemplate',
+ { id: parsed.id },
+ 'completed',
+ );
+
+ return output;
+};
+
+export const createTemplate: PDFMonkeyEndpoints['createTemplate'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = CreateTemplateInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/document_templates',
+ {
+ apiKey: ctx.key,
+ method: 'POST',
+ body: {
+ document_template: parsed.document_template,
+ },
+ },
+ );
+
+ const output = CreateTemplateOutputSchema.parse(response);
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.templates.createTemplate',
+ { identifier: parsed.document_template.identifier },
+ 'completed',
+ );
+
+ return output;
+};
+
+export const updateTemplate: PDFMonkeyEndpoints['updateTemplate'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = UpdateTemplateInputSchema.parse(input);
+ const response = await makePdfMonkeyRequest(
+ '/api/v1/document_templates/' + parsed.document_template_id,
+ {
+ apiKey: ctx.key,
+ method: 'PUT',
+ body: {
+ document_template: parsed.document_template,
+ },
+ },
+ );
+
+ const output = UpdateTemplateOutputSchema.parse(response);
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.templates.updateTemplate',
+ { template_id: parsed.document_template_id },
+ 'completed',
+ );
+
+ return output;
+};
+
+export const deleteTemplate: PDFMonkeyEndpoints['deleteTemplate'] = async (
+ ctx,
+ input,
+) => {
+ const parsed = DeleteTemplateInputSchema.parse(input);
+ await makePdfMonkeyRequest(
+ '/api/v1/document_templates/' + parsed.id,
+ {
+ apiKey: ctx.key,
+ method: 'DELETE',
+ },
+ );
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.templates.deleteTemplate',
+ { id: parsed.id },
+ 'completed',
+ );
+
+ return PDFMonkeyEndpointOutputSchemas.deleteTemplate.parse({ success: true });
+};
diff --git a/packages/pdfmonkey/endpoints/types.ts b/packages/pdfmonkey/endpoints/types.ts
new file mode 100644
index 000000000..3622fde6a
--- /dev/null
+++ b/packages/pdfmonkey/endpoints/types.ts
@@ -0,0 +1,350 @@
+import { z } from 'zod';
+
+const DeleteSuccessSchema = z.object({ success: z.boolean() });
+export type DeleteSuccess = z.infer;
+
+const JsonValueSchema = z.unknown();
+
+export const PaginationMetaSchema = z.object({
+ current_page: z.number().int().nonnegative(),
+ next_page: z.number().int().nullable(),
+ prev_page: z.number().int().nullable(),
+ total_pages: z.number().int().nonnegative(),
+});
+
+export type PaginationMeta = z.infer;
+
+export const DocumentTemplateCardSchema = z.object({
+ id: z.string(),
+ app_id: z.string(),
+ identifier: z.string().optional(),
+ edition_mode: z.enum(['code', 'builder']).optional(),
+ output_type: z.enum(['pdf', 'image']).optional(),
+ is_draft: z.boolean().optional(),
+ created_at: z.string(),
+ updated_at: z.string(),
+});
+
+export type DocumentTemplateCard = z.infer;
+
+export const DocumentTemplateSchema = z.object({
+ id: z.string(),
+ app_id: z.string(),
+ identifier: z.string().optional(),
+ body: z.string().optional(),
+ body_draft: z.string().optional(),
+ scss_style: z.string().optional(),
+ scss_style_draft: z.string().optional(),
+ sample_data: z.string().optional(),
+ sample_data_draft: z.string().optional(),
+ settings: JsonValueSchema.optional(),
+ settings_draft: JsonValueSchema.optional(),
+ pdf_engine_id: z.string().nullable().optional(),
+ pdf_engine_draft_id: z.string().nullable().optional(),
+ template_folder_id: z.string().nullable().optional(),
+ template_folder_identifier: z.string().optional(),
+ ttl: z.number().int().nullable().optional(),
+ edition_mode: z.enum(['code', 'builder']).optional(),
+ output_type: z.enum(['pdf', 'image']).optional(),
+ created_at: z.string(),
+ updated_at: z.string(),
+});
+
+export type DocumentTemplate = z.infer;
+
+export const ListTemplateCardsInputSchema = z.object({
+ page: z.number().int().positive().default(1),
+ q: z.object({
+ workspace_id: z.string(),
+ folders: z.string().optional(),
+ }),
+ sort: z.string().optional(),
+});
+
+export type ListTemplateCardsInput = z.input<
+ typeof ListTemplateCardsInputSchema
+>;
+
+export const ListTemplateCardsOutputSchema = z.object({
+ document_template_cards: z.array(DocumentTemplateCardSchema),
+ meta: PaginationMetaSchema.optional(),
+});
+
+export type ListTemplateCardsOutput = z.infer<
+ typeof ListTemplateCardsOutputSchema
+>;
+
+export const GetTemplateInputSchema = z.object({
+ id: z.string(),
+});
+
+export type GetTemplateInput = z.input;
+
+export const GetTemplateOutputSchema = z.object({
+ document_template: DocumentTemplateSchema,
+});
+
+export type GetTemplateOutput = z.infer;
+
+export const CreateTemplateInputSchema = z.object({
+ document_template: z.object({
+ app_id: z.string(),
+ identifier: z.string(),
+ body: z.string(),
+ body_draft: z.string().optional(),
+ scss_style: z.string().optional(),
+ scss_style_draft: z.string().optional(),
+ sample_data: z.string().optional(),
+ sample_data_draft: z.string().optional(),
+ settings: JsonValueSchema.optional(),
+ settings_draft: JsonValueSchema.optional(),
+ pdf_engine_id: z.string().optional(),
+ pdf_engine_draft_id: z.string().optional(),
+ template_folder_id: z.string().optional(),
+ ttl: z.number().int().nullable().optional(),
+ edition_mode: z.enum(['code', 'builder']).optional().default('code'),
+ output_type: z.enum(['pdf', 'image']).optional().default('pdf'),
+ }),
+});
+
+export type CreateTemplateInput = z.input;
+
+export const CreateTemplateOutputSchema = z.object({
+ document_template: z.object({
+ id: z.string(),
+ }),
+});
+
+export type CreateTemplateOutput = z.infer;
+
+export const UpdateTemplateInputSchema = z.object({
+ document_template_id: z.string(),
+ document_template: z.object({
+ identifier: z.string().optional(),
+ body: z.string().optional(),
+ body_draft: z.string().optional(),
+ scss_style: z.string().optional(),
+ scss_style_draft: z.string().optional(),
+ sample_data: z.string().optional(),
+ sample_data_draft: z.string().optional(),
+ settings: JsonValueSchema.optional(),
+ settings_draft: JsonValueSchema.optional(),
+ pdf_engine_id: z.string().optional(),
+ pdf_engine_draft_id: z.string().optional(),
+ template_folder_id: z.string().optional(),
+ ttl: z.number().int().nullable().optional(),
+ edition_mode: z.enum(['code', 'builder']).optional(),
+ output_type: z.enum(['pdf', 'image']).optional(),
+ }),
+});
+
+export type UpdateTemplateInput = z.input;
+
+export const UpdateTemplateOutputSchema = CreateTemplateOutputSchema;
+
+export type UpdateTemplateOutput = z.infer;
+
+export const DeleteTemplateInputSchema = z.object({
+ id: z.string(),
+});
+
+export type DeleteTemplateInput = z.input;
+
+export const DocumentCardSchema = z.object({
+ id: z.string(),
+ app_id: z.string(),
+ document_template_id: z.string().optional(),
+ document_template_identifier: z.string().optional(),
+ status: z.enum(['draft', 'pending', 'generating', 'success', 'failure']),
+ filename: z.string().nullable().optional(),
+ download_url: z.url().nullable().optional(),
+ preview_url: z.url().nullable().optional(),
+ public_share_link: z.url().nullable().optional(),
+ failure_cause: z.string().nullable().optional(),
+ meta: JsonValueSchema.nullable().optional(),
+ output_type: z.enum(['pdf', 'image']).optional(),
+ created_at: z.string(),
+ updated_at: z.string(),
+});
+
+export type DocumentCard = z.infer;
+
+export const DocumentSchema = z.object({
+ id: z.string(),
+ app_id: z.string(),
+ document_template_id: z.string(),
+ document_template_identifier: z.string().optional(),
+ status: z.enum(['draft', 'pending', 'generating', 'success', 'failure']),
+ payload: JsonValueSchema.nullable(),
+ meta: JsonValueSchema.nullable(),
+ filename: z.string().nullable(),
+ download_url: z.url().nullable(),
+ preview_url: z.url().nullable(),
+ public_share_link: z.url().nullable(),
+ checksum: z.string().nullable(),
+ generation_logs: z.array(JsonValueSchema).optional(),
+ failure_cause: z.string().nullable(),
+ output_type: z.enum(['pdf', 'image']).optional(),
+ created_at: z.string(),
+ updated_at: z.string(),
+});
+
+export type Document = z.infer;
+
+export const DocumentResponseSchema = z.object({
+ document: DocumentSchema,
+});
+
+export type DocumentResponse = z.infer;
+
+export const DocumentCardResponseSchema = z.object({
+ document_card: DocumentCardSchema,
+});
+
+export type DocumentCardResponse = z.infer;
+
+export const DocumentCreateRequestSchema = z.object({
+ document: z.object({
+ document_template_id: z.string(),
+ status: z.enum(['draft', 'pending']).optional(),
+ payload: JsonValueSchema.optional(),
+ meta: JsonValueSchema.optional(),
+ }),
+});
+
+export type DocumentCreateRequest = z.infer;
+
+export const CreateDocumentInputSchema = DocumentCreateRequestSchema;
+
+export type CreateDocumentInput = z.input;
+
+export const CreateDocumentSyncInputSchema = z.object({
+ document: z.object({
+ document_template_id: z.string(),
+ status: z.enum(['draft', 'pending']).optional().default('pending'),
+ payload: JsonValueSchema.optional(),
+ meta: JsonValueSchema.optional(),
+ }),
+});
+
+export type CreateDocumentSyncInput = z.input<
+ typeof CreateDocumentSyncInputSchema
+>;
+
+export const GetDocumentCardInputSchema = z.object({
+ id: z.string(),
+});
+
+export type GetDocumentCardInput = z.input;
+
+export const ListDocumentCardsInputSchema = z.object({
+ page: z.number().int().positive().default(1),
+ q: z
+ .object({
+ document_template_id: z.string().optional(),
+ status: z
+ .enum(['draft', 'pending', 'generating', 'success', 'failure'])
+ .optional(),
+ workspace_id: z.string().optional(),
+ updated_since: z.string().optional(),
+ search: z.string().optional(),
+ })
+ .optional(),
+});
+
+export type ListDocumentCardsInput = z.input<
+ typeof ListDocumentCardsInputSchema
+>;
+
+export const ListDocumentCardsOutputSchema = z.object({
+ document_cards: z.array(DocumentCardSchema),
+ meta: PaginationMetaSchema.optional(),
+});
+
+export type ListDocumentCardsOutput = z.infer<
+ typeof ListDocumentCardsOutputSchema
+>;
+
+export const GetDocumentInputSchema = z.object({
+ id: z.string(),
+});
+
+export type GetDocumentInput = z.input;
+
+export const UpdateDocumentInputSchema = z.object({
+ document_id: z.string(),
+ document: z.object({
+ document_template_id: z.string().optional(),
+ status: z.enum(['draft', 'pending']).optional(),
+ payload: JsonValueSchema.optional(),
+ meta: JsonValueSchema.optional(),
+ }),
+});
+
+export type UpdateDocumentInput = z.input;
+
+export const DeleteDocumentInputSchema = z.object({
+ id: z.string(),
+});
+
+export type DeleteDocumentInput = z.input;
+
+export type PDFMonkeyEndpointInputs = {
+ listTemplateCards: ListTemplateCardsInput;
+ getTemplate: GetTemplateInput;
+ createTemplate: CreateTemplateInput;
+ updateTemplate: UpdateTemplateInput;
+ deleteTemplate: DeleteTemplateInput;
+ createDocument: CreateDocumentInput;
+ createDocumentSync: CreateDocumentSyncInput;
+ getDocumentCard: GetDocumentCardInput;
+ listDocumentCards: ListDocumentCardsInput;
+ getDocument: GetDocumentInput;
+ updateDocument: UpdateDocumentInput;
+ deleteDocument: DeleteDocumentInput;
+};
+
+export type PDFMonkeyEndpointOutputs = {
+ listTemplateCards: ListTemplateCardsOutput;
+ getTemplate: GetTemplateOutput;
+ createTemplate: CreateTemplateOutput;
+ updateTemplate: UpdateTemplateOutput;
+ deleteTemplate: DeleteSuccess;
+ createDocument: Document;
+ createDocumentSync: DocumentCard;
+ getDocumentCard: DocumentCard;
+ listDocumentCards: ListDocumentCardsOutput;
+ getDocument: Document;
+ updateDocument: Document;
+ deleteDocument: DeleteSuccess;
+};
+
+export const PDFMonkeyEndpointInputSchemas = {
+ listTemplateCards: ListTemplateCardsInputSchema,
+ getTemplate: GetTemplateInputSchema,
+ createTemplate: CreateTemplateInputSchema,
+ updateTemplate: UpdateTemplateInputSchema,
+ deleteTemplate: DeleteTemplateInputSchema,
+ createDocument: CreateDocumentInputSchema,
+ createDocumentSync: CreateDocumentSyncInputSchema,
+ getDocumentCard: GetDocumentCardInputSchema,
+ listDocumentCards: ListDocumentCardsInputSchema,
+ getDocument: GetDocumentInputSchema,
+ updateDocument: UpdateDocumentInputSchema,
+ deleteDocument: DeleteDocumentInputSchema,
+} as const;
+
+export const PDFMonkeyEndpointOutputSchemas = {
+ listTemplateCards: ListTemplateCardsOutputSchema,
+ getTemplate: GetTemplateOutputSchema,
+ createTemplate: CreateTemplateOutputSchema,
+ updateTemplate: UpdateTemplateOutputSchema,
+ deleteTemplate: DeleteSuccessSchema,
+ createDocument: DocumentSchema,
+ createDocumentSync: DocumentCardSchema,
+ getDocumentCard: DocumentCardSchema,
+ listDocumentCards: ListDocumentCardsOutputSchema,
+ getDocument: DocumentSchema,
+ updateDocument: DocumentSchema,
+ deleteDocument: DeleteSuccessSchema,
+} as const;
diff --git a/packages/pdfmonkey/error-handlers.test.ts b/packages/pdfmonkey/error-handlers.test.ts
new file mode 100644
index 000000000..54eee111b
--- /dev/null
+++ b/packages/pdfmonkey/error-handlers.test.ts
@@ -0,0 +1,53 @@
+import { ApiError } from 'corsair/http';
+import { errorHandlers } from './error-handlers';
+
+function apiError(status: number, message: string, retryAfter?: number) {
+ return new ApiError(
+ { method: 'GET', url: '/api/v1/documents' },
+ {
+ url: 'https://api.pdfmonkey.io/api/v1/documents',
+ ok: false,
+ status,
+ statusText: 'Error',
+ body: { message },
+ },
+ message,
+ { retryAfter },
+ );
+}
+
+function route(error: Error): string {
+ const match = Object.entries(errorHandlers).find(([, entry]) =>
+ entry.match(error),
+ );
+ if (!match) throw new Error('no handler matched');
+ return match[0];
+}
+
+describe('PDFMonkey errorHandlers', () => {
+ it('routes a 429 Too Many Requests to RATE_LIMIT_ERROR and keeps retryAfter', async () => {
+ const error = apiError(429, 'Too Many Requests', 1500);
+
+ expect(route(error)).toBe('RATE_LIMIT_ERROR');
+ expect(await errorHandlers.RATE_LIMIT_ERROR.handler(error)).toEqual({
+ maxRetries: 5,
+ headersRetryAfterMs: 1500,
+ });
+ });
+
+ it('routes rate-limit message text without a status', () => {
+ expect(route(new Error('too many requests'))).toBe('RATE_LIMIT_ERROR');
+ });
+
+ it('routes 401 to AUTH_ERROR', () => {
+ expect(route(apiError(401, 'Unauthorized'))).toBe('AUTH_ERROR');
+ });
+
+ it('routes unknown errors to DEFAULT with no retries', async () => {
+ const error = apiError(500, 'Internal Server Error');
+ expect(route(error)).toBe('DEFAULT');
+ expect(await errorHandlers.DEFAULT.handler()).toEqual({
+ maxRetries: 0,
+ });
+ });
+});
diff --git a/packages/pdfmonkey/error-handlers.ts b/packages/pdfmonkey/error-handlers.ts
new file mode 100644
index 000000000..4ac636b55
--- /dev/null
+++ b/packages/pdfmonkey/error-handlers.ts
@@ -0,0 +1,31 @@
+import type { CorsairErrorHandler } from 'corsair/core';
+import { ApiError } from 'corsair/http';
+
+export const errorHandlers = {
+ RATE_LIMIT_ERROR: {
+ match: (error: Error) => {
+ if (error instanceof ApiError && error.status === 429) return true;
+ const msg = error.message.toLowerCase();
+ return msg.includes('too many requests') || msg.includes('rate limit');
+ },
+ handler: async (error: Error) => {
+ let retryAfterMs: number | undefined;
+ if (error instanceof ApiError && error.retryAfter !== undefined) {
+ retryAfterMs = error.retryAfter;
+ }
+ return { maxRetries: 5, headersRetryAfterMs: retryAfterMs };
+ },
+ },
+ AUTH_ERROR: {
+ match: (error: Error) => {
+ if (error instanceof ApiError && error.status === 401) return true;
+ const msg = error.message.toLowerCase();
+ return msg.includes('unauthorized') || msg.includes('invalid_auth');
+ },
+ handler: async () => ({ maxRetries: 0 }),
+ },
+ DEFAULT: {
+ match: () => true,
+ handler: async () => ({ maxRetries: 0 }),
+ },
+} satisfies CorsairErrorHandler;
diff --git a/packages/pdfmonkey/index.ts b/packages/pdfmonkey/index.ts
new file mode 100644
index 000000000..74ca0cc22
--- /dev/null
+++ b/packages/pdfmonkey/index.ts
@@ -0,0 +1,342 @@
+import type {
+ AuthTypes,
+ BindEndpoints,
+ BindWebhooks,
+ CorsairEndpoint,
+ CorsairErrorHandler,
+ CorsairPlugin,
+ CorsairPluginContext,
+ CorsairWebhook,
+ KeyBuilderContext,
+ PickAuth,
+ PluginAuthConfig,
+ PluginPermissionsConfig,
+ RequiredPluginEndpointMeta,
+ RequiredPluginEndpointSchemas,
+ RequiredPluginWebhookSchemas,
+} from 'corsair/core';
+import { AuthMissingError } from 'corsair/core';
+import { Document, Template } from './endpoints';
+import type {
+ PDFMonkeyEndpointInputs,
+ PDFMonkeyEndpointOutputs,
+} from './endpoints/types';
+import {
+ PDFMonkeyEndpointInputSchemas,
+ PDFMonkeyEndpointOutputSchemas,
+} from './endpoints/types';
+import { errorHandlers } from './error-handlers';
+import { PDFMonkeySchema } from './schema';
+import { DocumentWebhooks } from './webhooks';
+import { matchPDFMonkeyTenantWebhook } from './webhooks/tenant-matcher';
+import type {
+ DocumentGenerationFailureEvent,
+ DocumentGenerationSuccessEvent,
+ PDFMonkeyWebhookOutputs,
+} from './webhooks/types';
+import {
+ DocumentGenerationFailureEventSchema,
+ DocumentGenerationSuccessEventSchema,
+ matchPDFMonkeyPluginWebhook,
+} from './webhooks/types';
+
+export type PDFMonkeyPluginOptions = {
+ authType?: PickAuth<'api_key'>;
+ key?: string;
+ webhookSecret?: string;
+ hooks?: InternalPDFMonkeyPlugin['hooks'];
+ webhookHooks?: InternalPDFMonkeyPlugin['webhookHooks'];
+ errorHandlers?: CorsairErrorHandler;
+ permissions?: PluginPermissionsConfig;
+};
+
+export type PDFMonkeyContext = CorsairPluginContext<
+ typeof PDFMonkeySchema,
+ PDFMonkeyPluginOptions
+>;
+
+export type PDFMonkeyKeyBuilderContext =
+ KeyBuilderContext;
+
+export type PDFMonkeyBoundEndpoints = BindEndpoints<
+ typeof pDFMonkeyEndpointsNested
+>;
+
+type PDFMonkeyEndpoint =
+ CorsairEndpoint<
+ PDFMonkeyContext,
+ PDFMonkeyEndpointInputs[K],
+ PDFMonkeyEndpointOutputs[K]
+ >;
+
+export type PDFMonkeyEndpoints = {
+ listTemplateCards: PDFMonkeyEndpoint<'listTemplateCards'>;
+ getTemplate: PDFMonkeyEndpoint<'getTemplate'>;
+ createTemplate: PDFMonkeyEndpoint<'createTemplate'>;
+ updateTemplate: PDFMonkeyEndpoint<'updateTemplate'>;
+ deleteTemplate: PDFMonkeyEndpoint<'deleteTemplate'>;
+ createDocument: PDFMonkeyEndpoint<'createDocument'>;
+ createDocumentSync: PDFMonkeyEndpoint<'createDocumentSync'>;
+ getDocumentCard: PDFMonkeyEndpoint<'getDocumentCard'>;
+ listDocumentCards: PDFMonkeyEndpoint<'listDocumentCards'>;
+ getDocument: PDFMonkeyEndpoint<'getDocument'>;
+ updateDocument: PDFMonkeyEndpoint<'updateDocument'>;
+ deleteDocument: PDFMonkeyEndpoint<'deleteDocument'>;
+};
+
+type PDFMonkeyWebhook<
+ K extends keyof PDFMonkeyWebhookOutputs,
+ TEvent,
+> = CorsairWebhook;
+
+export type PDFMonkeyWebhooks = {
+ generationSuccess: PDFMonkeyWebhook<
+ 'generationSuccess',
+ DocumentGenerationSuccessEvent
+ >;
+ generationFailure: PDFMonkeyWebhook<
+ 'generationFailure',
+ DocumentGenerationFailureEvent
+ >;
+};
+
+export type PDFMonkeyBoundWebhooks = BindWebhooks;
+
+const pDFMonkeyEndpointsNested = {
+ templates: {
+ listTemplateCards: Template.listTemplateCards,
+ getTemplate: Template.getTemplate,
+ createTemplate: Template.createTemplate,
+ updateTemplate: Template.updateTemplate,
+ deleteTemplate: Template.deleteTemplate,
+ },
+ documents: {
+ createDocument: Document.createDocument,
+ createDocumentSync: Document.createDocumentSync,
+ getDocumentCard: Document.getDocumentCard,
+ listDocumentCards: Document.listDocumentCards,
+ getDocument: Document.getDocument,
+ updateDocument: Document.updateDocument,
+ deleteDocument: Document.deleteDocument,
+ },
+} as const;
+
+export const pDFMonkeyEndpointSchemas = {
+ 'templates.listTemplateCards': {
+ input: PDFMonkeyEndpointInputSchemas.listTemplateCards,
+ output: PDFMonkeyEndpointOutputSchemas.listTemplateCards,
+ },
+ 'templates.getTemplate': {
+ input: PDFMonkeyEndpointInputSchemas.getTemplate,
+ output: PDFMonkeyEndpointOutputSchemas.getTemplate,
+ },
+ 'templates.createTemplate': {
+ input: PDFMonkeyEndpointInputSchemas.createTemplate,
+ output: PDFMonkeyEndpointOutputSchemas.createTemplate,
+ },
+ 'templates.updateTemplate': {
+ input: PDFMonkeyEndpointInputSchemas.updateTemplate,
+ output: PDFMonkeyEndpointOutputSchemas.updateTemplate,
+ },
+ 'templates.deleteTemplate': {
+ input: PDFMonkeyEndpointInputSchemas.deleteTemplate,
+ output: PDFMonkeyEndpointOutputSchemas.deleteTemplate,
+ },
+ 'documents.createDocument': {
+ input: PDFMonkeyEndpointInputSchemas.createDocument,
+ output: PDFMonkeyEndpointOutputSchemas.createDocument,
+ },
+ 'documents.createDocumentSync': {
+ input: PDFMonkeyEndpointInputSchemas.createDocumentSync,
+ output: PDFMonkeyEndpointOutputSchemas.createDocumentSync,
+ },
+ 'documents.getDocumentCard': {
+ input: PDFMonkeyEndpointInputSchemas.getDocumentCard,
+ output: PDFMonkeyEndpointOutputSchemas.getDocumentCard,
+ },
+ 'documents.listDocumentCards': {
+ input: PDFMonkeyEndpointInputSchemas.listDocumentCards,
+ output: PDFMonkeyEndpointOutputSchemas.listDocumentCards,
+ },
+ 'documents.getDocument': {
+ input: PDFMonkeyEndpointInputSchemas.getDocument,
+ output: PDFMonkeyEndpointOutputSchemas.getDocument,
+ },
+ 'documents.updateDocument': {
+ input: PDFMonkeyEndpointInputSchemas.updateDocument,
+ output: PDFMonkeyEndpointOutputSchemas.updateDocument,
+ },
+ 'documents.deleteDocument': {
+ input: PDFMonkeyEndpointInputSchemas.deleteDocument,
+ output: PDFMonkeyEndpointOutputSchemas.deleteDocument,
+ },
+} satisfies RequiredPluginEndpointSchemas;
+
+const pDFMonkeyWebhooksNested = {
+ documents: {
+ generationSuccess: DocumentWebhooks.generationSuccess,
+ generationFailure: DocumentWebhooks.generationFailure,
+ },
+} as const;
+
+export const pDFMonkeyWebhookSchemas = {
+ 'documents.generationSuccess': {
+ description: 'A document finished generating successfully',
+ payload: DocumentGenerationSuccessEventSchema,
+ response: DocumentGenerationSuccessEventSchema,
+ },
+ 'documents.generationFailure': {
+ description: 'A document failed to generate',
+ payload: DocumentGenerationFailureEventSchema,
+ response: DocumentGenerationFailureEventSchema,
+ },
+} as const satisfies RequiredPluginWebhookSchemas<
+ typeof pDFMonkeyWebhooksNested
+>;
+
+const defaultAuthType: AuthTypes = 'api_key' as const;
+
+const pDFMonkeyEndpointMeta = {
+ 'templates.listTemplateCards': {
+ riskLevel: 'read',
+ description: 'List template cards for a workspace',
+ },
+ 'templates.getTemplate': {
+ riskLevel: 'read',
+ description: 'Get a template by ID',
+ },
+ 'templates.createTemplate': {
+ riskLevel: 'write',
+ description: 'Create a new document template',
+ },
+ 'templates.updateTemplate': {
+ riskLevel: 'write',
+ description: 'Update an existing template',
+ },
+ 'templates.deleteTemplate': {
+ riskLevel: 'destructive',
+ irreversible: true,
+ description: 'Delete a template [DESTRUCTIVE · IRREVERSIBLE]',
+ },
+ 'documents.createDocument': {
+ riskLevel: 'write',
+ description: 'Create a document and queue it for PDF generation',
+ },
+ 'documents.createDocumentSync': {
+ riskLevel: 'write',
+ description: 'Create a document and wait for generation to complete',
+ },
+ 'documents.getDocumentCard': {
+ riskLevel: 'read',
+ description: 'Get a document card with status and download URL',
+ },
+ 'documents.listDocumentCards': {
+ riskLevel: 'read',
+ description: 'List document cards with pagination and filters',
+ },
+ 'documents.getDocument': {
+ riskLevel: 'read',
+ description: 'Get a full document including payload and generation logs',
+ },
+ 'documents.updateDocument': {
+ riskLevel: 'write',
+ description: "Update a document's payload, metadata, or template",
+ },
+ 'documents.deleteDocument': {
+ riskLevel: 'destructive',
+ irreversible: true,
+ description: 'Delete a document [DESTRUCTIVE · IRREVERSIBLE]',
+ },
+} satisfies RequiredPluginEndpointMeta;
+
+export const pDFMonkeyAuthConfig = {
+ api_key: {
+ account: ['tenant_external_id'] as const,
+ },
+} as const satisfies PluginAuthConfig;
+
+export type BasePDFMonkeyPlugin =
+ CorsairPlugin<
+ 'pdfmonkey',
+ typeof PDFMonkeySchema,
+ typeof pDFMonkeyEndpointsNested,
+ typeof pDFMonkeyWebhooksNested,
+ T,
+ typeof defaultAuthType
+ >;
+
+export type InternalPDFMonkeyPlugin =
+ BasePDFMonkeyPlugin;
+
+export type ExternalPDFMonkeyPlugin =
+ BasePDFMonkeyPlugin;
+
+export function pdfmonkey(
+ incomingOptions: PDFMonkeyPluginOptions & T = {} as PDFMonkeyPluginOptions &
+ T,
+): ExternalPDFMonkeyPlugin {
+ const options = {
+ ...incomingOptions,
+ authType: incomingOptions.authType ?? defaultAuthType,
+ };
+ return {
+ id: 'pdfmonkey',
+ authConfig: pDFMonkeyAuthConfig,
+ schema: PDFMonkeySchema,
+ options: options,
+ hooks: options.hooks,
+ webhookHooks: options.webhookHooks,
+ endpoints: pDFMonkeyEndpointsNested,
+ webhooks: pDFMonkeyWebhooksNested,
+ endpointMeta: pDFMonkeyEndpointMeta,
+ endpointSchemas: pDFMonkeyEndpointSchemas,
+ webhookSchemas: pDFMonkeyWebhookSchemas,
+ pluginWebhookMatcher: matchPDFMonkeyPluginWebhook,
+ pluginTenantWebhookMatcher: matchPDFMonkeyTenantWebhook,
+ errorHandlers: (() => {
+ const { DEFAULT: defaultHandler, ...specificDefaults } = errorHandlers;
+ return {
+ ...specificDefaults,
+ ...(options.errorHandlers || {}),
+ DEFAULT: options.errorHandlers?.DEFAULT || defaultHandler,
+ };
+ })(),
+ keyBuilder: async (ctx: PDFMonkeyKeyBuilderContext, source) => {
+ if (source === 'webhook' && options.webhookSecret) {
+ return options.webhookSecret;
+ }
+
+ if (source === 'webhook') {
+ const res = await ctx.keys.get_webhook_signature();
+ if (!res) {
+ throw new AuthMissingError('pdfmonkey', 'webhook_signature');
+ }
+ return res;
+ }
+
+ if (source === 'endpoint' && options.key) {
+ return options.key;
+ }
+
+ if (source === 'endpoint' && ctx.authType === 'api_key') {
+ const res = await ctx.keys.get_api_key();
+ if (!res) {
+ throw new AuthMissingError('pdfmonkey', 'api_key');
+ }
+ return res;
+ }
+
+ throw new AuthMissingError('pdfmonkey', 'api_key');
+ },
+ } satisfies InternalPDFMonkeyPlugin;
+}
+
+export type {
+ PDFMonkeyEndpointInputs,
+ PDFMonkeyEndpointOutputs,
+} from './endpoints/types';
+export type {
+ DocumentGenerationFailureEvent,
+ DocumentGenerationSuccessEvent,
+ PDFMonkeyWebhookOutputs,
+} from './webhooks/types';
diff --git a/packages/pdfmonkey/jest.config.cjs b/packages/pdfmonkey/jest.config.cjs
new file mode 100644
index 000000000..8c6218f64
--- /dev/null
+++ b/packages/pdfmonkey/jest.config.cjs
@@ -0,0 +1,55 @@
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ roots: [''],
+ testMatch: [
+ '**/*.test.ts',
+ '**/tests/**/*.test.ts',
+ '**/plugins/**/*.test.ts',
+ '**/setup/**/*.test.ts',
+ ],
+ collectCoverageFrom: [
+ '**/*.ts',
+ '!**/*.d.ts',
+ '!**/node_modules/**',
+ '!**/dist/**',
+ '!jest.config.ts',
+ '!tests/**',
+ ],
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
+ transform: {
+ '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs',
+ '^.+\\.ts$': [
+ 'ts-jest',
+ {
+ useESM: true,
+ tsconfig: {
+ esModuleInterop: true,
+ allowSyntheticDefaultImports: true,
+ verbatimModuleSyntax: false,
+ module: 'ESNext',
+ moduleResolution: 'Bundler',
+ },
+ },
+ ],
+ '.*\\.js$': [
+ 'ts-jest',
+ {
+ useESM: true,
+ tsconfig: {
+ esModuleInterop: true,
+ allowSyntheticDefaultImports: true,
+ },
+ },
+ ],
+ },
+ moduleNameMapper: {
+ '^corsair/core$': '/../corsair/core.ts',
+ '^corsair/http$': '/../corsair/http.ts',
+ '^(\\.\\.?/.*)\\.js$': '$1',
+ },
+ transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'],
+ extensionsToTreatAsEsm: ['.ts'],
+ testTimeout: 30000,
+ verbose: true,
+};
diff --git a/packages/pdfmonkey/package.json b/packages/pdfmonkey/package.json
new file mode 100644
index 000000000..fe5eebfa4
--- /dev/null
+++ b/packages/pdfmonkey/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@corsair-dev/pdfmonkey",
+ "version": "0.1.0",
+ "description": "PDFMonkey plugin for Corsair",
+ "type": "module",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "dev-source": "./index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "rm -rf dist && tsc --build --force && tsup",
+ "typecheck": "tsc --noEmit",
+ "test": "jest"
+ },
+ "peerDependencies": {
+ "corsair": ">=0.1.0",
+ "zod": "^4.1.13"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.14",
+ "corsair": "workspace:*",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.4.9",
+ "tsup": "^8.0.1",
+ "typescript": "catalog:",
+ "zod": "^4.1.13"
+ },
+ "keywords": [
+ "corsair",
+ "pdfmonkey",
+ "plugin"
+ ],
+ "author": "",
+ "license": "Apache-2.0",
+ "files": [
+ "dist"
+ ]
+}
diff --git a/packages/pdfmonkey/schema.test.ts b/packages/pdfmonkey/schema.test.ts
new file mode 100644
index 000000000..a62435bc0
--- /dev/null
+++ b/packages/pdfmonkey/schema.test.ts
@@ -0,0 +1,184 @@
+import {
+ CreateDocumentInputSchema,
+ CreateDocumentSyncInputSchema,
+ CreateTemplateInputSchema,
+ DeleteDocumentInputSchema,
+ DeleteTemplateInputSchema,
+ DocumentCardSchema,
+ DocumentSchema,
+ DocumentTemplateCardSchema,
+ GetDocumentCardInputSchema,
+ GetTemplateInputSchema,
+ ListDocumentCardsInputSchema,
+ ListTemplateCardsInputSchema,
+ PDFMonkeyEndpointInputSchemas,
+ PDFMonkeyEndpointOutputSchemas,
+ UpdateDocumentInputSchema,
+ UpdateTemplateInputSchema,
+} from './endpoints/types';
+import { PDFMonkeySchema } from './schema';
+
+describe('PDFMonkey schema', () => {
+ it('declares a semver version and empty entities', () => {
+ expect(PDFMonkeySchema.version).toMatch(/^\d+\.\d+\.\d+$/);
+ expect(PDFMonkeySchema.entities).toEqual({});
+ });
+
+ it('validates DocumentTemplateCardSchema', () => {
+ const result = DocumentTemplateCardSchema.safeParse({
+ id: 'test-id',
+ app_id: 'test-app',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('validates DocumentCardSchema', () => {
+ const result = DocumentCardSchema.safeParse({
+ id: 'doc-id',
+ app_id: 'test-app',
+ status: 'draft',
+ download_url: null,
+ preview_url: null,
+ public_share_link: null,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('validates DocumentSchema', () => {
+ const result = DocumentSchema.safeParse({
+ id: 'doc-id',
+ app_id: 'test-app',
+ document_template_id: 'template-id',
+ status: 'pending',
+ payload: { clientName: 'Ada' },
+ meta: null,
+ filename: null,
+ download_url: null,
+ preview_url: null,
+ public_share_link: null,
+ checksum: 'abc123',
+ generation_logs: [],
+ failure_cause: null,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('validates nested list query inputs', () => {
+ expect(
+ ListTemplateCardsInputSchema.parse({
+ q: { workspace_id: 'ws-123' },
+ }),
+ ).toMatchObject({
+ page: 1,
+ q: { workspace_id: 'ws-123' },
+ });
+ expect(
+ ListDocumentCardsInputSchema.parse({
+ page: 2,
+ q: { status: 'pending' },
+ }),
+ ).toMatchObject({
+ page: 2,
+ q: { status: 'pending' },
+ });
+ });
+
+ it('rejects list template cards without workspace_id', () => {
+ expect(ListTemplateCardsInputSchema.safeParse({ page: 1 }).success).toBe(
+ false,
+ );
+ });
+
+ it('requires update bodies', () => {
+ expect(
+ UpdateTemplateInputSchema.safeParse({
+ document_template_id: 'temp-1',
+ }).success,
+ ).toBe(false);
+ expect(
+ UpdateDocumentInputSchema.safeParse({ document_id: 'doc-1' }).success,
+ ).toBe(false);
+ expect(
+ UpdateTemplateInputSchema.parse({
+ document_template_id: 'temp-1',
+ document_template: { identifier: 'updated' },
+ }),
+ ).toMatchObject({
+ document_template: { identifier: 'updated' },
+ });
+ expect(
+ UpdateDocumentInputSchema.parse({
+ document_id: 'doc-1',
+ document: { status: 'pending' },
+ }),
+ ).toMatchObject({
+ document: { status: 'pending' },
+ });
+ });
+
+ it('defaults createDocumentSync status to pending', () => {
+ expect(
+ CreateDocumentSyncInputSchema.parse({
+ document: { document_template_id: 'temp-1' },
+ }),
+ ).toMatchObject({
+ document: { document_template_id: 'temp-1', status: 'pending' },
+ });
+ });
+
+ it('validates remaining input schemas', () => {
+ expect(GetTemplateInputSchema.parse({ id: 'template-123' }).id).toBe(
+ 'template-123',
+ );
+ expect(GetDocumentCardInputSchema.parse({ id: 'doc-456' }).id).toBe(
+ 'doc-456',
+ );
+ expect(
+ CreateTemplateInputSchema.parse({
+ document_template: {
+ app_id: 'app-1',
+ identifier: 'my-template',
+ body: 'Hello
',
+ },
+ }).document_template.identifier,
+ ).toBe('my-template');
+ expect(
+ CreateDocumentInputSchema.parse({
+ document: {
+ document_template_id: 'temp-1',
+ status: 'pending',
+ },
+ }).document.document_template_id,
+ ).toBe('temp-1');
+ expect(DeleteTemplateInputSchema.parse({ id: 'temp-1' }).id).toBe('temp-1');
+ expect(DeleteDocumentInputSchema.parse({ id: 'doc-1' }).id).toBe('doc-1');
+ });
+
+ it('registers input and output schemas for every operation', () => {
+ const operations = [
+ 'listTemplateCards',
+ 'getTemplate',
+ 'createTemplate',
+ 'updateTemplate',
+ 'deleteTemplate',
+ 'createDocument',
+ 'createDocumentSync',
+ 'getDocumentCard',
+ 'listDocumentCards',
+ 'getDocument',
+ 'updateDocument',
+ 'deleteDocument',
+ ] as const;
+
+ for (const operation of operations) {
+ expect(PDFMonkeyEndpointInputSchemas[operation]).toBeDefined();
+ expect(PDFMonkeyEndpointOutputSchemas[operation]).toBeDefined();
+ }
+ });
+});
diff --git a/packages/pdfmonkey/schema/database.ts b/packages/pdfmonkey/schema/database.ts
new file mode 100644
index 000000000..cb0ff5c3b
--- /dev/null
+++ b/packages/pdfmonkey/schema/database.ts
@@ -0,0 +1 @@
+export {};
diff --git a/packages/pdfmonkey/schema/index.ts b/packages/pdfmonkey/schema/index.ts
new file mode 100644
index 000000000..757884ce4
--- /dev/null
+++ b/packages/pdfmonkey/schema/index.ts
@@ -0,0 +1,4 @@
+export const PDFMonkeySchema = {
+ version: '1.0.0',
+ entities: {},
+} as const;
diff --git a/packages/pdfmonkey/tsconfig.json b/packages/pdfmonkey/tsconfig.json
new file mode 100644
index 000000000..15e507a13
--- /dev/null
+++ b/packages/pdfmonkey/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["esnext"],
+ "types": ["node", "jest"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "outDir": "./dist",
+ "rootDir": "./",
+ "composite": true,
+ "incremental": true,
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true,
+ "skipLibCheck": true
+ },
+ "include": ["./**/*"],
+ "exclude": ["dist", "node_modules"],
+ "references": []
+}
diff --git a/packages/pdfmonkey/tsup.config.ts b/packages/pdfmonkey/tsup.config.ts
new file mode 100644
index 000000000..3ec221e23
--- /dev/null
+++ b/packages/pdfmonkey/tsup.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ clean: false,
+ dts: false,
+ format: ['esm'],
+ target: 'esnext',
+ platform: 'node',
+ bundle: true,
+ splitting: true,
+ minify: true,
+ outDir: 'dist',
+ external: ['corsair', 'zod'],
+ entry: ['index.ts'],
+});
diff --git a/packages/pdfmonkey/webhooks/documents.ts b/packages/pdfmonkey/webhooks/documents.ts
new file mode 100644
index 000000000..b2899718a
--- /dev/null
+++ b/packages/pdfmonkey/webhooks/documents.ts
@@ -0,0 +1,60 @@
+import { logEventFromContext } from 'corsair/core';
+import type { PDFMonkeyWebhooks } from '../index';
+import {
+ createPDFMonkeyMatch,
+ DocumentGenerationFailureEventSchema,
+ DocumentGenerationSuccessEventSchema,
+ verifyPDFMonkeyWebhookSignature,
+} from './types';
+
+export const generationSuccess: PDFMonkeyWebhooks['generationSuccess'] = {
+ match: createPDFMonkeyMatch('success'),
+
+ handler: async (ctx, request) => {
+ const verification = verifyPDFMonkeyWebhookSignature(request, ctx.key);
+ if (!verification.valid) {
+ return {
+ success: false,
+ statusCode: 401,
+ error: verification.error || 'Signature verification failed',
+ };
+ }
+
+ const event = DocumentGenerationSuccessEventSchema.parse(request.payload);
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.webhook.generationSuccess',
+ { id: event.document.id, status: event.document.status },
+ 'completed',
+ );
+
+ return { success: true, data: event };
+ },
+};
+
+export const generationFailure: PDFMonkeyWebhooks['generationFailure'] = {
+ match: createPDFMonkeyMatch('failure'),
+
+ handler: async (ctx, request) => {
+ const verification = verifyPDFMonkeyWebhookSignature(request, ctx.key);
+ if (!verification.valid) {
+ return {
+ success: false,
+ statusCode: 401,
+ error: verification.error || 'Signature verification failed',
+ };
+ }
+
+ const event = DocumentGenerationFailureEventSchema.parse(request.payload);
+
+ await logEventFromContext(
+ ctx,
+ 'pdfmonkey.webhook.generationFailure',
+ { id: event.document.id, status: event.document.status },
+ 'completed',
+ );
+
+ return { success: true, data: event };
+ },
+};
diff --git a/packages/pdfmonkey/webhooks/index.ts b/packages/pdfmonkey/webhooks/index.ts
new file mode 100644
index 000000000..d165cc24f
--- /dev/null
+++ b/packages/pdfmonkey/webhooks/index.ts
@@ -0,0 +1,9 @@
+import { generationFailure, generationSuccess } from './documents';
+
+export const DocumentWebhooks = {
+ generationSuccess,
+ generationFailure,
+};
+
+export * from './tenant-matcher';
+export * from './types';
diff --git a/packages/pdfmonkey/webhooks/tenant-matcher.ts b/packages/pdfmonkey/webhooks/tenant-matcher.ts
new file mode 100644
index 000000000..b843def9e
--- /dev/null
+++ b/packages/pdfmonkey/webhooks/tenant-matcher.ts
@@ -0,0 +1,15 @@
+import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core';
+import { asRecord, firstString, readBodyRecord } from 'corsair/core';
+
+export function matchPDFMonkeyTenantWebhook(
+ request: RawWebhookRequest,
+): WebhookTenantMatch | null {
+ const body = readBodyRecord(request);
+ if (!body) return null;
+
+ const document = asRecord(body.document);
+ const externalId = firstString([document?.app_id, body.app_id]);
+ if (!externalId) return null;
+
+ return { linkType: 'tenant_external_id', externalId };
+}
diff --git a/packages/pdfmonkey/webhooks/types.test.ts b/packages/pdfmonkey/webhooks/types.test.ts
new file mode 100644
index 000000000..e530b2106
--- /dev/null
+++ b/packages/pdfmonkey/webhooks/types.test.ts
@@ -0,0 +1,201 @@
+import type { WebhookRequest } from 'corsair/core';
+import { createHmac } from 'crypto';
+import { matchPDFMonkeyTenantWebhook } from './tenant-matcher';
+import {
+ createPDFMonkeyMatch,
+ matchPDFMonkeyPluginWebhook,
+ verifyPDFMonkeyWebhookSignature,
+} from './types';
+
+const SECRET_BYTES = Buffer.from('pdfmonkey-test-secret', 'utf8');
+const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`;
+const SVIX_ID = 'msg_test_1';
+
+const successPayload = {
+ document: {
+ id: 'doc-1',
+ app_id: 'app-1',
+ status: 'success',
+ download_url: 'https://files.example.com/doc.pdf',
+ preview_url: null,
+ public_share_link: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ },
+};
+
+const rawBody = JSON.stringify(successPayload);
+
+function sign(id: string, timestamp: string, body: string, secret = SECRET) {
+ const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
+ const digest = createHmac('sha256', key)
+ .update(`${id}.${timestamp}.${body}`)
+ .digest('base64');
+ return `v1,${digest}`;
+}
+
+function requestWith(
+ headers: Record,
+ body: string | null = rawBody,
+): WebhookRequest {
+ return {
+ payload: successPayload,
+ headers,
+ rawBody: body === null ? undefined : body,
+ };
+}
+
+describe('verifyPDFMonkeyWebhookSignature', () => {
+ const timestamp = String(Math.floor(Date.now() / 1000));
+
+ it('rejects a missing secret', () => {
+ expect(
+ verifyPDFMonkeyWebhookSignature(
+ requestWith({
+ 'svix-id': SVIX_ID,
+ 'svix-timestamp': timestamp,
+ 'svix-signature': sign(SVIX_ID, timestamp, rawBody),
+ }),
+ undefined,
+ ),
+ ).toEqual({ valid: false, error: 'Missing webhook secret' });
+ });
+
+ it('rejects a missing raw body', () => {
+ expect(
+ verifyPDFMonkeyWebhookSignature(
+ requestWith(
+ {
+ 'svix-id': SVIX_ID,
+ 'svix-timestamp': timestamp,
+ 'svix-signature': sign(SVIX_ID, timestamp, rawBody),
+ },
+ null,
+ ),
+ SECRET,
+ ),
+ ).toEqual({
+ valid: false,
+ error: 'Missing raw body for signature verification',
+ });
+ });
+
+ it('rejects missing Svix headers', () => {
+ expect(verifyPDFMonkeyWebhookSignature(requestWith({}), SECRET)).toEqual({
+ valid: false,
+ error: 'Missing svix-id header',
+ });
+ });
+
+ it('rejects a malformed webhook secret that would decode to an empty key', () => {
+ expect(
+ verifyPDFMonkeyWebhookSignature(
+ requestWith({
+ 'svix-id': SVIX_ID,
+ 'svix-timestamp': timestamp,
+ 'svix-signature': sign(SVIX_ID, timestamp, rawBody),
+ }),
+ 'whsec_!!!!',
+ ),
+ ).toEqual({ valid: false, error: 'Malformed webhook secret' });
+ });
+
+ it('rejects a stale timestamp', () => {
+ const stale = String(Math.floor(Date.now() / 1000) - 10 * 60);
+ expect(
+ verifyPDFMonkeyWebhookSignature(
+ requestWith({
+ 'svix-id': SVIX_ID,
+ 'svix-timestamp': stale,
+ 'svix-signature': sign(SVIX_ID, stale, rawBody),
+ }),
+ SECRET,
+ ),
+ ).toEqual({
+ valid: false,
+ error: 'Webhook timestamp is too old or invalid',
+ });
+ });
+
+ it('accepts a correctly signed Svix request', () => {
+ expect(
+ verifyPDFMonkeyWebhookSignature(
+ requestWith({
+ 'svix-id': SVIX_ID,
+ 'svix-timestamp': timestamp,
+ 'svix-signature': sign(SVIX_ID, timestamp, rawBody),
+ }),
+ SECRET,
+ ),
+ ).toEqual({ valid: true });
+ });
+
+ it('rejects a signature over the wrong content', () => {
+ expect(
+ verifyPDFMonkeyWebhookSignature(
+ requestWith({
+ 'svix-id': SVIX_ID,
+ 'svix-timestamp': timestamp,
+ 'svix-signature': sign(SVIX_ID, timestamp, '{"tampered":true}'),
+ }),
+ SECRET,
+ ),
+ ).toEqual({ valid: false, error: 'Invalid signature' });
+ });
+});
+
+describe('PDFMonkey webhook matchers', () => {
+ it('plugin matcher accepts Svix document payloads and rejects Resend events', () => {
+ expect(
+ matchPDFMonkeyPluginWebhook({
+ headers: {
+ 'svix-id': SVIX_ID,
+ 'svix-timestamp': '1',
+ 'svix-signature': 'v1,abc',
+ },
+ body: successPayload,
+ }),
+ ).toBe(true);
+ expect(
+ matchPDFMonkeyPluginWebhook({
+ headers: { 'x-pdfmonkey-signature': 'nope' },
+ body: successPayload,
+ }),
+ ).toBe(false);
+ expect(
+ matchPDFMonkeyPluginWebhook({
+ headers: {
+ 'svix-id': SVIX_ID,
+ 'svix-timestamp': '1',
+ 'svix-signature': 'v1,abc',
+ },
+ body: { type: 'email.sent', data: {} },
+ }),
+ ).toBe(false);
+ });
+
+ it('event matcher uses document.status', () => {
+ const success = createPDFMonkeyMatch('success');
+ const failure = createPDFMonkeyMatch('failure');
+ const headers = { 'svix-signature': 'v1,abc' };
+ expect(success({ headers, body: successPayload })).toBe(true);
+ expect(failure({ headers, body: successPayload })).toBe(false);
+ expect(
+ failure({
+ headers,
+ body: {
+ document: { ...successPayload.document, status: 'failure' },
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('tenant matcher reads document.app_id', () => {
+ expect(
+ matchPDFMonkeyTenantWebhook({
+ headers: {},
+ body: successPayload,
+ }),
+ ).toEqual({ linkType: 'tenant_external_id', externalId: 'app-1' });
+ });
+});
diff --git a/packages/pdfmonkey/webhooks/types.ts b/packages/pdfmonkey/webhooks/types.ts
new file mode 100644
index 000000000..f546a196b
--- /dev/null
+++ b/packages/pdfmonkey/webhooks/types.ts
@@ -0,0 +1,173 @@
+import type {
+ CorsairWebhookMatcher,
+ RawWebhookRequest,
+ WebhookRequest,
+} from 'corsair/core';
+import { createHmac, timingSafeEqual } from 'crypto';
+import { z } from 'zod';
+import { DocumentCardSchema } from '../endpoints/types';
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function parseBody(body: unknown): Record | null {
+ if (typeof body === 'string') {
+ try {
+ const parsed = JSON.parse(body);
+ return isRecord(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+ }
+ return isRecord(body) ? body : null;
+}
+
+function getHeader(
+ headers: WebhookRequest['headers'],
+ name: string,
+): string | undefined {
+ const lower = name.toLowerCase();
+ for (const [key, value] of Object.entries(headers)) {
+ if (key.toLowerCase() !== lower) continue;
+ return Array.isArray(value) ? value[0] : value;
+ }
+ return undefined;
+}
+
+function extractSvixSignatures(signatureHeader: string): string[] {
+ return signatureHeader
+ .split(' ')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .flatMap((part) => {
+ const [version, signature] = part.split(',', 2);
+ return version === 'v1' && signature ? [signature] : [];
+ });
+}
+
+export const DocumentGenerationSuccessEventSchema = z.object({
+ type: z.literal('documents.generation.success').optional(),
+ document: DocumentCardSchema.extend({
+ status: z.literal('success'),
+ }),
+});
+
+export type DocumentGenerationSuccessEvent = z.infer<
+ typeof DocumentGenerationSuccessEventSchema
+>;
+
+export const DocumentGenerationFailureEventSchema = z.object({
+ type: z.literal('documents.generation.failure').optional(),
+ document: DocumentCardSchema.extend({
+ status: z.literal('failure'),
+ }),
+});
+
+export type DocumentGenerationFailureEvent = z.infer<
+ typeof DocumentGenerationFailureEventSchema
+>;
+
+export type PDFMonkeyWebhookOutputs = {
+ generationSuccess: DocumentGenerationSuccessEvent;
+ generationFailure: DocumentGenerationFailureEvent;
+};
+
+export function createPDFMonkeyMatch(
+ status: 'success' | 'failure',
+): CorsairWebhookMatcher {
+ return (request: RawWebhookRequest) => {
+ if (!getHeader(request.headers, 'svix-signature')) return false;
+ const parsedBody = parseBody(request.body);
+ if (!parsedBody) return false;
+ const document = parsedBody.document;
+ return isRecord(document) && document.status === status;
+ };
+}
+
+export function matchPDFMonkeyPluginWebhook(
+ request: RawWebhookRequest,
+): boolean {
+ if (!getHeader(request.headers, 'svix-signature')) return false;
+ if (!getHeader(request.headers, 'svix-id')) return false;
+ if (!getHeader(request.headers, 'svix-timestamp')) return false;
+ const parsedBody = parseBody(request.body);
+ if (!parsedBody) return false;
+ const document = parsedBody.document;
+ return (
+ isRecord(document) &&
+ typeof document.id === 'string' &&
+ typeof document.status === 'string'
+ );
+}
+
+export function verifyPDFMonkeyWebhookSignature(
+ request: WebhookRequest,
+ secret?: string,
+): { valid: boolean; error?: string } {
+ if (!secret) {
+ return { valid: false, error: 'Missing webhook secret' };
+ }
+
+ const rawBody = request.rawBody;
+ if (!rawBody) {
+ return {
+ valid: false,
+ error: 'Missing raw body for signature verification',
+ };
+ }
+
+ const svixId = getHeader(request.headers, 'svix-id');
+ const svixTimestamp = getHeader(request.headers, 'svix-timestamp');
+ const svixSignature = getHeader(request.headers, 'svix-signature');
+
+ if (!svixId) {
+ return { valid: false, error: 'Missing svix-id header' };
+ }
+ if (!svixTimestamp) {
+ return { valid: false, error: 'Missing svix-timestamp header' };
+ }
+ if (!svixSignature) {
+ return { valid: false, error: 'Missing svix-signature header' };
+ }
+
+ const timestampMs = Number.parseInt(svixTimestamp, 10) * 1000;
+ if (
+ Number.isNaN(timestampMs) ||
+ Math.abs(Date.now() - timestampMs) > 5 * 60 * 1000
+ ) {
+ return { valid: false, error: 'Webhook timestamp is too old or invalid' };
+ }
+
+ if (!secret.startsWith('whsec_')) {
+ return { valid: false, error: 'Malformed webhook secret' };
+ }
+ const secretBase64 = secret.slice('whsec_'.length);
+ const secretKey = Buffer.from(secretBase64, 'base64');
+ if (!secretBase64 || secretKey.length === 0) {
+ return { valid: false, error: 'Malformed webhook secret' };
+ }
+
+ const signatures = extractSvixSignatures(svixSignature);
+ if (signatures.length === 0) {
+ return { valid: false, error: 'Malformed svix-signature header' };
+ }
+
+ const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`;
+ const expected = createHmac('sha256', secretKey)
+ .update(signedContent)
+ .digest();
+
+ const isValid = signatures.some((signature) => {
+ const received = Buffer.from(signature, 'base64');
+ return (
+ received.length === expected.length && timingSafeEqual(received, expected)
+ );
+ });
+
+ if (!isValid) {
+ return { valid: false, error: 'Invalid signature' };
+ }
+
+ return { valid: true };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd3d10ddc..e7ad98839 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -130,7 +130,7 @@ importers:
version: link:../../packages/slack
corsair:
specifier: ^0.1.4
- version: 0.1.119(postgres@3.4.7)(react@19.2.7)
+ version: link:../../packages/corsair
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -3992,6 +3992,30 @@ importers:
specifier: 4.4.3
version: 4.4.3
+ packages/pdfmonkey:
+ devDependencies:
+ '@types/jest':
+ specifier: ^29.5.14
+ version: 29.5.14
+ corsair:
+ specifier: workspace:*
+ version: link:../corsair
+ jest:
+ specifier: ^29.7.0
+ version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
+ ts-jest:
+ specifier: ^29.4.9
+ version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)
+ tsup:
+ specifier: ^8.0.1
+ version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0)
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+ zod:
+ specifier: 4.4.3
+ version: 4.4.3
+
packages/perplexityai:
devDependencies:
'@types/jest':
@@ -6494,36 +6518,6 @@ packages:
'@codemirror/view@6.43.3':
resolution: {integrity: sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng==}
- '@corsair-dev/frpc-darwin-arm64@0.1.117':
- resolution: {integrity: sha512-PrxfqHlRKzm5xW5J2wnZ1knVF4xV6svgmeo11YvHMAAUqsNBY6mczMQFlu1HJcMvZ9yysqMiTZBKdjAC+DnhlQ==}
- cpu: [arm64]
- os: [darwin]
-
- '@corsair-dev/frpc-darwin-x64@0.1.117':
- resolution: {integrity: sha512-8GZP5G5kPhu/TPGLnSgecvyyxXB6+ZdwDmXAhz3PvqiE8mmQ2PRa7FaoVYdOa4nY8XWa5YmnXC/DcjsTLIfPmQ==}
- cpu: [x64]
- os: [darwin]
-
- '@corsair-dev/frpc-linux-arm64@0.1.117':
- resolution: {integrity: sha512-TwVeRBYd17hy6YJByCUouMVBo2itK4fPuvm3TPwdq5ZVxN64PEKE2KHQIo+uW7opxcMXre6DVSzLQyFmrqI/Xg==}
- cpu: [arm64]
- os: [linux]
-
- '@corsair-dev/frpc-linux-x64@0.1.117':
- resolution: {integrity: sha512-Ch98qSFQXYSA6sg2ysTKrRCZ08tNWfXjSEHKT3nMM7vYhuDXkQcBmXM4tbPEO8SI6BzLa9/g1lsXT5T0hx48dQ==}
- cpu: [x64]
- os: [linux]
-
- '@corsair-dev/frpc-win32-arm64@0.1.117':
- resolution: {integrity: sha512-QyLWoaVNrq1C5V81d7DIOLm63n4+0YSKcKJK1MUTdaQAEroaejybeAuskiYhq11LCJ2wb+WTkv3qaWF0yLgg6g==}
- cpu: [arm64]
- os: [win32]
-
- '@corsair-dev/frpc-win32-x64@0.1.117':
- resolution: {integrity: sha512-V4sI1JUwlNBi9nAJHedXPXzmivfi5DHaU2yJMwnf++5PQvVcxRMJQcec1p9+Q8bPyAZ7gtq+4XdRuBJVZXdFkg==}
- cpu: [x64]
- os: [win32]
-
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -11578,14 +11572,6 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
- corsair@0.1.119:
- resolution: {integrity: sha512-Cl86/VhMNOdzTbUzmeb9RwlhuucojM1gEmgTQJsQYdcxP2qFiPPly2VGK9M2h5GkHLZ3kZ4HRALTQP3wIhepBQ==}
- peerDependencies:
- react: '>=18.0.0'
- peerDependenciesMeta:
- react:
- optional: true
-
cosmiconfig@9.0.1:
resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==}
engines: {node: '>=14'}
@@ -17973,24 +17959,6 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
- '@corsair-dev/frpc-darwin-arm64@0.1.117':
- optional: true
-
- '@corsair-dev/frpc-darwin-x64@0.1.117':
- optional: true
-
- '@corsair-dev/frpc-linux-arm64@0.1.117':
- optional: true
-
- '@corsair-dev/frpc-linux-x64@0.1.117':
- optional: true
-
- '@corsair-dev/frpc-win32-arm64@0.1.117':
- optional: true
-
- '@corsair-dev/frpc-win32-x64@0.1.117':
- optional: true
-
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -23559,23 +23527,6 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
- corsair@0.1.119(postgres@3.4.7)(react@19.2.7):
- dependencies:
- kysely: 0.28.17
- kysely-postgres-js: 3.0.0(kysely@0.28.17)(postgres@3.4.7)
- uuid: 13.0.0
- zod: 4.4.3
- optionalDependencies:
- '@corsair-dev/frpc-darwin-arm64': 0.1.117
- '@corsair-dev/frpc-darwin-x64': 0.1.117
- '@corsair-dev/frpc-linux-arm64': 0.1.117
- '@corsair-dev/frpc-linux-x64': 0.1.117
- '@corsair-dev/frpc-win32-arm64': 0.1.117
- '@corsair-dev/frpc-win32-x64': 0.1.117
- react: 19.2.7
- transitivePeerDependencies:
- - postgres
-
cosmiconfig@9.0.1(typescript@5.9.3):
dependencies:
env-paths: 2.2.1
@@ -25911,12 +25862,6 @@ snapshots:
kleur@4.1.5: {}
- kysely-postgres-js@3.0.0(kysely@0.28.17)(postgres@3.4.7):
- dependencies:
- kysely: 0.28.17
- optionalDependencies:
- postgres: 3.4.7
-
kysely-postgres-js@3.0.0(kysely@0.28.9)(postgres@3.4.7):
dependencies:
kysely: 0.28.9