Logo
API ReferenceWebhooks

Signature Verification

One2Pays signs webhook events with a signature that you can use to verify that the events were sent by One2Pays, not by a third party.

Security

Always verify webhook signatures in production to prevent malicious requests from being processed as legitimate webhooks.

How It Works

One2Pays generates a signature for each webhook event using your webhook secret and the request payload. The signature is included in the X-Webhook-Signature header.

Getting Your Webhook Secret

  1. Go to your Dashboard
  2. Navigate to Settings → Webhooks
  3. Click on your webhook endpoint
  4. Copy the "Signing Secret"

Tip

Each webhook endpoint has its own unique signing secret. Make sure you're using the correct secret for each endpoint.

Signature Format

The signature header contains a timestamp and one or more signatures:

X-Webhook-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Webhook-Timestamp: 1640995200000
  • X-Webhook-Signature - HMAC signature in format sha256=<hex_signature>
  • X-Webhook-Timestamp - Unix timestamp in milliseconds when the signature was generated

Manual Verification

If you're not using our SDKs, you can manually verify the signature:

Step 1: Extract the Signature and Timestamp

function extractSignatureHeaders(request: Request) {
  const signature = request.headers.get('x-webhook-signature');
  const timestamp = request.headers.get('x-webhook-timestamp');

  if (!signature || !timestamp) {
    throw new Error('Missing signature or timestamp headers');
  }

  // Extract signature hash (format: sha256=<hash>)
  const signatureHash = signature.replace(/^sha256=/, '');
  const timestampMs = parseInt(timestamp, 10);

  if (isNaN(timestampMs) || timestampMs <= 0) {
    throw new Error('Invalid timestamp format');
  }

  return { signatureHash, timestampMs };
}

Step 2: Verify the Timestamp

Check that the timestamp is within the tolerance window (default: 5 minutes = 300000 milliseconds):

function verifyTimestamp(timestampMs: number, tolerance: number = 300000): boolean {
  const currentTime = Date.now();
  return Math.abs(currentTime - timestampMs) <= tolerance;
}

Step 3: Create the Signed Payload

Concatenate the timestamp (in milliseconds) and the raw request body with a dot:

const signedPayload = `${timestampMs}.${rawBody}`;

Step 4: Compute the Expected Signature

Use HMAC SHA256 with your webhook secret:

import crypto from 'crypto';

function computeSignature(signedPayload: string, secret: string): string {
  return crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
}

Step 5: Compare Signatures

Use a constant-time comparison to prevent timing attacks:

function compareSignatures(expectedHash: string, receivedHash: string): boolean {
  return crypto.timingSafeEqual(Buffer.from(expectedHash, 'hex'), Buffer.from(receivedHash, 'hex'));
}

Complete Manual Verification Example

import crypto from 'crypto';

function verifyWebhookSignature(
  payload: string,
  signature: string,
  timestamp: string,
  secret: string,
  tolerance: number = 300000 // 5 minutes in milliseconds
): boolean {
  // Extract signature hash (format: sha256=<hash>)
  const signatureHash = signature.replace(/^sha256=/, '');

  // Parse timestamp (in milliseconds)
  const timestampMs = parseInt(timestamp, 10);
  if (isNaN(timestampMs) || timestampMs <= 0) {
    throw new Error('Invalid timestamp format');
  }

  // Check timestamp tolerance
  const currentTime = Date.now();
  if (Math.abs(currentTime - timestampMs) > tolerance) {
    throw new Error('Timestamp outside tolerance');
  }

  // Create signed payload: timestamp.payload
  const signedPayload = `${timestampMs}.${payload}`;

  // Compute expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Compare signatures using timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signatureHash, 'hex'),
    Buffer.from(expectedSignature, 'hex')
  );
}

// Usage
export async function POST(request: Request) {
  const signature = request.headers.get('x-webhook-signature');
  const timestamp = request.headers.get('x-webhook-timestamp');
  const body = await request.text();

  if (!signature || !timestamp) {
    return new Response('Missing signature or timestamp', { status: 400 });
  }

  try {
    const isValid = verifyWebhookSignature(
      body,
      signature,
      timestamp,
      process.env.PAYMENT_WEBHOOK_SECRET!
    );

    if (!isValid) {
      return new Response('Invalid signature', { status: 401 });
    }

    // Process webhook
    const event = JSON.parse(body);
    await handleWebhookEvent(event);

    return new Response('OK', { status: 200 });
  } catch (error) {
    console.error('Webhook verification failed:', error);
    return new Response('Verification failed', { status: 400 });
  }
}

Timestamp Tolerance

By default, One2Pays rejects webhooks with timestamps older than 5 minutes to prevent replay attacks. The verification function above includes a tolerance check (default 300 seconds = 5 minutes). You can adjust this tolerance:

const isValid = verifyWebhookSignature(
  body,
  signature,
  process.env.PAYMENT_WEBHOOK_SECRET,
  600 // 10 minutes tolerance
);

Clock Skew

If your server's clock is significantly out of sync, you may need to increase the tolerance or sync your server's time.

Common Issues

Invalid Signature Format

Make sure you're reading the raw request body, not a parsed JSON object.

Timestamp Outside Tolerance

Check your server's system time and ensure it's synchronized.

Wrong Secret

Verify you're using the correct webhook secret for your endpoint.

Missing Header

Ensure your server is receiving both the X-Webhook-Signature and X-Webhook-Timestamp headers.

Security Best Practices

  1. Always verify signatures in production
  2. Use HTTPS for webhook endpoints
  3. Implement timestamp tolerance to prevent replay attacks
  4. Store webhook secrets securely (environment variables)
  5. Log verification failures for monitoring
  6. Use constant-time comparison to prevent timing attacks

On this page