Attackers can impersonate services by simply sending a fake webhook to an endpoint since it's fundamentally an HTTP POST from an unknown source.

To prevent this, every webhook message (along with its payload) is signed with a unique key (webhook_secret) . Clients can use the webhook_secret to verify the message origin.

Clients can provide a custom webhook_secret when creating a Webhook. If not provided, a randomly generated webhook_secret will be generated

How to verify webhooks

Each webhook message contains 3 important pieces of information in the header, which can be utilized for verification:

  • webhook-id - The unique message identifier for the webhook message. This identifier is unique across all messages, but will be the same when the same webhook is being resent (e.g. due to failure).
  • webhook-timestamp - Timestamp in seconds since epoch
  • webhook-signature - Base64 encoded list of signatures (space delimited)

Signed Content

The signed content is composed by joining the webhook_id, webhook_timestamp and message body with a period. In Node.js, it will look something like this:

signedContent = `${webhook_id}.${webhook_timestamp}.${body}`;
  • body - raw body of the request.

Validating Signed Content against Expected Signature

The webhook-signature is generated using an HMAC with SHA-256

To calculate the expected signature:

  1. HMAC the signed_content from above using your webhook_secret (e.g:MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw)
  2. The generated signature should match one of the signatures in webhook-signature. The webhook-signature header is composed of a list of space delimited signatures and their corresponding version identifiers. For example: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=
  3. There's usually a single webhook-signature but there could be any number of signatures for versioning and webhook_secret rotation.

The following code sample calculates the expected signature in Node.js

const crypto = require("crypto");

// HTTP Request headers
const webhook_id = "msg_2Kp7XXfVpg9DcEphTNjt7QunxcZ";
const webhook_timestamp = "1674659710";
const webhook_signature = "v1,F3NpVu//Inc0BgwSiBu7fLr+eC7tC2XQxbjwFhhKfAI=";

// HTTP body
const body = JSON.stringify({
  data: [
    {
      collection_id: "c4ae7e3f9a4ca6a1e61b57dd16ff3084",
      floor_price: {
        marketplace_id: "opensea",
        payment_token: {
          address: null,
          decimals: 18,
          name: "Ether",
          payment_token_id: "ethereum.native",
          symbol: "ETH",
        },
        value: 2900000000000000000,
      },
    },
  ],
  type: "collection.floor_price_update",
});



signedContent = `${webhook_id}.${webhook_timestamp}.${body}`;
const secret = "<YOUR webhook_secret here>"

// Need to base64 decode the secret
const secretBytes = Buffer.from(secret, "base64");
const computed_signature = crypto
  .createHmac("sha256", secretBytes)
  .update(signedContent)
  .digest("base64");
console.log(computed_signature);

// computed_signature should match one of the signatures in webhook_signature