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

  1. Navigate to Settings → Webhooks in your dashboard
  2. Select your webhook endpoint
  3. Click "Reveal Signing Secret"
  4. Copy the secret and store it securely in your environment variables
.envbash
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 twice
5 const clonedRequest = request.clone();
6 const rawBody = await clonedRequest.text();
7
8 // Parse for processing later
9 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:

Signature Verificationtypescript
1import crypto from 'crypto';
2
3function verifyWebhookSignature(
4 rawBody: string,
5 signature: string,
6 secret: string
7): boolean {
8 // Compute HMAC SHA-256 hash
9 const hmac = crypto
10 .createHmac('sha256', secret)
11 .update(rawBody, 'utf8')
12 .digest('hex');
13
14 // Compare using timing-safe comparison
15 try {
16 return crypto.timingSafeEqual(
17 Buffer.from(signature, 'hex'),
18 Buffer.from(hmac, 'hex')
19 );
20 } catch {
21 return false;
22 }
23}
24
25// Usage in your action
26export 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 webhook
36 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:

app/routes/webhooks.inkress.tsxtypescript
1import { json } from "@remix-run/node";
2import type { ActionFunctionArgs } from "@remix-run/node";
3import crypto from 'crypto';
4
5function verifyWebhookSignature(
6 rawBody: string,
7 signature: string,
8 secret: string
9): boolean {
10 const hmac = crypto
11 .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}
24
25export async function action({ request }: ActionFunctionArgs) {
26 // Only accept POST requests
27 if (request.method !== 'POST') {
28 return json({ error: 'Method not allowed' }, { status: 405 });
29 }
30
31 // Get signature header
32 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 }
37
38 // Get raw body for verification
39 const rawBody = await request.clone().text();
40
41 // Verify signature
42 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 }
47
48 if (!verifyWebhookSignature(rawBody, signature, secret)) {
49 console.error('Webhook: Invalid signature');
50 return json({ error: 'Invalid signature' }, { status: 401 });
51 }
52
53 // Parse and process webhook
54 try {
55 const payload = JSON.parse(rawBody);
56 const { id, event_type, data } = payload;
57
58 // 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 }
67
68 // Store event
69 await db.webhookEvent.create({
70 data: {
71 id,
72 event_type,
73 processed_at: new Date()
74 }
75 });
76
77 // Process the webhook
78 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 }
91
92 return json({ received: true });
93 } catch (error) {
94 console.error('Webhook processing error:', error);
95 return json({ error: 'Processing failed' }, { status: 500 });
96 }
97}
98
99async function handlePaymentSuccess(data: any) {
100 // Send confirmation email
101 await sendEmail({
102 to: data.customer.email,
103 subject: 'Payment Successful',
104 template: 'payment-confirmation',
105 data
106 });
107
108 // Update order status
109 await db.order.update({
110 where: { id: data.order_id },
111 data: { status: 'paid' }
112 });
113}
114
115async function handlePaymentFailed(data: any) {
116 // Notify customer
117 await sendEmail({
118 to: data.customer.email,
119 subject: 'Payment Failed',
120 template: 'payment-failed',
121 data
122 });
123}
124
125async function handlePayoutCompleted(data: any) {
126 // Update payout record
127 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);
2
3// Validate required fields
4if (!payload.id || !payload.event_type || !payload.data) {
5 return json({ error: 'Invalid payload structure' }, { status: 400 });
6}
7
8// Validate data types
9if (typeof payload.data.amount !== 'number') {
10 return json({ error: 'Invalid amount type' }, { status: 400 });
11}
12
13// Validate business rules
14if (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:

  1. Go to Settings → Webhooks
  2. Select your webhook endpoint
  3. Click "Send Test Event"
  4. The test event will be signed with your current signing secret
  5. 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)