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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,9 @@ sequenceDiagram
| GitHub | Signature | `x-hub-signature-256` | HMAC-SHA256 with `sha256=` prefix | None | (raw/fallback) | Strong verifier support |
| Clerk | Signature | `svix-signature` | HMAC-SHA256 (base64 secret derivation) | `svix-timestamp` | `auth` | Svix-style payload format |
| Dodo Payments | Signature | `webhook-signature` | HMAC-SHA256 (svix-style/base64) | `webhook-timestamp` | (raw/fallback) | Verifier implemented |
| Shopify | Signature | `x-shopify-hmac-sha256` | HMAC-SHA256 | none/custom | (raw/fallback) | Platform config present |
| Shopify | Signature | `x-shopify-hmac-sha256` | HMAC-SHA256 (base64 signature) | none | (raw/fallback) | Platform config present |
| Vercel | Signature | `x-vercel-signature` | HMAC-SHA256 | `x-vercel-timestamp` | `infrastructure` | Typed normalization present |
| Polar | Signature | `x-polar-signature` | HMAC-SHA256 | `x-polar-timestamp` | `payment` | Typed normalization present |
| Polar | Signature | `webhook-signature` | HMAC-SHA256 (Standard Webhooks/base64) | `webhook-timestamp` | `payment` | Typed normalization present |
| Supabase | Token | `x-webhook-token` (+ `x-webhook-id`) | token compare (custom) | N/A | `auth` | Typed normalization present |
| GitLab | Token | `X-Gitlab-Token` | token compare (custom) | N/A | (raw/fallback) | Verifier implemented |
| Custom/Unknown | Configurable | user-defined | configurable | configurable | fallback | Extension path for new platforms |
Expand Down
4 changes: 2 additions & 2 deletions FRAMEWORK_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ const result = await WebhookVerificationService.verify(request, config);
- **Stripe**: `stripe-signature` with comma-separated format
- **Clerk**: `svix-signature` with base64 encoding
- **Dodo Payments**: `webhook-signature` with raw format
- **Shopify**: `x-shopify-hmac-sha256`
- **Shopify**: `x-shopify-hmac-sha256` (base64 signature)
- **Vercel**: `x-vercel-signature`
- **Polar**: `x-polar-signature`
- **Polar**: `webhook-signature` (Standard Webhooks)

### HMAC-SHA1 (Legacy)
- Legacy platforms that still use SHA1
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
- **WooCommerce**: HMAC-SHA256 (base64 signature)
- **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style)
- **fal.ai**: ED25519 (`x-fal-webhook-signature`)
- **Shopify**: HMAC-SHA256
- **Shopify**: HMAC-SHA256 (base64 signature)
- **Vercel**: HMAC-SHA256
- **Polar**: HMAC-SHA256
- **Supabase**: Token-based authentication
Expand Down
2 changes: 1 addition & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ if (result.isValid) {
- **Stripe**: `stripe-signature` header
- **Shopify**: `x-shopify-hmac-sha256` header
- **Vercel**: `x-vercel-signature` header
- **Polar**: `x-polar-signature` header
- **Polar**: `webhook-signature` header (Standard Webhooks)
- **Dodo Payments**: `webhook-signature` header
- **Paddle**: `paddle-signature` header
- **Razorpay**: `x-razorpay-signature` header
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function createWebhookHandler<TEnv = Record<string, unknown>, TResponse =
);

if (!result.isValid) {
return Response.json({ error: result.error, platform: result.platform }, { status: 400 });
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
}

const data = await options.handler(result.payload, env, result.metadata || {});
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function createWebhookMiddleware(options: ExpressWebhookMiddlewareOptions
);

if (!result.isValid) {
res.status(400).json({ error: result.error, platform: result.platform });
res.status(400).json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata });
return;
}

Expand Down
2 changes: 1 addition & 1 deletion src/adapters/nextjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function createWebhookHandler<TResponse = unknown>(
);

if (!result.isValid) {
return Response.json({ error: result.error, platform: result.platform }, { status: 400 });
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
}

const data = await options.handler(result.payload, result.metadata || {});
Expand Down
27 changes: 25 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SignatureConfig,
MultiPlatformSecrets,
NormalizeOptions,
WebhookErrorCode,
} from './types';
import { createAlgorithmVerifier } from './verifiers/algorithms';
import { createCustomVerifier } from './verifiers/custom-algorithms';
Expand Down Expand Up @@ -112,6 +113,12 @@ export class WebhookVerificationService {
);
}

const failedAttempts: Array<{
platform: WebhookPlatform;
error?: string;
errorCode?: WebhookErrorCode;
}> = [];

for (const [platform, secret] of Object.entries(secrets)) {
if (!secret) {
continue;
Expand All @@ -128,13 +135,28 @@ export class WebhookVerificationService {
if (result.isValid) {
return result;
}

failedAttempts.push({
platform: platform.toLowerCase() as WebhookPlatform,
error: result.error,
errorCode: result.errorCode,
});
}

const details = failedAttempts
.map((attempt) => `${attempt.platform}: ${attempt.error || 'verification failed'}`)
.join('; ');

return {
isValid: false,
error: 'Unable to verify webhook with provided platform secrets',
errorCode: 'VERIFICATION_ERROR',
error: details
? `Unable to verify webhook with provided platform secrets. Attempts -> ${details}`
: 'Unable to verify webhook with provided platform secrets',
errorCode: failedAttempts.find((attempt) => attempt.errorCode)?.errorCode || 'VERIFICATION_ERROR',
platform: detectedPlatform,
metadata: {
attempts: failedAttempts,
},
};
}

Expand All @@ -147,6 +169,7 @@ export class WebhookVerificationService {
if (headers.has('workos-signature')) return 'workos';
if (headers.has('webhook-signature')) {
const userAgent = headers.get('user-agent')?.toLowerCase() || '';
if (userAgent.includes('polar')) return 'polar';
if (userAgent.includes('replicate')) return 'replicateai';
return 'dodopayments';
}
Expand Down
22 changes: 16 additions & 6 deletions src/platforms/algorithms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,13 @@ export const platformAlgorithmConfigs: Record<
algorithm: 'hmac-sha256',
headerName: 'x-shopify-hmac-sha256',
headerFormat: 'raw',
timestampHeader: 'x-shopify-shop-domain',
payloadFormat: 'raw',
customConfig: {
encoding: 'base64',
secretEncoding: 'utf8',
},
},
description: 'Shopify webhooks use HMAC-SHA256',
description: 'Shopify webhooks use HMAC-SHA256 with base64 encoded signature',
},

vercel: {
Expand All @@ -106,13 +109,20 @@ export const platformAlgorithmConfigs: Record<
platform: 'polar',
signatureConfig: {
algorithm: 'hmac-sha256',
headerName: 'x-polar-signature',
headerName: 'webhook-signature',
headerFormat: 'raw',
timestampHeader: 'x-polar-timestamp',
timestampHeader: 'webhook-timestamp',
timestampFormat: 'unix',
payloadFormat: 'raw',
payloadFormat: 'custom',
customConfig: {
signatureFormat: 'v1={signature}',
payloadFormat: '{id}.{timestamp}.{body}',
encoding: 'base64',
secretEncoding: 'base64',
idHeader: 'webhook-id',
},
},
description: 'Polar webhooks use HMAC-SHA256',
description: 'Polar webhooks use HMAC-SHA256 with Standard Webhooks format',
},

supabase: {
Expand Down
72 changes: 72 additions & 0 deletions src/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ function createPaddleSignature(body: string, secret: string, timestamp: number):
return `ts=${timestamp};h1=${hmac.digest('hex')}`;
}


function createShopifySignature(body: string, secret: string): string {
const hmac = createHmac('sha256', secret);
hmac.update(body);
return hmac.digest('base64');
}

function createWooCommerceSignature(body: string, secret: string): string {
const hmac = createHmac('sha256', secret);
hmac.update(body);
Expand Down Expand Up @@ -372,6 +379,29 @@ try {
console.log(' ❌ verifyAny test failed:', error);
}

// Test 10.5: verifyAny error diagnostics
console.log('\n10.5. Testing verifyAny error diagnostics...');
try {
const unknownRequest = createMockRequest({
'content-type': 'application/json',
});

const invalidVerifyAny = await WebhookVerificationService.verifyAny(unknownRequest, {
stripe: testSecret,
shopify: testSecret,
});

const hasDetailedErrors = Boolean(
invalidVerifyAny.error
&& invalidVerifyAny.error.includes('Attempts ->')
&& invalidVerifyAny.metadata?.attempts?.length === 2,
);

console.log(' ✅ verifyAny diagnostics:', hasDetailedErrors ? 'PASSED' : 'FAILED');
} catch (error) {
console.log(' ❌ verifyAny diagnostics test failed:', error);
}

// Test 11: Normalization for Stripe
console.log('\n11. Testing payload normalization...');
try {
Expand Down Expand Up @@ -513,6 +543,24 @@ try {
console.log(' ❌ WorkOS test failed:', error);
}

// Test 17.5: Shopify
console.log('\n17.5. Testing Shopify webhook...');
try {
const signature = createShopifySignature(testBody, testSecret);
const request = createMockRequest({
'x-shopify-hmac-sha256': signature,
'content-type': 'application/json',
});

const result = await WebhookVerificationService.verifyWithPlatformConfig(request, 'shopify', testSecret);
console.log(' ✅ Shopify:', result.isValid ? 'PASSED' : 'FAILED');
if (!result.isValid) {
console.log(' ❌ Error:', result.error);
}
} catch (error) {
console.log(' ❌ Shopify test failed:', error);
}

// Test 18: WooCommerce
console.log('\n18. Testing WooCommerce webhook...');
try {
Expand All @@ -528,6 +576,30 @@ try {
console.log(' ❌ WooCommerce test failed:', error);
}

// Test 18.5: Polar (Standard Webhooks)
console.log('\n18.5. Testing Polar webhook...');
try {
const secret = `whsec_${Buffer.from(testSecret).toString('base64')}`;
const webhookId = 'polar-webhook-id-1';
const timestamp = Math.floor(Date.now() / 1000);
const signature = createStandardWebhooksSignature(testBody, secret, webhookId, timestamp);
const request = createMockRequest({
'webhook-signature': signature,
'webhook-id': webhookId,
'webhook-timestamp': timestamp.toString(),
'user-agent': 'Polar.sh Webhooks',
'content-type': 'application/json',
});

const result = await WebhookVerificationService.verifyWithPlatformConfig(request, 'polar', secret);
const detectedPlatform = WebhookVerificationService.detectPlatform(request);

console.log(' ✅ Polar verification:', result.isValid ? 'PASSED' : 'FAILED');
console.log(' ✅ Polar auto-detect:', detectedPlatform === 'polar' ? 'PASSED' : 'FAILED');
} catch (error) {
console.log(' ❌ Polar test failed:', error);
}

// Test 19: Replicate
console.log('\n19. Testing Replicate webhook...');
try {
Expand Down
7 changes: 7 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ export function detectPlatformFromHeaders(headers: Headers): WebhookPlatform | n
return 'polar';
}

if (headerMap.has('webhook-signature')) {
const userAgent = (headerMap.get('user-agent') || '').toLowerCase();
if (userAgent.includes('polar')) {
return 'polar';
}
}

// Supabase
if (headerMap.has('x-webhook-token')) {
return 'supabase';
Expand Down
13 changes: 8 additions & 5 deletions src/verifiers/algorithms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,14 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier {
algorithm: string = 'sha256',
): boolean {
const secretEncoding = this.config.customConfig?.secretEncoding || 'base64';
const secretMaterial = secretEncoding === 'base64'
? new Uint8Array(
Buffer.from(this.secret.includes('_') ? this.secret.split('_')[1] : this.secret, 'base64'),
)
: this.secret;

let secretMaterial: string | Uint8Array = this.secret;
if (secretEncoding === 'base64') {
const base64Secret = this.secret.includes('_')
? this.secret.split('_').slice(1).join('_')
: this.secret;
secretMaterial = new Uint8Array(Buffer.from(base64Secret, 'base64'));
}

const hmac = createHmac(algorithm, secretMaterial);
hmac.update(payload);
Expand Down