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
- Go to your Dashboard
- Navigate to Settings → Webhooks
- Click on your webhook endpoint
- 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: 1640995200000X-Webhook-Signature- HMAC signature in formatsha256=<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
- Always verify signatures in production
- Use HTTPS for webhook endpoints
- Implement timestamp tolerance to prevent replay attacks
- Store webhook secrets securely (environment variables)
- Log verification failures for monitoring
- Use constant-time comparison to prevent timing attacks