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
.env
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:

Signature Verification
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:

app/routes/webhooks.inkress.tsx
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:

  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)