Skip to main content

Verifying Events

Webhooks can be vulnerable to security risks, such as malicious actors sending fake events or replaying intercepted requests. To safeguard against these threats, you should prevent replay attacks, verify the origin and authenticity of events, and prevent duplicate processing of events. A replay attack can be prevented by blocking intercepted requests from being reused. Every webhook includes the webhook-timestamp request header, which provides the Unix timestamp of when the event was signed. Use this timestamp to prevent replay attacks:
  • Ensure the webhook-timestamp is within a valid time window (we recommend up to 3 minutes).
  • Since retries generate a new timestamp for each attempt, you can confidently validate each request.
  • Reject requests with timestamps outside the threshold to block tampered or replayed events.
Verify the origin and authenticity of events by using the secret signing key unique to your account, and the request header webhook-signature included as part of the webhook. We sign all webhooks originating from us using the secret signing key. Follow these steps to ensure the event was sent by us:
  • Generate the signed content by concatenating the values of the webhook-id request header, webhook-timestamp request header, and a string representation of the request body into a single string, separated by periods.
  • Calculate the expected signature, using the secret key and the signed content above to generate a HMAC SHA256 signature. Then, encode this value using a base64 encoding.
  • Verify the signature by comparing the expected signature with the signature sent in the webhook-signature header. If the generated signature matches the provided signature, you can proceed with processing the event.
  • Make sure to use the string representation of the request body i.e. the raw request body as any minor modification to the body will generate a completely different signature.
  • Before calculating the expected signature, ensure that you exclude the whsec_ prefix from the secret signing key.
  • Before verifying the signature, make sure to remove the version prefix and delimiter (e.g. v1,).
  • When comparing the signatures, it is recommended to use a constant-time string comparison method to prevent timing attacks.

Handling Duplicate Events

Duplicate processing of events can be prevented at the request-level by processing events in an idempotent-safe manner. Each webhook request includes both a webhook-id in the request header and an event-id in the message body. The webhook-id uniquely identifies a single delivery attempt, while the event-id uniquely identifies the event itself across all retries or replays of the same event. To prevent duplicates, store the webhook-id in a persistent data store and ensure that a request with the same identifier has not been processed before. Using both identifiers ensures that duplicate HTTP deliveries and duplicate logical events are handled safely. Beyond request-level handling, integrations should always implement operation-level idempotency, as request-level deduplication is often only applied over a limited time window. Operation-level idempotency ensures that repeated or delayed business events do not cause inconsistent state changes after that window has expired. Before applying an update to a resource such as a payment_session or a payment, inspect the payload and the current state of the object:
  • If the payment_session (as identified by the payment session identifier) is already in a final state, for example, completed, expired, or canceled; or a payment (as identified by the payment identifier) is already in a final state, for example, succeeded, ignore the event by responding with a 2xx status code to prevent redelivery — since no further state change is needed.
  • Only apply updates that move the object forward, for example:
    • payment_session: activecompleted
    • payment: draftsucceeded
Combining request-level and operation-level idempotency ensures that duplicate or delayed webhook events are non-destructive, keeping states consistent even when events are retried or replayed beyond the initial request-handling window.

Additional Security Precautions

In addition to the security measures used to verify the origin and authenticity of webhook events described above, all webhook events are sent from a specific set of source static IP addresses. If your webhook endpoint is hosted behind a firewall or a NAT (Network Address Translation) gateway, you’ll need to ensure it can receive incoming requests. Whitelist the following source static IP addresses in your network configuration so that webhook traffic is not blocked or dropped by your security settings.
52.215.16.239
54.216.8.72
63.33.109.123
2a05:d028:17:8000::/56

Sample Code

// Using NodeJs + Express
app.post("/my/webhook/url", function(req, res) {
    const requestBody = req.rawBody;

    // TODO: Verify and process the received data

    res.send(200);
});
Generate the signed content:
// Using NodeJs + Express
const headers = req.headers;
const requestBody = req.rawBody;

const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];

const signedContent = `${id}.${timestamp}.${requestBody}`;
Calculate the expected signature:
const crypto = require('crypto');

const secret = 'whsec_MDUyYmQ2NThjZDY3NDU5NzkyYTViNjViOTlhYzI2MzE=';
const secretBytes = Buffer.from(secret.split("_")[1], "base64");

const expectedSignature = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64');
Verify the signature:
// Compare just the first signature
const signature = headers['webhook-signature'].split(' ')[0].split(',')[1]
if (crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))) {
    // process webhook event
    return res.send(200);
}
// do not process webhook event
return res.send(403);
const express = require('express');
const crypto = require('crypto');
const getRawBody = require('raw-body');

const app = express();

app.use((req, res, next) => {
    getRawBody(req, {
        length: req.headers['content-length'],
        encoding: 'utf-8'
    }, (err, rawBody) => {
        if (err) return next(err);
        req.rawBody = rawBody;
        next();
    });
});

app.post("/my/webhook/url", function(req, res) {
    const headers = req.headers;
    const requestBody = req.rawBody;

    const id = headers['webhook-id'];
    const timestamp = headers['webhook-timestamp'];

    const signedContent = `${id}.${timestamp}.${requestBody}`;

    const secret = 'whsec_M0U0MDI3QjYzMEQ0NTK5NDNCIjVFMENCMDEzNzc1QkE=';
    const secretBytes = new Buffer(secret.split('_')[1], "base64");

    const expectedSignature = crypto
        .createHmac('sha256', secretBytes)
        .update(signedContent)
        .digest('base64');

    const signature = headers['webhook-signature'].split(' ')[0].split(',')[1]
    if (crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))) {
        return res.send(200);
    }
    return res.send(403);
});

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});