From 1f3b28f382caff00946bec1f18e4ae3523f10d1f Mon Sep 17 00:00:00 2001 From: fortezzalaboratory Date: Sun, 18 Oct 2026 14:59:30 +0300 Subject: [PATCH 1/4] feat(webhooks): add event type filter and mount DLQ replay console Resolves #929 --- backend/src/index.ts | 2 ++ backend/src/routes/webhooks-dlq.ts | 3 ++- backend/src/services/webhooks/dead-letter-queue.ts | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 1f66b3a0..e5839666 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,6 +25,7 @@ import allowancesRouter from './routes/allowances.js'; import { formsRouter } from './routes/forms.js'; import { twoFactorAuthRouter } from './routes/2fa.js'; import { webhooksRouter } from './routes/webhooks.js'; +import webhooksDlqRouter from './routes/webhooks-dlq.js'; import { webhookHandlersRouter } from './routes/webhookHandlers.js'; import { webhookJsonParser } from './middleware/webhookVerification.js'; import { startJobs, getJobScheduler } from './jobs/index.js'; @@ -380,6 +381,7 @@ app.use('/api/v1/gdpr', gdprRouter); app.use('/api/v1/escrow', escrowRouter); app.use('/api/v1/multisig', multisigRouter); app.use('/api/v1/webhooks', webhooksRouter); +app.use('/api/v1/webhooks', webhooksDlqRouter); app.use('/api/v1/fraud-detection', fraudDetectionRouter); app.use('/api/v1/bridge', bridgeRouter); app.use('/api/v1/tokenization', tokenizationRouter); diff --git a/backend/src/routes/webhooks-dlq.ts b/backend/src/routes/webhooks-dlq.ts index f90a63ca..628c7b4b 100644 --- a/backend/src/routes/webhooks-dlq.ts +++ b/backend/src/routes/webhooks-dlq.ts @@ -75,10 +75,11 @@ router.get('/delivery-logs/export.csv', (req: Request, res: Response) => { router.get('/dlq', (req: Request, res: Response) => { syncDLQFromDeliveryLogs(); - const { merchantId, endpointId, failureCategory } = req.query as Record; + const { merchantId, endpointId, eventType, failureCategory } = req.query as Record; const entries = listDLQ({ merchantId, endpointId, + eventType, failureCategory: failureCategory as Parameters[0]['failureCategory'], }); res.json({ count: entries.length, entries }); diff --git a/backend/src/services/webhooks/dead-letter-queue.ts b/backend/src/services/webhooks/dead-letter-queue.ts index b120f78a..e47b8074 100644 --- a/backend/src/services/webhooks/dead-letter-queue.ts +++ b/backend/src/services/webhooks/dead-letter-queue.ts @@ -85,12 +85,14 @@ export function enqueueDeadLetter(log: DeliveryLog): DeadLetterEntry { export function listDLQ(filters: { merchantId?: string; endpointId?: string; + eventType?: string; failureCategory?: FailureCategory; } = {}): DeadLetterEntry[] { return Array.from(dlqEntries.values()).filter((e) => { if (e.purgedAt) return false; if (filters.merchantId && e.merchantId !== filters.merchantId) return false; if (filters.endpointId && e.endpointId !== filters.endpointId) return false; + if (filters.eventType && e.eventType !== filters.eventType) return false; if (filters.failureCategory && e.failureCategory !== filters.failureCategory) return false; return true; }); From 2774d3df528fe9908e2e6cf3600dd53436ddd0eb Mon Sep 17 00:00:00 2001 From: fortezzalaboratory Date: Sun, 18 Oct 2026 14:59:36 +0300 Subject: [PATCH 2/4] feat(sdk): add sandbox mock payment fixtures Resolves #930 --- packages/sdk/src/index.ts | 1 + packages/sdk/src/sandbox.ts | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b83a9769..68efd07b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -8,6 +8,7 @@ import { AgenticPayClientOptions } from './types.js'; export * from './types.js'; export * from './errors.js'; export * from './auth.js'; +export { SANDBOX_MOCK_PAYMENTS, createMockPaymentResult } from './sandbox.js'; export class AgenticPaySDK { readonly client: AgenticPayClient; diff --git a/packages/sdk/src/sandbox.ts b/packages/sdk/src/sandbox.ts index a4a5abe3..1615abc6 100644 --- a/packages/sdk/src/sandbox.ts +++ b/packages/sdk/src/sandbox.ts @@ -22,6 +22,31 @@ export type SandboxPaymentResult = { timestamp: string; }; +export const SANDBOX_MOCK_PAYMENTS: SandboxPaymentResult[] = [ + { + transactionHash: 'mock_tx_0001', + status: 'success', + ledger: 1_000_001, + timestamp: '2026-01-01T00:00:00.000Z', + }, + { + transactionHash: 'mock_tx_0002', + status: 'success', + ledger: 1_000_002, + timestamp: '2026-01-02T00:00:00.000Z', + }, +]; + +/** Build a deterministic mock payment result for offline sandbox tests. */ +export function createMockPaymentResult(input: SandboxPaymentInput, ledger = 1_000_000): SandboxPaymentResult { + return { + transactionHash: `mock_${input.from}_${input.to}_${input.amount}_${input.currency}`, + status: 'success', + ledger, + timestamp: new Date(0).toISOString(), + }; +} + export class SandboxApi { constructor(private readonly client: AgenticPayClient) {} From bb972a1fd20ce49a1f86b00a3719ea05763d225a Mon Sep 17 00:00:00 2001 From: fortezzalaboratory Date: Sun, 18 Oct 2026 14:59:45 +0300 Subject: [PATCH 3/4] feat(sdk): add playground with runnable API examples Resolves #931 --- docs/sdk/PLAYGROUND.md | 28 ++++++++++++++++++ packages/sdk/src/index.ts | 1 + packages/sdk/src/playground.ts | 52 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 docs/sdk/PLAYGROUND.md create mode 100644 packages/sdk/src/playground.ts diff --git a/docs/sdk/PLAYGROUND.md b/docs/sdk/PLAYGROUND.md new file mode 100644 index 00000000..f2fd2abc --- /dev/null +++ b/docs/sdk/PLAYGROUND.md @@ -0,0 +1,28 @@ +# SDK Playground + +The SDK playground is a set of small, runnable examples that exercise the +`@agenticpay/sdk` client against a running API (the sandbox is a good target). + +## Examples + +| id | Description | Args | +|---|---|---| +| `sandbox-status` | Check sandbox health | none | +| `refund-policy` | Fetch a merchant's refund policy | `` | +| `merchant-splits` | List a merchant's payment splits | `` | +| `verification` | Fetch a work verification | `` | + +## Usage + +```ts +import { createAgenticPaySDK, getPlaygroundExample, PLAYGROUND_EXAMPLES } from '@agenticpay/sdk'; + +const sdk = createAgenticPaySDK({ baseUrl: process.env.AGENTICPAY_BASE_URL ?? 'http://localhost:3000/api/v1', apiKey: process.env.AGENTICPAY_API_KEY }); + +const example = getPlaygroundExample('sandbox-status'); +console.log(await example.run(sdk, [])); + +console.log(PLAYGROUND_EXAMPLES.map((item) => item.id)); +``` + +Unknown example ids and missing arguments throw descriptive errors. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 68efd07b..18f2cd9f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -9,6 +9,7 @@ export * from './types.js'; export * from './errors.js'; export * from './auth.js'; export { SANDBOX_MOCK_PAYMENTS, createMockPaymentResult } from './sandbox.js'; +export * from './playground.js'; export class AgenticPaySDK { readonly client: AgenticPayClient; diff --git a/packages/sdk/src/playground.ts b/packages/sdk/src/playground.ts new file mode 100644 index 00000000..0c714e67 --- /dev/null +++ b/packages/sdk/src/playground.ts @@ -0,0 +1,52 @@ +import type { AgenticPaySDK } from './index.js'; +import { SandboxApi } from './sandbox.js'; + +export interface PlaygroundExample { + id: string; + title: string; + description: string; + run: (sdk: AgenticPaySDK, args: string[]) => Promise; +} + +export const PLAYGROUND_EXAMPLES: PlaygroundExample[] = [ + { + id: 'sandbox-status', + title: 'Sandbox status', + description: 'Check whether the sandbox environment is healthy.', + run: (sdk) => new SandboxApi(sdk.client).getStatus(), + }, + { + id: 'refund-policy', + title: 'Refund policy', + description: 'Fetch the refund policy for a merchant. Args: .', + run: (sdk, args) => sdk.refunds.getPolicy(requireArg(args, 0, 'merchantId')), + }, + { + id: 'merchant-splits', + title: 'Merchant splits', + description: 'List payment split configs for a merchant. Args: .', + run: (sdk, args) => sdk.payments.listMerchantSplits(requireArg(args, 0, 'merchantId')), + }, + { + id: 'verification', + title: 'Get verification', + description: 'Fetch a work verification by id. Args: .', + run: (sdk, args) => sdk.verification.getVerification(requireArg(args, 0, 'verificationId')), + }, +]; + +export function getPlaygroundExample(id: string): PlaygroundExample { + const example = PLAYGROUND_EXAMPLES.find((item) => item.id === id); + if (!example) { + throw new Error(`Unknown playground example '${id}'. Available: ${PLAYGROUND_EXAMPLES.map((item) => item.id).join(', ')}`); + } + return example; +} + +function requireArg(args: string[], index: number, name: string): string { + const value = args[index]; + if (!value) { + throw new Error(`Missing required argument '${name}'`); + } + return value; +} From 7412c875184db011e9ab4db861e3536c8348c4c7 Mon Sep 17 00:00:00 2001 From: fortezzalaboratory Date: Sun, 18 Oct 2026 15:00:03 +0300 Subject: [PATCH 4/4] feat(api): add API version registry with deprecation notices Resolves #932 --- backend/src/index.ts | 2 + backend/src/middleware/versioning.ts | 68 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/backend/src/index.ts b/backend/src/index.ts index e5839666..36757a75 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,7 @@ import { formsRouter } from './routes/forms.js'; import { twoFactorAuthRouter } from './routes/2fa.js'; import { webhooksRouter } from './routes/webhooks.js'; import webhooksDlqRouter from './routes/webhooks-dlq.js'; +import { apiVersionsRouter } from './routes/api-versions.js'; import { webhookHandlersRouter } from './routes/webhookHandlers.js'; import { webhookJsonParser } from './middleware/webhookVerification.js'; import { startJobs, getJobScheduler } from './jobs/index.js'; @@ -382,6 +383,7 @@ app.use('/api/v1/escrow', escrowRouter); app.use('/api/v1/multisig', multisigRouter); app.use('/api/v1/webhooks', webhooksRouter); app.use('/api/v1/webhooks', webhooksDlqRouter); +app.use('/api/v1/api-versions', apiVersionsRouter); app.use('/api/v1/fraud-detection', fraudDetectionRouter); app.use('/api/v1/bridge', bridgeRouter); app.use('/api/v1/tokenization', tokenizationRouter); diff --git a/backend/src/middleware/versioning.ts b/backend/src/middleware/versioning.ts index 0223d349..e2b1726e 100644 --- a/backend/src/middleware/versioning.ts +++ b/backend/src/middleware/versioning.ts @@ -57,5 +57,73 @@ export const versionMiddleware = (req: Request, res: Response, next: NextFunctio req.apiVersion = version; res.setHeader('X-API-Version', version); + const info = versionRegistry.get(version); + if (info?.deprecated) { + res.setHeader('Deprecation', 'true'); + if (info.sunsetDate) { + res.setHeader('Sunset', new Date(info.sunsetDate).toUTCString()); + } + if (info.alternativeVersion) { + res.setHeader('Link', `; rel="successor-version"`); + } + } + next(); }; + +export interface ApiVersion { + version: string; + description?: string; + changelogUrl?: string; + alternativeVersion?: string; + deprecated: boolean; + sunsetDate?: string; + registeredAt: string; +} + +export interface RegisterVersionInput { + version: string; + description?: string; + changelogUrl?: string; + alternativeVersion?: string; +} + +const versionRegistry = new Map(); + +function compareVersions(a: string, b: string): number { + return Number(a.replace(/^v/, '')) - Number(b.replace(/^v/, '')); +} + +export function registerVersion(input: RegisterVersionInput): ApiVersion { + const existing = versionRegistry.get(input.version); + const info: ApiVersion = { + ...existing, + ...input, + deprecated: existing?.deprecated ?? false, + sunsetDate: existing?.sunsetDate, + registeredAt: existing?.registeredAt ?? new Date().toISOString(), + }; + versionRegistry.set(info.version, info); + return info; +} + +export function getVersion(version: string): ApiVersion | undefined { + return versionRegistry.get(version); +} + +export function listVersions(): ApiVersion[] { + return Array.from(versionRegistry.values()).sort((a, b) => compareVersions(a.version, b.version)); +} + +export function deprecateVersion(version: string, sunsetDate: Date, alternativeVersion?: string): ApiVersion { + const info = versionRegistry.get(version); + if (!info) { + throw new Error(`Version '${version}' is not registered.`); + } + info.deprecated = true; + info.sunsetDate = sunsetDate.toISOString(); + if (alternativeVersion) { + info.alternativeVersion = alternativeVersion; + } + return info; +}