Verify Signatures
Step-by-step guide to verify MyTPE Pay webhook signatures to ensure authenticity.
Verify Webhook Signatures
Every webhook from MyTPE Pay is signed. You must verify the signature before processing any webhook. This prevents attackers from sending fake webhooks to your endpoint.
Verification Steps
Step 1: Extract Headers
X-MytpePay-Signature: sha256=a1b2c3d4e5f6...
X-MytpePay-Timestamp: 1712678400
X-MytpePay-Event: transaction.completed
X-MytpePay-Delivery-Id: f47ac10b-58cc-4372-...Step 2: Replay Protection
Reject webhooks with timestamps older than 5 minutes:
const timestamp = parseInt(headers['x-mytpepay-timestamp']);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
// Reject — possible replay attack
return res.status(403).send('Timestamp expired');
}Step 3: Compute Expected Signature
Concatenate the timestamp and raw request body with a dot, then compute HMAC-SHA256:
data_to_sign = "{timestamp}.{raw_body}"
expected = HMAC-SHA256(data_to_sign, your_webhook_secret)Step 4: Compare Signatures
Use a constant-time comparison to prevent timing attacks:
| Language | Function |
|---|---|
| PHP | hash_equals($expected, $actual) |
| Node.js | crypto.timingSafeEqual(buf1, buf2) |
| Python | hmac.compare_digest(a, b) |
| Go | hmac.Equal(a, b) |
| Ruby | ActiveSupport::SecurityUtils.secure_compare(a, b) |
Never use == for signature comparison
Using == or === for string comparison is vulnerable to timing attacks. Always use the constant-time functions listed above.
Step 5: Check Idempotency
Store processed delivery_id values and skip duplicates:
const deliveryId = headers['x-mytpepay-delivery-id'];
if (await db.webhookDeliveries.exists(deliveryId)) {
return res.status(200).send('Already processed');
}
// Process the webhook...
await db.webhookDeliveries.insert(deliveryId);Complete Example (PHP)
<?php
function verifyWebhook(string $secret): array
{
$signature = $_SERVER['HTTP_X_MYTPEPAY_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_MYTPEPAY_TIMESTAMP'] ?? '';
$body = file_get_contents('php://input');
// Step 2: Replay protection
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(403);
die('Webhook timestamp expired');
}
// Step 3: Compute expected signature
$expected = 'sha256=' . hash_hmac(
'sha256',
$timestamp . '.' . $body,
$secret
);
// Step 4: Constant-time comparison
if (!hash_equals($expected, $signature)) {
http_response_code(403);
die('Invalid webhook signature');
}
// Signature valid — parse payload
return json_decode($body, true);
}
// Usage
$payload = verifyWebhook($_ENV['MYTPEPAY_WEBHOOK_SECRET']);
$event = $payload['event'];
$data = $payload['data'];
// Process based on event type...Testing
Use the artisan command to send a test webhook to your endpoint:
php artisan test:mytpe-webhook https://your-server.com/webhooks/mytpe --secret=whsec_your_secretThis sends a transaction.completed test event with a valid signature so you can verify your implementation works correctly.
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
403 Invalid signature | Wrong secret or body was modified | Check your secret matches, ensure no middleware modifies the raw body |
403 Timestamp expired | Your server clock is off | Sync your server time with NTP |
| Duplicate events | Retry after timeout | Implement idempotency with delivery_id |
| No webhooks received | URL not reachable | Ensure your endpoint is publicly accessible and returns 200 |
| Timeout errors | Processing takes too long | Process asynchronously, respond 200 immediately |