Example webhook handler

Use this example as a boilerplate for your webhook handler. We added extra comments to make it super clear:

const express = require('express')
const router = express.Router()
const crypto = require('crypto')
const { logger } = global

// Create your signing key at https://app.frontitude.com/settings/integrations in the "Webhooks" section
const WEBHOOKS_SIGNING_KEY = process.env.WEBHOOKS_SIGNING_KEY || 'xxx'

router.post('/handle_frontitude_events', async function(req, res) {
    // It is important to disable CSRF protection for this endpoint if the framework you use enables them by default.
    
    const { 'webhook-id': webhookId, 'webhook-timestamp': webhookTimestamp, 'webhook-signature': webhookSignature } = req.headers
    const { eventType, data } = req.body

    logger.info({
        message: 'Frontitude webhooks: new event received',
        eventType,
        webhookId
    })

    // Compare event's timestamp (in seconds since epoch) against system's timestamp to make sure it's within tolerance in order to prevent timestamp/replay attacks (see: https://en.wikipedia.org/wiki/Replay_attack)
    const VALID_TIMESTAMP_TOLERANCE_IN_MINUTES = 5
    const currentTimestampInSeconds = Math.floor(new Date().getTime() / 1000)
    const minutesAgo = currentTimestampInSeconds - (1000 * 60 * VALID_TIMESTAMP_TOLERANCE_IN_MINUTES)
    const minutesLater = currentTimestampInSeconds + (1000 * 60 * VALID_TIMESTAMP_TOLERANCE_IN_MINUTES)
    if (webhookTimestamp < minutesAgo || webhookTimestamp > minutesLater) {
        logger.error({
            message: 'Frontitude webhooks: invalid timestamp',
            webhookTimestamp,
            minutesAgo,
            minutesLater,
            request: {
                headers: req.headers,
                body: req.body
            }
        })

        return res.status(401).send('Invalid timestamp')
    }

    // Verify event's signature by comparing the signature sent in the webhook headers with the signature computed from the webhook payload
    // The content to sign is composed by concatenating the webhook id, timestamp and payload, separated by the full-stop character (.).
    signedContent = `${webhookId}.${webhookTimestamp}.${JSON.stringify(req.body)}`
    const expectedSignature = crypto
        .createHmac('sha256', Buffer.from(WEBHOOKS_SIGNING_KEY, 'base64'))
        .update(signedContent)
        .digest('base64')
    
    // The expected signature should match one of the signatures sent in the signature header
    // Signature header is composed of a list of space delimited signatures and their corresponding version identifiers. The list is most commonly of length one. Though there could be any number of signatures.
    // For example: "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo="
    const webhookPotentialSignatures = webhookSignature.split(' ').map(signature => signature.split(',')[1])
    if (!webhookPotentialSignatures.includes(expectedSignature)) {
        logger.error({
            message: 'Frontitude webhooks: invalid signature',
            expectedSignature,
            webhookSignature,
            request: {
                headers: req.headers,
                body: req.body
            }
        })

        return res.status(401).send('Invalid signature')
    }

    // Process the event
    // The way to indicate that a webhook has been processed is by returning a 2xx (status code 200-299) response to the webhook message within a reasonable time-frame (up to 15s). If processing the webhook takes longer than that, it's better to return a 2xx response and process the webhook asynchronously, to avoid Frontitude retrying the webhook delivery.

    logger.info({
        message: 'Frontitude webhooks: processed event successfully',
        eventType,
        data
    })

    res.sendStatus(200)
})

module.exports = router

Last updated