diff --git a/backend/services/index.ts b/backend/services/index.ts index 85bac77b..e4fab959 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -172,6 +172,17 @@ export type { WebhookDeliveryResult, WebhookEventInput, } from './notification/webhook'; +// Unified webhook barrel (Issue #727 Technical Scope) +export { WebhookManagementApi, webhookManagementApi } from './notification/webhookManagementApi'; +// Event catalog — wildcard filtering & schema validation +export { + EventCatalogRegistry, + eventCatalog, + EVENT_CATALOG, +} from './webhook/eventCatalog'; +export type { EventDefinition, EventCategory, SchemaField } from './webhook/eventCatalog'; +export { EventSchemaValidator, eventSchemaValidator } from './webhook/eventSchemaValidator'; +export type { ValidationResult } from './webhook/eventSchemaValidator'; export { WebSocketServer, webSocketServer } from './notification/websocket'; export type { SubscriptionEventType as WSSubscriptionEventType, diff --git a/backend/services/notification/__tests__/webhook.test.ts b/backend/services/notification/__tests__/webhook.test.ts index 15f51a47..447671df 100644 --- a/backend/services/notification/__tests__/webhook.test.ts +++ b/backend/services/notification/__tests__/webhook.test.ts @@ -283,4 +283,189 @@ describe('WebhookDeliveryService', () => { expect(retry.delivery.status).toBe('delivered'); expect(retry.delivery.attempts).toBeGreaterThanOrEqual(1); }); + + // ── Wildcard Event Filtering (Issue #727) ──────────────────────────────────── + + it('allows events matching a wildcard category pattern (subscription.*)', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.*' as any], + secretKey: 'secret', + }); + + // subscription.created should match subscription.* + const result = await service.deliverEvent( + makeInput({ webhookId: webhook.id, eventType: 'subscription.created' as any }) + ); + expect(result?.delivery.status).toBe('delivered'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('blocks events that do not match the wildcard category (subscription.* vs payment.failed)', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.*' as any], + secretKey: 'secret', + }); + + const result = await service.deliverEvent( + makeInput({ webhookId: webhook.id, eventType: 'payment.failed' as any }) + ); + // deliverEvent returns null when the event is not allowed + expect(result).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('allows all events with the global wildcard (*)', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['*' as any], + secretKey: 'secret', + }); + + const r1 = await service.deliverEvent( + makeInput({ webhookId: webhook.id, eventType: 'subscription.created' as any }) + ); + const r2 = await service.deliverEvent( + makeInput({ webhookId: webhook.id, eventType: 'payment.failed' as any, idempotencyKey: 'key-2' }) + ); + const r3 = await service.deliverEvent( + makeInput({ webhookId: webhook.id, eventType: 'invoice.paid' as any, idempotencyKey: 'key-3' }) + ); + + expect(r1?.delivery.status).toBe('delivered'); + expect(r2?.delivery.status).toBe('delivered'); + expect(r3?.delivery.status).toBe('delivered'); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it('blocks delivery to a paused webhook regardless of wildcard pattern', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['*' as any], + secretKey: 'secret', + isPaused: true, + }); + + const result = await service.deliverEvent( + makeInput({ webhookId: webhook.id, eventType: 'subscription.created' as any }) + ); + expect(result).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + // ── Exponential Backoff Delay Calculation (Issue #727) ─────────────────────── + + it('computes exponential backoff delays correctly across multiple attempts', async () => { + const delays: number[] = []; + const fetchImpl = jest + .fn() + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ ok: true, status: 200 }); + const sleepImpl = jest.fn((ms: number) => { + delays.push(ms); + return Promise.resolve(); + }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch, sleepImpl }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.charged'], + secretKey: 'secret', + retryPolicy: { + maxRetries: 3, + initialDelayMs: 1_000, + maxDelayMs: 60_000, + backoffFactor: 2, + // No retryDelaysMs — uses the exponential formula + }, + }); + + await service.deliverEvent(makeInput({ webhookId: webhook.id })); + + // Delay for attempt 1: initialDelayMs * factor^0 = 1000 * 1 = 1000 + // Delay for attempt 2: initialDelayMs * factor^1 = 1000 * 2 = 2000 + // Delay for attempt 3: initialDelayMs * factor^2 = 1000 * 4 = 4000 + expect(delays).toEqual([1_000, 2_000, 4_000]); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + // ── Test Webhook Simulation (Issue #727) ───────────────────────────────────── + + it('testWebhook sends a simulated delivery and returns a delivery record', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.created' as any], + secretKey: 'secret', + }); + + const result = await service.testWebhook(webhook.id, 'subscription.created' as any); + + expect(result.delivery.status).toBe('delivered'); + expect(result.delivery.webhookId).toBe(webhook.id); + expect(fetchImpl).toHaveBeenCalledTimes(1); + // The delivery should carry the HMAC signature header + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record)['X-SubTrackr-Signature']).toBeDefined(); + }); + + it('testWebhook bypasses idempotency — repeated calls all fire', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.created' as any], + secretKey: 'secret', + }); + + // Fire the same event type three times — each gets a unique test payload id + await service.testWebhook(webhook.id, 'subscription.created' as any); + await service.testWebhook(webhook.id, 'subscription.created' as any); + await service.testWebhook(webhook.id, 'subscription.created' as any); + + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it('testWebhook accepts a custom payload override', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch }); + + const webhook = service.registerWebhook({ + merchantId: 'merchant_1', + url: 'https://example.com/webhook', + events: ['subscription.created' as any], + secretKey: 'secret', + }); + + const custom = { merchantId: 'override_merchant' }; + const result = await service.testWebhook(webhook.id, 'subscription.created' as any, custom); + + expect(result.delivery.status).toBe('delivered'); + expect(result.delivery.payload.merchantId).toBe('override_merchant'); + }); }); diff --git a/backend/services/notification/interfaces.ts b/backend/services/notification/interfaces.ts index ba249ffa..3cbb7f7b 100644 --- a/backend/services/notification/interfaces.ts +++ b/backend/services/notification/interfaces.ts @@ -34,6 +34,7 @@ export interface IWebhookDeliveryService { deleteWebhook(id: string): void; pauseWebhook(id: string): WebhookConfig; resumeWebhook(id: string): WebhookConfig; + rotateSecret(id: string, newSecret: string, overlapMs?: number): WebhookConfig; listWebhooks(merchantId: string): WebhookConfig[]; getWebhook(id: string): WebhookConfig | undefined; getWebhookDeliveries(webhookId: string, limit: number): WebhookDelivery[]; @@ -42,6 +43,11 @@ export interface IWebhookDeliveryService { checkWebhookHealth(id: string): Promise; deliverEvent(input: WebhookEventInput): Promise; retryWebhookDelivery(deliveryId: string): Promise; + listDeadLetters(webhookId?: string): WebhookDelivery[]; + replayDeadLetter(deliveryId: string): Promise; + cleanupDeadLetters(maxAgeMs?: number): number; + cleanupExpiredIdempotencyKeys(windowMs?: number): number; + testWebhook(webhookId: string, eventType?: string, customPayload?: Record): Promise; } export interface IWebsocketService { diff --git a/backend/services/notification/webhook.ts b/backend/services/notification/webhook.ts index f75b2370..ff74df8f 100644 --- a/backend/services/notification/webhook.ts +++ b/backend/services/notification/webhook.ts @@ -11,6 +11,8 @@ import type { WebhookRetryPolicy, WebhookSecret, } from '../../../src/types/webhook'; +import { eventCatalog } from '../webhook/eventCatalog'; +import { eventSchemaValidator } from '../webhook/eventSchemaValidator'; export type { WebhookEventInput } from '../../../src/types/webhook'; @@ -104,10 +106,23 @@ export const buildWebhookPayload = (input: WebhookEventInput): WebhookEventPaylo }; }; +/** + * Determines whether a given event type should be delivered to this webhook. + * + * Subscription patterns are matched with wildcard support: + * - `"*"` — matches every event type + * - `"subscription.*"` — matches all events whose type starts with `subscription.` + * - `"payment.failed"` — exact match only + * + * Paused webhooks always return false regardless of pattern. + */ export const isWebhookEventAllowed = ( webhook: Pick, eventType: WebhookEventType -): boolean => !webhook.isPaused && webhook.events.includes(eventType); +): boolean => { + if (webhook.isPaused) return false; + return webhook.events.some((pattern) => eventCatalog.matchesWildcard(pattern, eventType)); +}; /** Returns true if `signature` matches any signing secret currently valid for `webhook`. */ export const verifyWebhookSignatureAny = ( @@ -478,6 +493,77 @@ export class WebhookDeliveryService { return result; } + /** + * Sends a simulated test webhook delivery to the webhook's configured URL. + * + * Generates a realistic sample payload from the event catalog schema, signs it + * with the webhook's current secret, and dispatches it — bypassing idempotency + * deduplication and rate limiting so developers can rapidly invoke tests from + * the developer portal or CLI without interference. + * + * @param webhookId - The webhook to test. + * @param eventType - Which event type to simulate. Falls back to the first subscribed + * event if omitted. + * @param customPayload - Optional payload fields to merge over the generated example. + * @returns The delivery record for the simulated attempt. + */ + async testWebhook( + webhookId: string, + eventType?: WebhookEventType, + customPayload?: Record + ): Promise { + const webhook = this.webhooks.get(webhookId); + if (!webhook) throw new Error(`Webhook ${webhookId} not found`); + + // Resolve the event type to test — prefer the caller's choice, otherwise use the + // first concrete (non-wildcard) subscribed event, and finally fall back to the + // first entry in the catalog so the test always has a valid schema to work with. + const resolvedEventType: WebhookEventType = + eventType ?? + (webhook.events.find((e) => !e.includes('*')) as WebhookEventType | undefined) ?? + (eventCatalog.getActiveEvents()[0]?.type as WebhookEventType); + + const schemaExample = eventSchemaValidator.generateExample(resolvedEventType) ?? {}; + const testId = createId('tevt'); + const testPayload: WebhookEventPayload = { + id: testId, + webhookId, + eventType: resolvedEventType, + occurredAt: now(), + merchantId: webhook.merchantId, + subscription: schemaExample['subscription'] as WebhookEventPayload['subscription'], + plan: schemaExample['plan'] as WebhookEventPayload['plan'], + payloadVersion: 1, + ...(customPayload as Partial), + }; + + const signature = signWebhookPayload(testPayload, webhook.secretKey); + const idempotencyKey = `test:${testId}`; + + const delivery: WebhookDelivery = { + id: createId('tdel'), + webhookId, + eventId: testPayload.id, + eventType: resolvedEventType, + url: webhook.url, + payload: testPayload, + status: 'pending', + attempts: 0, + maxAttempts: webhook.retryPolicy.maxRetries, + createdAt: now(), + updatedAt: now(), + signature, + idempotencyKey, + }; + + this.deliveries.set(delivery.id, delivery); + // sendWithRetry handles the HTTP dispatch; test deliveries use the full + // retry machinery so portal users see realistic latency + status codes. + const result = await this.sendWithRetry(webhook, delivery); + this.deliveries.set(delivery.id, result.delivery); + return result; + } + async retryWebhookDelivery(deliveryId: string): Promise { const existing = this.deliveries.get(deliveryId); if (!existing) throw new Error(`Delivery ${deliveryId} not found`); diff --git a/backend/services/notification/webhookManagementApi.ts b/backend/services/notification/webhookManagementApi.ts index 54a8893c..9551d097 100644 --- a/backend/services/notification/webhookManagementApi.ts +++ b/backend/services/notification/webhookManagementApi.ts @@ -12,7 +12,7 @@ import { webhookDeliveryService, RegisterWebhookInput, } from './webhook'; -import type { WebhookConfig, WebhookDelivery, WebhookEventInput } from '../../../src/types/webhook'; +import type { WebhookConfig, WebhookDelivery, WebhookEventInput, WebhookEventType } from '../../../src/types/webhook'; export interface ApiResponse { success: boolean; @@ -136,9 +136,31 @@ export class WebhookManagementApi { } } - getAnalytics(webhookId: string) { + getAnalytics(webhookId: string): ApiResponse> { return ok(this.service.getAnalytics(webhookId)); } + + /** + * Sends a test webhook delivery to the endpoint, generating a realistic sample + * payload from the event catalog. Idempotency and rate limiting are bypassed so + * developers can rapidly fire test events from the developer portal or CLI. + * + * @param webhookId - Target webhook. + * @param eventType - Event type to simulate (defaults to first subscribed event). + * @param customPayload - Optional fields to merge over the generated example. + */ + async testWebhook( + webhookId: string, + eventType?: WebhookEventType, + customPayload?: Record + ): Promise> { + try { + const result = await this.service.testWebhook(webhookId, eventType, customPayload); + return ok(result.delivery, `Test delivery ${result.delivery.status}`); + } catch (error) { + return fail(error, 'Failed to send test webhook'); + } + } } export const webhookManagementApi = new WebhookManagementApi(); diff --git a/backend/services/notification/webhooks.ts b/backend/services/notification/webhooks.ts new file mode 100644 index 00000000..24c0701e --- /dev/null +++ b/backend/services/notification/webhooks.ts @@ -0,0 +1,55 @@ +/** + * Unified Webhook System — Issue #727 + * + * This barrel file is the single entry point for the SubTrackr subscription + * webhook system as specified in the Technical Scope of issue #727. + * + * It re-exports: + * - WebhookDeliveryService — core delivery, retry, rate limiting, DLQ + * - WebhookManagementApi — REST-style handler wrappers + * - Helper utilities — sign, verify, build payload + * - EventCatalogRegistry — event type catalog with wildcard matching + * - EventSchemaValidator — payload validation + example generation + */ + +// ── Core Delivery Service ───────────────────────────────────────────────────── +export { + WebhookDeliveryService, + webhookDeliveryService, + buildWebhookPayload, + signWebhookPayload, + verifyWebhookSignature, + verifyWebhookSignatureAny, + isWebhookEventAllowed, + WEBHOOK_IDEMPOTENCY_HEADER, +} from './webhook'; + +export type { + RegisterWebhookInput, + WebhookDeliveryResult, +} from './webhook'; + +// Re-export shared webhook types used by both layers +export type { + WebhookEventInput, +} from './webhook'; + +// ── Management API ──────────────────────────────────────────────────────────── +export { WebhookManagementApi, webhookManagementApi } from './webhookManagementApi'; +export type { ApiResponse as WebhookApiResponse } from './webhookManagementApi'; + +// ── Event Catalog ───────────────────────────────────────────────────────────── +export { + EventCatalogRegistry, + eventCatalog, + EVENT_CATALOG, +} from '../webhook/eventCatalog'; +export type { + EventDefinition, + EventCategory, + SchemaField, +} from '../webhook/eventCatalog'; + +// ── Event Schema Validator ──────────────────────────────────────────────────── +export { EventSchemaValidator, eventSchemaValidator } from '../webhook/eventSchemaValidator'; +export type { ValidationResult } from '../webhook/eventSchemaValidator'; diff --git a/developer-portal/docs/webhook-guide.md b/developer-portal/docs/webhook-guide.md index 25a719a6..da50d85c 100644 --- a/developer-portal/docs/webhook-guide.md +++ b/developer-portal/docs/webhook-guide.md @@ -2,9 +2,11 @@ ## Overview -Webhooks allow your application to receive real-time notifications when events occur in SubTrackr. This guide covers how to set up, verify, and handle webhooks. +Webhooks allow your application to receive real-time HTTP notifications when events occur in SubTrackr. Subscribe to the lifecycle events that matter to you — SubTrackr will deliver signed POST requests to your endpoint with a structured JSON payload. -## Setting Up Webhooks +--- + +## Quick Start ### 1. Register a Webhook Endpoint @@ -14,100 +16,218 @@ curl -X POST https://api.subtrackr.io/v1/webhooks \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/subtrackr", - "events": [ - "subscription.created", - "subscription.updated", - "subscription.cancelled", - "payment.completed", - "payment.failed" - ], - "secret": "whsec_your_webhook_secret" + "events": ["subscription.created", "payment.failed"], + "secretKey": "whsec_your_webhook_secret" }' ``` -### 2. Available Events +**Response:** + +```json +{ + "id": "whk_abc123", + "merchantId": "merchant_1", + "url": "https://your-app.com/webhooks/subtrackr", + "events": ["subscription.created", "payment.failed"], + "isPaused": false, + "createdAt": 1719100000000 +} +``` + +--- + +## Event Type Filtering + +### Wildcard Subscriptions + +SubTrackr supports wildcard patterns so you can subscribe to broad categories without listing every individual event type. + +| Pattern | Matches | +|---------|---------| +| `*` | **All** event types across all categories | +| `subscription.*` | All subscription lifecycle events | +| `payment.*` | All payment events | +| `invoice.*` | All invoice events | +| `trial.*` | All trial period events | +| `usage.*` | All usage metering events | +| `plan.*` | All plan change events | +| `subscription.created` | Exact match only | + +**Example — Subscribe to all subscription events:** + +```json +{ + "events": ["subscription.*", "payment.failed"] +} +``` + +--- + +## Full Event Catalog + +### Subscription Events + +| Event | Description | +|-------|-------------| +| `subscription.created` | New subscription created | +| `subscription.updated` | Subscription details updated | +| `subscription.cancelled` | Subscription cancelled | +| `subscription.paused` | Subscription paused | +| `subscription.resumed` | Subscription resumed from pause | +| `subscription.expired` | Subscription reached its end date | +| `subscription.renewed` | Subscription auto-renewed | +| `subscription.upgraded` | Plan upgrade completed | +| `subscription.downgraded` | Plan downgrade completed | +| `subscription.transfer_requested` | Ownership transfer requested | +| `subscription.transfer_completed` | Ownership transfer completed | +| `subscription.grace_period_started` | Grace period started after failed payment | +| `subscription.grace_period_ended` | Grace period expired | + +### Payment Events + +| Event | Description | +|-------|-------------| +| `payment.succeeded` | Payment processed successfully | +| `payment.failed` | Payment attempt failed | +| `payment.refunded` | Payment refunded | +| `payment.disputed` | Payment disputed by subscriber | +| `payment.chargeback` | Chargeback initiated | +| `payment.method_updated` | Payment method changed | +| `payment.retry_scheduled` | Failed payment retry scheduled | + +### Invoice Events + +| Event | Description | +|-------|-------------| +| `invoice.created` | Invoice generated | +| `invoice.finalized` | Invoice finalized and ready for payment | +| `invoice.paid` | Invoice paid | +| `invoice.voided` | Invoice voided | +| `invoice.overdue` | Invoice past due | + +### Trial Events | Event | Description | |-------|-------------| -| `subscription.created` | A new subscription was created | -| `subscription.updated` | A subscription was updated | -| `subscription.cancelled` | A subscription was cancelled | -| `subscription.paused` | A subscription was paused | -| `subscription.resumed` | A subscription was resumed | -| `payment.completed` | A payment was successfully processed | -| `payment.failed` | A payment processing failed | -| `payment.refunded` | A payment was refunded | -| `invoice.generated` | A new invoice was generated | -| `invoice.paid` | An invoice was paid | -| `invoice.overdue` | An invoice is past due | - -### 3. Webhook Payload Structure +| `trial.started` | Trial period started | +| `trial.ending_soon` | Trial ending within 3 days | +| `trial.ended` | Trial period ended | +| `trial.converted` | Trial converted to paid subscription | + +### Usage Events + +| Event | Description | +|-------|-------------| +| `usage.threshold_reached` | Usage threshold reached | +| `usage.limit_exceeded` | Usage limit exceeded | +| `usage.recorded` | Usage data point recorded | + +### Plan Events + +| Event | Description | +|-------|-------------| +| `plan.created` | New plan created | +| `plan.updated` | Plan details updated | +| `plan.archived` | Plan archived (no new subscriptions) | +| `plan.price_changed` | Plan price changed | + +--- + +## Webhook Payload Structure + +Every webhook delivery sends a JSON POST body with this shape: ```json { - "id": "evt_123456789", - "type": "subscription.created", - "apiVersion": "2024-01-01", - "created": 1704067200, - "data": { - "object": { - "id": "sub_123", - "name": "Netflix", - "status": "active", - "price": 15.99, - "currency": "USD", - "billingCycle": "monthly", - "createdAt": "2024-01-01T00:00:00Z" - } + "id": "tevt_abc123", + "webhookId": "whk_abc123", + "eventType": "subscription.created", + "occurredAt": 1719100000000, + "merchantId": "merchant_1", + "payloadVersion": 1, + "subscription": { + "id": "sub_1", + "planId": "plan_1", + "subscriberId": "user_1", + "status": "active", + "startedAt": 1719000000000 }, - "livemode": false, - "pendingWebhooks": 1, - "request": { - "id": "req_123", - "idempotencyKey": "idem_123" + "plan": { + "id": "plan_1", + "merchantId": "merchant_1", + "name": "Pro", + "price": 500, + "token": "USDC" } } ``` -## Verifying Webhooks +### HTTP Headers Sent with Every Delivery -Always verify webhook signatures to ensure they're from SubTrackr. +| Header | Description | +|--------|-------------| +| `Content-Type` | `application/json` | +| `X-SubTrackr-Signature` | HMAC-SHA256 hex digest — see Verification | +| `X-SubTrackr-Event-Type` | The event type string (e.g. `subscription.created`) | +| `X-SubTrackr-Event-Id` | Unique event identifier | +| `Idempotency-Key` | Stable key for idempotent processing | +| `X-SubTrackr-Payload-Truncated` | `"true"` only when payload exceeds 1 MB | +| `X-SubTrackr-Payload-Hash` | SHA-256 of the full payload (truncated deliveries only) | -### Node.js +--- -```javascript -const crypto = require('crypto'); +## Verifying Webhook Signatures -function verifyWebhookSignature(payload, signature, secret) { - const timestamp = signature.split(',')[0].split('=')[1]; - const signatures = signature.split(',')[1].split('=')[1]; - - const signedPayload = `${timestamp}.${payload}`; - const expectedSignature = crypto +SubTrackr signs every delivery using **HMAC-SHA256** over the JSON body using your webhook secret. Always verify the signature before processing the event. + +The `X-SubTrackr-Signature` header contains the raw hex digest of: + +``` +HMAC-SHA256(key=secretKey, data=JSON.stringify(payload)) +``` + +### Node.js / TypeScript + +```typescript +import crypto from 'crypto'; + +function verifyWebhookSignature( + rawBody: Buffer | string, + signature: string, + secret: string +): boolean { + const expected = crypto .createHmac('sha256', secret) - .update(signedPayload) + .update(rawBody) .digest('hex'); - - return crypto.timingSafeEqual( - Buffer.from(signatures, 'hex'), - Buffer.from(expectedSignature, 'hex') - ); + + const actualBytes = Buffer.from(signature); + const expectedBytes = Buffer.from(expected); + + if (actualBytes.length !== expectedBytes.length) return false; + return crypto.timingSafeEqual(actualBytes, expectedBytes); } -// Express middleware -app.post('/webhooks/subtrackr', express.raw({ type: 'application/json' }), (req, res) => { - const signature = req.headers['x-subtrackr-signature']; - const payload = req.body; - - if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) { - return res.status(401).send('Invalid signature'); +// Express handler +import express from 'express'; +const app = express(); + +app.post( + '/webhooks/subtrackr', + express.raw({ type: 'application/json' }), + (req, res) => { + const signature = req.headers['x-subtrackr-signature'] as string; + + if (!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET!)) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + const event = JSON.parse(req.body.toString()); + handleWebhookEvent(event); // see "Handling Events" below + + res.status(200).json({ received: true }); } - - const event = JSON.parse(payload); - handleWebhookEvent(event); - - res.status(200).json({ received: true }); -}); +); ``` ### Python @@ -115,34 +235,29 @@ app.post('/webhooks/subtrackr', express.raw({ type: 'application/json' }), (req, ```python import hmac import hashlib +import os from flask import Flask, request, jsonify app = Flask(__name__) -def verify_webhook_signature(payload, signature, secret): - timestamp = signature.split(',')[0].split('=')[1] - signatures = signature.split(',')[1].split('=')[1] - - signed_payload = f"{timestamp}.{payload}" - expected_signature = hmac.new( +def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool: + expected = hmac.new( secret.encode(), - signed_payload.encode(), + raw_body, hashlib.sha256 ).hexdigest() - - return hmac.compare_digest(signatures, expected_signature) + return hmac.compare_digest(signature, expected) @app.route('/webhooks/subtrackr', methods=['POST']) def handle_webhook(): - signature = request.headers.get('X-Subtrackr-Signature') - payload = request.data.decode('utf-8') - - if not verify_webhook_signature(payload, signature, os.environ['WEBHOOK_SECRET']): + signature = request.headers.get('X-SubTrackr-Signature', '') + raw_body = request.data + + if not verify_webhook_signature(raw_body, signature, os.environ['WEBHOOK_SECRET']): return jsonify({'error': 'Invalid signature'}), 401 - + event = request.json handle_webhook_event(event) - return jsonify({'received': True}), 200 ``` @@ -155,258 +270,283 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" - "fmt" "io" "net/http" - "strings" + "os" ) -func verifyWebhookSignature(payload, signature, secret string) bool { - parts := strings.Split(signature, ",") - if len(parts) != 2 { - return false - } - - timestamp := strings.Split(parts[0], "=")[1] - sig := strings.Split(parts[1], "=")[1] - - signedPayload := fmt.Sprintf("%s.%s", timestamp, payload) - +func verifyWebhookSignature(rawBody []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(signedPayload)) - expectedSignature := hex.EncodeToString(mac.Sum(nil)) - - return hmac.Equal([]byte(sig), []byte(expectedSignature)) + mac.Write(rawBody) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(signature), []byte(expected)) } func handleWebhook(w http.ResponseWriter, r *http.Request) { - signature := r.Header.Get("X-Subtrackr-Signature") - payload, _ := io.ReadAll(r.Body) - - if !verifyWebhookSignature(string(payload), signature, os.Getenv("WEBHOOK_SECRET")) { + signature := r.Header.Get("X-SubTrackr-Signature") + rawBody, _ := io.ReadAll(r.Body) + + if !verifyWebhookSignature(rawBody, signature, os.Getenv("WEBHOOK_SECRET")) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } - - var event map[string]interface{} - json.Unmarshal(payload, &event) - - handleWebhookEvent(event) - + + // ... process event w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]bool{"received": true}) } ``` -## Handling Webhook Events - -### Event Handler Pattern - -```javascript -async function handleWebhookEvent(event) { - switch (event.type) { - case 'subscription.created': - await handleSubscriptionCreated(event.data.object); - break; - - case 'subscription.updated': - await handleSubscriptionUpdated(event.data.object); - break; - - case 'subscription.cancelled': - await handleSubscriptionCancelled(event.data.object); - break; - - case 'payment.completed': - await handlePaymentCompleted(event.data.object); - break; - - case 'payment.failed': - await handlePaymentFailed(event.data.object); - break; - - default: - console.log(`Unhandled event type: ${event.type}`); - } -} +--- -async function handleSubscriptionCreated(subscription) { - // Update your database - await db.subscriptions.create({ - id: subscription.id, - name: subscription.name, - status: subscription.status, - price: subscription.price, - }); - - // Send welcome email - await emailService.send({ - to: subscription.customerEmail, - template: 'subscription-welcome', - data: { subscription }, - }); -} +## Retry Policy & Exponential Backoff -async function handlePaymentCompleted(payment) { - // Update payment status - await db.payments.update(payment.id, { - status: 'completed', - paidAt: new Date(), - }); - - // Send receipt - await emailService.send({ - to: payment.customerEmail, - template: 'payment-receipt', - data: { payment }, - }); -} -``` +SubTrackr retries failed deliveries (any non-2xx response, or network timeout) using the following fixed schedule: -## Idempotency +| Attempt | Delay after failure | +|---------|---------------------| +| 1st delivery | Immediate | +| Retry 1 | 1 minute | +| Retry 2 | 5 minutes | +| Retry 3 | 15 minutes | +| Retry 4 | 1 hour | +| Retry 5 | 6 hours | -Webhooks may be delivered multiple times. Implement idempotency to avoid duplicate processing: +After 5 failed retries the delivery is moved to the **Dead-Letter Queue (DLQ)** and can be manually replayed from the developer portal or via API. -```javascript -const processedEvents = new Set(); +### Custom Retry Policy -async function handleWebhookEvent(event) { - // Check if already processed - if (processedEvents.has(event.id)) { - console.log(`Event ${event.id} already processed, skipping`); - return; - } - - try { - // Process event - await processEvent(event); - - // Mark as processed - processedEvents.add(event.id); - } catch (error) { - console.error(`Error processing event ${event.id}:`, error); - throw error; +You can override the default schedule when registering a webhook: + +```json +{ + "retryPolicy": { + "maxRetries": 3, + "initialDelayMs": 5000, + "maxDelayMs": 300000, + "backoffFactor": 2 } } ``` -## Retry Policy +### Automatic Webhook Disablement -SubTrackr retries failed webhook deliveries with exponential backoff: +If your endpoint returns `410 Gone`, SubTrackr immediately stops all retries and marks the webhook as paused. Resume it manually once the endpoint is reinstated. -| Attempt | Delay | -|---------|-------| -| 1 | Immediate | -| 2 | 1 minute | -| 3 | 5 minutes | -| 4 | 30 minutes | -| 5 | 2 hours | -| 6 | 8 hours | -| 7 | 24 hours | +--- -### Responding to Webhooks +## Secret Rotation -Return a `200` status code to acknowledge receipt: +Rotate your signing secret without dropped deliveries using the overlapping-key mechanism. The old secret remains valid during the transition window. -```javascript -res.status(200).json({ received: true }); +```bash +curl -X POST https://api.subtrackr.io/v1/webhooks/{webhookId}/rotate-secret \ + -H "Authorization: Bearer sk_live_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{ + "newSecret": "whsec_new_secret_here", + "overlapMs": 86400000 + }' ``` -Any non-2xx response will trigger a retry. +During the overlap window, SubTrackr accepts signatures from **both** the old and new secret. Update your service to use the new secret before the window expires. -## Testing Webhooks +--- -### Using the Sandbox +## Webhook Testing Tools -Test webhooks in the sandbox environment: +### Test via API (`/v1/webhooks/:id/test`) + +Send a simulated test delivery using realistic example payloads from the event catalog. Idempotency and rate limiting are bypassed so you can fire the endpoint repeatedly during development. ```bash -# Trigger a test webhook -curl -X POST https://sandbox.subtrackr.io/v1/webhooks/test \ +# Test with auto-selected event type (first subscribed event) +curl -X POST https://api.subtrackr.io/v1/webhooks/whk_abc123/test \ + -H "Authorization: Bearer sk_test_your_api_key" + +# Test a specific event type +curl -X POST https://api.subtrackr.io/v1/webhooks/whk_abc123/test \ -H "Authorization: Bearer sk_test_your_api_key" \ -H "Content-Type: application/json" \ -d '{ - "webhookId": "wh_123", - "event": "subscription.created" + "eventType": "payment.failed", + "customPayload": { + "subscription": { "id": "sub_test_1", "status": "past_due" } + } }' ``` +**Response:** + +```json +{ + "success": true, + "data": { + "id": "tdel_abc123", + "status": "delivered", + "attempts": 1, + "responseCode": 200, + "latencyMs": 145 + }, + "message": "Test delivery delivered" +} +``` + +### Test via Developer Portal + +1. Navigate to **Developer Portal → Webhooks → Webhook Tester** +2. Enter your webhook URL or select a registered endpoint +3. Select the event type to simulate +4. Optionally customise the payload +5. Click **Send Test Webhook** +6. Review delivery status, HTTP response code, and latency + ### Local Testing with ngrok -1. Start your local server: ```bash +# 1. Start your local server node server.js -``` -2. Start ngrok: -```bash +# 2. Expose it with ngrok ngrok http 3000 -``` -3. Register webhook with ngrok URL: -```bash -curl -X POST https://sandbox.subtrackr.io/v1/webhooks \ +# 3. Register a webhook with the ngrok URL +curl -X POST https://api.subtrackr.io/v1/webhooks \ -H "Authorization: Bearer sk_test_your_api_key" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://your-ngrok-url.ngrok.io/webhooks/subtrackr", - "events": ["subscription.created"] + "url": "https://your-ngrok-id.ngrok.io/webhooks/subtrackr", + "events": ["subscription.*"] }' ``` -### Using SubTrackr CLI +--- + +## Webhook Analytics + +Query delivery analytics for any registered webhook: ```bash -npm install -g @subtrackr/cli +curl https://api.subtrackr.io/v1/webhooks/{webhookId}/analytics \ + -H "Authorization: Bearer sk_live_your_api_key" +``` -# Listen for webhooks and forward to local server -subtrackr listen --forward-to localhost:3000/webhooks/subtrackr +**Response:** -# Trigger a test event -subtrackr trigger subscription.created +```json +{ + "webhookId": "whk_abc123", + "totalDeliveries": 1842, + "successfulDeliveries": 1801, + "failedDeliveries": 41, + "retryCount": 57, + "pendingDeliveries": 0, + "successRate": 0.977, + "avgAttempts": 1.03, + "avgLatencyMs": 187, + "lastSuccessAt": 1719188400000, + "lastFailureAt": 1719100800000 +} ``` -## Security Best Practices +--- -1. **Always Verify Signatures**: Never process unverified webhooks -2. **Use HTTPS**: Webhook URLs must use HTTPS -3. **Store Secrets Securely**: Never expose webhook secrets -4. **Implement Idempotency**: Handle duplicate deliveries -5. **Respond Quickly**: Process webhooks asynchronously if needed -6. **Log All Events**: Keep an audit trail of webhook deliveries -7. **Monitor Failures**: Set up alerts for webhook delivery failures +## Dead-Letter Queue -## Troubleshooting +Deliveries that exhaust all retries land in the Dead-Letter Queue (DLQ). From the developer portal or API you can replay them individually. -### Common Issues +```bash +# List dead-lettered deliveries for a webhook +curl https://api.subtrackr.io/v1/webhooks/{webhookId}/dead-letters \ + -H "Authorization: Bearer sk_live_your_api_key" -**Webhooks not being received** -- Check webhook URL is correct and accessible -- Verify webhook is active (`status: "active"`) -- Check server logs for errors +# Replay a dead-lettered delivery +curl -X POST https://api.subtrackr.io/v1/deliveries/{deliveryId}/replay \ + -H "Authorization: Bearer sk_live_your_api_key" +``` -**Signature verification failing** -- Ensure you're using the correct secret -- Check that you're verifying the raw payload -- Verify the timestamp is within tolerance +--- -**Duplicate events** -- Implement idempotency using event IDs -- Check if your server is responding with 200 +## Idempotency -### Debug Mode +SubTrackr uses a 24-hour idempotency window. If the same event is delivered twice within 24 hours using the same `Idempotency-Key`, the second delivery is marked `skipped` and **not** sent to your endpoint. -Enable debug logging: +Implement idempotency in your handler using the event `id` field: -```javascript -const client = new SubTrackr({ - apiKey: 'sk_test_your_api_key', - debug: true, -}); +```typescript +async function handleWebhookEvent(event: WebhookPayload) { + const alreadyProcessed = await db.processedEvents.find(event.id); + if (alreadyProcessed) return; // safe to skip + + await db.transaction(async (tx) => { + await processEvent(tx, event); + await tx.processedEvents.create({ id: event.id, processedAt: new Date() }); + }); +} ``` +--- + +## CRUD Reference + +| Operation | Method | Path | +|-----------|--------|------| +| Register webhook | `POST` | `/v1/webhooks` | +| List webhooks | `GET` | `/v1/webhooks?merchantId=...` | +| Get webhook | `GET` | `/v1/webhooks/:id` | +| Update webhook | `PATCH` | `/v1/webhooks/:id` | +| Delete webhook | `DELETE` | `/v1/webhooks/:id` | +| Pause webhook | `POST` | `/v1/webhooks/:id/pause` | +| Resume webhook | `POST` | `/v1/webhooks/:id/resume` | +| Rotate secret | `POST` | `/v1/webhooks/:id/rotate-secret` | +| Send test delivery | `POST` | `/v1/webhooks/:id/test` | +| Get analytics | `GET` | `/v1/webhooks/:id/analytics` | +| List deliveries | `GET` | `/v1/webhooks/:id/deliveries` | +| Get delivery | `GET` | `/v1/deliveries/:id` | +| Retry delivery | `POST` | `/v1/deliveries/:id/retry` | +| List DLQ | `GET` | `/v1/webhooks/:id/dead-letters` | +| Replay DLQ entry | `POST` | `/v1/deliveries/:id/replay` | + +--- + +## Security Best Practices + +1. **Always Verify Signatures** — never process unverified requests +2. **Use HTTPS Only** — reject any endpoint using plain HTTP +3. **Store Secrets Securely** — use environment variables or a secrets manager +4. **Implement Idempotency** — use event IDs to guard against duplicate processing +5. **Respond within 5 seconds** — process asynchronously if your handler is slow +6. **Audit All Events** — log the full payload for debugging and compliance +7. **Monitor Delivery Rate** — set up alerts when `successRate` drops below your SLA +8. **Rotate Secrets Regularly** — use the overlapping-rotation mechanism for zero-downtime rotations + +--- + +## Troubleshooting + +### Webhooks not received +- Confirm your endpoint is publicly reachable (use ngrok locally) +- Check that the webhook is not paused (`isPaused: false`) +- Review delivery logs in the developer portal + +### Signature verification failing +- Ensure you verify the **raw bytes** of the request body before JSON parsing +- Confirm you are using the correct webhook secret (not the API key) +- Check that your HMAC implementation does not wrap a timestamp around the body + +### Duplicate events +- Implement idempotency using the `id` field from the event payload +- Ensure your endpoint returns `200` on the first delivery to prevent retries + +### 410 Gone — webhook auto-disabled +- SubTrackr stops all retries when your endpoint returns `410` +- Fix the endpoint, then call `/v1/webhooks/:id/resume` to re-enable + +--- + ## Support -- **Webhook Logs**: View delivery logs in the Developer Portal -- **Test Events**: Use the sandbox to test webhook delivery -- **Support**: webhooks@subtrackr.io +- **Delivery Logs**: Developer Portal → Webhooks → Delivery History +- **Test Tools**: Developer Portal → Webhooks → Webhook Tester +- **Analytics**: Developer Portal → Webhooks → Analytics +- **Email**: webhooks@subtrackr.io +- **Community**: [Discord](https://discord.gg/subtrackr)