Webhook Security
Learn how to verify webhook authenticity and secure your webhook endpoints from malicious requests.
Why Verify Webhooks?
Anyone with your webhook URL could potentially send fake requests to your endpoint. Webhook signature verification ensures that requests actually come from Inkress and haven't been tampered with.
⚠️ Security Warning
Always verify webhook signatures in production. Processing unverified webhooks could lead to financial fraud, data corruption, or unauthorized actions in your system.
How Signature Verification Works
1. Inkress Signs Each Webhook
When sending a webhook, Inkress generates a signature using your webhook signing secret and the webhook payload. This signature is included in the X-Inkress-Signature header.
2. Your Server Verifies the Signature
Your endpoint receives the webhook, computes its own signature using the same secret, and compares it to the signature in the header.
3. Process or Reject
If signatures match, the webhook is genuine. If they don't match, reject the request with a 401 status code.
Getting Your Signing Secret
- Navigate to Settings → Webhooks in your dashboard
- Select your webhook endpoint
- Click "Reveal Signing Secret"
- Copy the secret and store it securely in your environment variables
1INKRESS_WEBHOOK_SECRET=whsec_abc123xyz...🔒 Keep It Secret
Never commit your webhook signing secret to version control or expose it in client-side code. Treat it like a password.
Implementation
Step 1: Extract the Signature
Get the signature from the X-Inkress-Signature header:
1export async function action({ request }: ActionFunctionArgs) {2 const signature = request.headers.get('X-Inkress-Signature');3 4 if (!signature) {5 return json({ error: 'Missing signature' }, { status: 401 });6 }7 8 // Continue verification...9}Step 2: Get the Raw Request Body
You need the raw request body (not parsed JSON) to verify the signature:
1export async function action({ request }: ActionFunctionArgs) {2 const signature = request.headers.get('X-Inkress-Signature');3 4 // Clone the request to read the body twice5 const clonedRequest = request.clone();6 const rawBody = await clonedRequest.text();7 8 // Parse for processing later9 const payload = JSON.parse(rawBody);10 11 // Continue with verification...12}Step 3: Compute and Compare
Create an HMAC hash of the payload and compare it to the signature:
1import crypto from 'crypto';23function verifyWebhookSignature(4 rawBody: string, 5 signature: string, 6 secret: string7): boolean {8 // Compute HMAC SHA-256 hash9 const hmac = crypto10 .createHmac('sha256', secret)11 .update(rawBody, 'utf8')12 .digest('hex');13 14 // Compare using timing-safe comparison15 try {16 return crypto.timingSafeEqual(17 Buffer.from(signature, 'hex'),18 Buffer.from(hmac, 'hex')19 );20 } catch {21 return false;22 }23}2425// Usage in your action26export async function action({ request }: ActionFunctionArgs) {27 const signature = request.headers.get('X-Inkress-Signature');28 const rawBody = await request.clone().text();29 const secret = process.env.INKRESS_WEBHOOK_SECRET!;30 31 if (!verifyWebhookSignature(rawBody, signature!, secret)) {32 return json({ error: 'Invalid signature' }, { status: 401 });33 }34 35 // Signature is valid, process the webhook36 const payload = JSON.parse(rawBody);37 await processWebhook(payload);38 39 return json({ received: true });40}Complete Example
Here's a full implementation with proper error handling:
1import { json } from "@remix-run/node";2import type { ActionFunctionArgs } from "@remix-run/node";3import crypto from 'crypto';45function verifyWebhookSignature(6 rawBody: string,7 signature: string,8 secret: string9): boolean {10 const hmac = crypto11 .createHmac('sha256', secret)12 .update(rawBody, 'utf8')13 .digest('hex');14 15 try {16 return crypto.timingSafeEqual(17 Buffer.from(signature, 'hex'),18 Buffer.from(hmac, 'hex')19 );20 } catch {21 return false;22 }23}2425export async function action({ request }: ActionFunctionArgs) {26 // Only accept POST requests27 if (request.method !== 'POST') {28 return json({ error: 'Method not allowed' }, { status: 405 });29 }3031 // Get signature header32 const signature = request.headers.get('X-Inkress-Signature');33 if (!signature) {34 console.error('Webhook: Missing signature header');35 return json({ error: 'Missing signature' }, { status: 401 });36 }3738 // Get raw body for verification39 const rawBody = await request.clone().text();40 41 // Verify signature42 const secret = process.env.INKRESS_WEBHOOK_SECRET;43 if (!secret) {44 console.error('Webhook: INKRESS_WEBHOOK_SECRET not configured');45 return json({ error: 'Server configuration error' }, { status: 500 });46 }4748 if (!verifyWebhookSignature(rawBody, signature, secret)) {49 console.error('Webhook: Invalid signature');50 return json({ error: 'Invalid signature' }, { status: 401 });51 }5253 // Parse and process webhook54 try {55 const payload = JSON.parse(rawBody);56 const { id, event_type, data } = payload;5758 // Check for duplicate events (idempotency)59 const existing = await db.webhookEvent.findUnique({ 60 where: { id } 61 });62 63 if (existing) {64 console.log(`Webhook ${id} already processed`);65 return json({ received: true, duplicate: true });66 }6768 // Store event69 await db.webhookEvent.create({70 data: { 71 id, 72 event_type,73 processed_at: new Date() 74 }75 });7677 // Process the webhook78 switch (event_type) {79 case 'payment.success':80 await handlePaymentSuccess(data);81 break;82 case 'payment.failed':83 await handlePaymentFailed(data);84 break;85 case 'payout.completed':86 await handlePayoutCompleted(data);87 break;88 default:89 console.log(`Unhandled event type: ${event_type}`);90 }9192 return json({ received: true });93 } catch (error) {94 console.error('Webhook processing error:', error);95 return json({ error: 'Processing failed' }, { status: 500 });96 }97}9899async function handlePaymentSuccess(data: any) {100 // Send confirmation email101 await sendEmail({102 to: data.customer.email,103 subject: 'Payment Successful',104 template: 'payment-confirmation',105 data106 });107108 // Update order status109 await db.order.update({110 where: { id: data.order_id },111 data: { status: 'paid' }112 });113}114115async function handlePaymentFailed(data: any) {116 // Notify customer117 await sendEmail({118 to: data.customer.email,119 subject: 'Payment Failed',120 template: 'payment-failed',121 data122 });123}124125async function handlePayoutCompleted(data: any) {126 // Update payout record127 await db.payout.update({128 where: { id: data.payout_id },129 data: { 130 status: 'completed',131 completed_at: new Date()132 }133 });134}Additional Security Measures
1. Use HTTPS in Production
Always use HTTPS endpoints to ensure webhook data is encrypted in transit. Inkress will reject non-HTTPS webhook URLs in production.
2. Implement Rate Limiting
Protect your webhook endpoint from abuse by implementing rate limiting based on IP address or signature validity.
3. Log Failed Attempts
Monitor and log failed signature verifications to detect potential attacks or misconfiguration issues.
1if (!verifyWebhookSignature(rawBody, signature, secret)) {2 await db.securityLog.create({3 data: {4 type: 'invalid_webhook_signature',5 ip_address: request.headers.get('X-Forwarded-For'),6 attempted_signature: signature,7 timestamp: new Date()8 }9 });10 return json({ error: 'Invalid signature' }, { status: 401 });11}4. Rotate Signing Secrets
Periodically rotate your webhook signing secret. You can generate a new secret in the dashboard and update your environment variables. Old webhooks in transit may fail, but this is expected.
5. Validate Event Data
Even with signature verification, validate the event data structure and values before processing:
1const payload = JSON.parse(rawBody);23// Validate required fields4if (!payload.id || !payload.event_type || !payload.data) {5 return json({ error: 'Invalid payload structure' }, { status: 400 });6}78// Validate data types9if (typeof payload.data.amount !== 'number') {10 return json({ error: 'Invalid amount type' }, { status: 400 });11}1213// Validate business rules14if (payload.data.amount < 0) {15 return json({ error: 'Invalid amount value' }, { status: 400 });16}Testing Signature Verification
Use the dashboard to send test webhooks with valid signatures:
- Go to Settings → Webhooks
- Select your webhook endpoint
- Click "Send Test Event"
- The test event will be signed with your current signing secret
- Verify your endpoint correctly validates the signature
Tip: Temporarily log the computed hash and received signature during development to debug signature mismatches. Remove these logs in production.
Troubleshooting
Signatures Don't Match
- Verify you're using the raw request body, not parsed JSON
- Check that you're using the correct signing secret
- Ensure the secret has no extra whitespace or newlines
- Confirm you're using HMAC SHA-256 algorithm
- Make sure you're comparing the hex-encoded hash
Missing Signature Header
- Verify your webhook URL is correctly registered in the dashboard
- Check for reverse proxies that might strip headers
- Ensure you're checking the correct header name (case-sensitive)