MyTPE is getting a fresh new look! A simpler, faster platform is on the way. Learn more →
MyTPEMyTPE Pay
Webhooks

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:

LanguageFunction
PHPhash_equals($expected, $actual)
Node.jscrypto.timingSafeEqual(buf1, buf2)
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
RubyActiveSupport::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_secret

This sends a transaction.completed test event with a valid signature so you can verify your implementation works correctly.

Troubleshooting

IssueCauseSolution
403 Invalid signatureWrong secret or body was modifiedCheck your secret matches, ensure no middleware modifies the raw body
403 Timestamp expiredYour server clock is offSync your server time with NTP
Duplicate eventsRetry after timeoutImplement idempotency with delivery_id
No webhooks receivedURL not reachableEnsure your endpoint is publicly accessible and returns 200
Timeout errorsProcessing takes too longProcess asynchronously, respond 200 immediately

On this page