Webhooks Integration

When you receive webhook notifications from our API, it's crucial to verify the authenticity of the messages to ensure data integrity and prevent unauthorized access. We use HMAC (Hash-based Message Authentication Code) to generate a signature for each webhook payload, which you can verify using a shared secret and timestamp provided in the request headers.

Verifying the HMAC Signature

To verify the HMAC signature included in the webhook payload, follow these steps:

  1. Retrieve the PayNow-Signature header from the webhook request, containing the HMAC of the entire payload body and the timestamp.
  2. Extract the Unix millisecond timestamp from the PayNow-Timestamp header.
  3. Create a string by combining the timestamp from the header, followed by a dot (".") and then the actual payload string from the webhook request. Then create an HMAC signature using the resulting string and your signing secret.
  4. Compare the calculated signature with the signature provided in the PayNow-Signature header.

Preventing Replay Attacks

To prevent replay attacks, ensure that you check the timestamp included in the request and ignore webhook notifications with older timestamps. Here's how to do it:

Compare the timestamp provided in the PayNow-Timestamp header with the current time. If the timestamp is significantly older than the current time (e.g., more than a few seconds or minutes), consider the request invalid and discard it.

You may also store the event_id in your database and ignore any duplicate requests with the same ID. This is useful for ensuring that your server does not handle the same webhook twice.

Integration Implementation Examples

const express = require('express');
const crypto = require('crypto');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 8080;

const SIGNING_SECRET = process.env.SIGNING_SECRET;
const TOLERANCE_PERIOD = 5 * 60 * 1000; // 5 minutes in milliseconds

// Middleware to preserve raw body for signature verification
const rawBodySaver = function (req, res, buf, encoding) {
    if (buf && buf.length) {
        req.rawBody = buf.toString(encoding || 'utf8');
    }
}

// Function to calculate HMAC signature
function calculateHMAC(payloadWithTimestamp) {
    const hmac = crypto.createHmac('sha256', SIGNING_SECRET);
    hmac.update(payloadWithTimestamp);
    return hmac.digest('base64');
}

// Middleware to verify webhook signature and prevent replay attacks
function verifyWebhookSignature(req, res, next) {
    const timestamp = req.headers['paynow-timestamp'];
    const providedSignature = req.headers['paynow-signature'];

    if (!timestamp || !providedSignature) {
        return res.status(400).send('Missing required headers');
    }

    const timestampInt = parseInt(timestamp, 10);
    if (isNaN(timestampInt)) {
        return res.status(400).send('Invalid timestamp format');
    }

    const timestampTime = new Date(timestampInt);
    const currentTime = new Date();
    if (currentTime - timestampTime > TOLERANCE_PERIOD) {
        return res.status(401).send('Timestamp out of tolerance');
    }

    const payloadWithTimestamp = `${timestamp}.${req.rawBody}`;
    const expectedSignature = calculateHMAC(payloadWithTimestamp);

    const signatureValid = crypto.timingSafeEqual(Buffer.from(providedSignature, 'base64'), Buffer.from(expectedSignature, 'base64'));

    if (!signatureValid) {
        return res.status(401).send('Invalid signature');
    }

    next();
}

app.use(express.json({ verify: rawBodySaver }));

app.post('/webhook', verifyWebhookSignature, (req, res) => {
    const eventType = req.body.event_type;
    switch (eventType) {
        case 'ON_DELIVERY_ITEM_ADDED':
            // handleOnDeliveryItemAdded(req.body);
            break;
        case 'ON_DELIVERY_ITEM_ACTIVATED':
            // handleOnDeliveryItemActivated(req.body);
            break;
        case 'ON_DELIVERY_ITEM_USED':
            // handleOnDeliveryItemUsed(req.body);
            break;
        case 'ON_DELIVERY_ITEM_REVOKED':
            // handleOnDeliveryItemRevoked(req.body);
            break;
        default:
            console.log(`Received unknown event type: ${eventType}`);
    }
    res.status(200).send('Webhook processed');
});

app.listen(PORT, () => {
    console.log(`Server running at http://localhost:${PORT}/`);
});