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
INKRESS_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:
export async function action({ request }: ActionFunctionArgs) {
const signature = request.headers.get('X-Inkress-Signature');
if (!signature) {
return json({ error: 'Missing signature' }, { status: 401 });
}
// Continue verification...
}Step 2: Get the Raw Request Body
You need the raw request body (not parsed JSON) to verify the signature:
export async function action({ request }: ActionFunctionArgs) {
const signature = request.headers.get('X-Inkress-Signature');
// Clone the request to read the body twice
const clonedRequest = request.clone();
const rawBody = await clonedRequest.text();
// Parse for processing later
const payload = JSON.parse(rawBody);
// Continue with verification...
}Step 3: Compute and Compare
Create an HMAC hash of the payload and compare it to the signature:
import crypto from 'crypto';
function verifyWebhookSignature(
rawBody: string,
signature: string,
secret: string
): boolean {
// Compute HMAC SHA-256 hash
const hmac = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
// Compare using timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(hmac, 'hex')
);
} catch {
return false;
}
}
// Usage in your action
export async function action({ request }: ActionFunctionArgs) {
const signature = request.headers.get('X-Inkress-Signature');
const rawBody = await request.clone().text();
const secret = process.env.INKRESS_WEBHOOK_SECRET!;
if (!verifyWebhookSignature(rawBody, signature!, secret)) {
return json({ error: 'Invalid signature' }, { status: 401 });
}
// Signature is valid, process the webhook
const payload = JSON.parse(rawBody);
await processWebhook(payload);
return json({ received: true });
}Complete Example
Here's a full implementation with proper error handling:
import { json } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import crypto from 'crypto';
function verifyWebhookSignature(
rawBody: string,
signature: string,
secret: string
): boolean {
const hmac = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(hmac, 'hex')
);
} catch {
return false;
}
}
export async function action({ request }: ActionFunctionArgs) {
// Only accept POST requests
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
// Get signature header
const signature = request.headers.get('X-Inkress-Signature');
if (!signature) {
console.error('Webhook: Missing signature header');
return json({ error: 'Missing signature' }, { status: 401 });
}
// Get raw body for verification
const rawBody = await request.clone().text();
// Verify signature
const secret = process.env.INKRESS_WEBHOOK_SECRET;
if (!secret) {
console.error('Webhook: INKRESS_WEBHOOK_SECRET not configured');
return json({ error: 'Server configuration error' }, { status: 500 });
}
if (!verifyWebhookSignature(rawBody, signature, secret)) {
console.error('Webhook: Invalid signature');
return json({ error: 'Invalid signature' }, { status: 401 });
}
// Parse and process webhook
try {
const payload = JSON.parse(rawBody);
const { id, event_type, data } = payload;
// Check for duplicate events (idempotency)
const existing = await db.webhookEvent.findUnique({
where: { id }
});
if (existing) {
console.log(`Webhook ${id} already processed`);
return json({ received: true, duplicate: true });
}
// Store event
await db.webhookEvent.create({
data: {
id,
event_type,
processed_at: new Date()
}
});
// Process the webhook
switch (event_type) {
case 'payment.success':
await handlePaymentSuccess(data);
break;
case 'payment.failed':
await handlePaymentFailed(data);
break;
case 'payout.completed':
await handlePayoutCompleted(data);
break;
default:
console.log(`Unhandled event type: ${event_type}`);
}
return json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
return json({ error: 'Processing failed' }, { status: 500 });
}
}
async function handlePaymentSuccess(data: any) {
// Send confirmation email
await sendEmail({
to: data.customer.email,
subject: 'Payment Successful',
template: 'payment-confirmation',
data
});
// Update order status
await db.order.update({
where: { id: data.order_id },
data: { status: 'paid' }
});
}
async function handlePaymentFailed(data: any) {
// Notify customer
await sendEmail({
to: data.customer.email,
subject: 'Payment Failed',
template: 'payment-failed',
data
});
}
async function handlePayoutCompleted(data: any) {
// Update payout record
await db.payout.update({
where: { id: data.payout_id },
data: {
status: 'completed',
completed_at: new Date()
}
});
}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.
if (!verifyWebhookSignature(rawBody, signature, secret)) {
await db.securityLog.create({
data: {
type: 'invalid_webhook_signature',
ip_address: request.headers.get('X-Forwarded-For'),
attempted_signature: signature,
timestamp: new Date()
}
});
return json({ error: 'Invalid signature' }, { status: 401 });
}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:
const payload = JSON.parse(rawBody);
// Validate required fields
if (!payload.id || !payload.event_type || !payload.data) {
return json({ error: 'Invalid payload structure' }, { status: 400 });
}
// Validate data types
if (typeof payload.data.amount !== 'number') {
return json({ error: 'Invalid amount type' }, { status: 400 });
}
// Validate business rules
if (payload.data.amount < 0) {
return json({ error: 'Invalid amount value' }, { status: 400 });
}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)