Webhook Verification

Overview

Our webhooks service automatically signs each webhook event sent to the URL you specify in Ironclad. This allows you to verify the authenticity of the webhook and that it's coming from Ironclad. We recommend using a well-tested library for your preferred language and implementation.

Verifying Webhooks

There are a few steps required to verifying the webhook signatures. We'll go through each one below.

Retrieve Webhook Verification Key

To verify the signature, you need to grab a public key (encoded in PEM format). This is available through our REST API and can be retrieved by doing a GET call on the /webhooks/verification-key endpoint.

Extract Webhook Headers

On each webhook HTTP request, there are two headers that will be needed for verification.

  • X-Ironclad-Webhook-Event-Id: a string that uniquely identifies the webhook event.
  • X-Ironclad-Webhook-Verification is a serialized JSON object containing four keys. They are the following:
    • nonce is a random string.
    • signAlgorithm is the hashing algorithm that Ironclad uses for signing the webhook.
    • signature is the webhook digital signature signed by Ironclad.
    • encoding is the binary-to-text encoding scheme of the signature.

Verification

Now that you have the webhook body, webhook signature headers, and the public key, you can use these data points to do the verification.

The data that will need to be verified is a concatenation of the (1) X-Ironclad-Webhook-Event-Id, (2) JSON stringified version of the response body, and (3) nonce property in that order using the given signing algorithm specified by signAlgorithm.

Example Using Node.js

The following example utilizes express to listen to webhook events, axios to interact with the Ironclad API and the crypto library for handling the verification.

require('dotenv').config();
const express = require('express');
const app = express();
const axios = require('axios').default;
const port = 3001;

// Check for crypto module before using.
// https://nodejs.org/docs/latest-v14.x/api/crypto.html#crypto_determining_if_crypto_support_is_unavailable
let crypto;
try {
  crypto = require('crypto');
} catch (err) {
  console.log('Crypto support is disabled!');
}

// Be sure to store your API securely!
const apiToken = process.env.API_TOKEN;

// Your host URL may vary based on the implementation.
const hostUrl = (process.env.HOST_URL ? process.env.HOST_URL : 'ironcladapp');
const apiUrl = `https://${hostUrl}.com/public/api/v1`;

app.use(express.json());

/**
 * Caching the verification key.
 *
 * Depending on your volume, it may be optimal to cache
 * the public key in-memory/locally to avoid calling the
 * Ironclad API for every webhook event.
 *
 * You may want to consider checking to see if the key has
 * changed and update at your discretion.
 *
 * Additionally, you'll want to make sure the value still
 * exists before attempting to validate the webhook.
 */
let webhookVerificationKey;

// Retrieve the webhook verification key.
const retrieveVerificationKey = async () => {
  const response = await axios.get(
    `${apiUrl}/webhooks/verification-key`, {
      headers: {
        'Authorization': `Bearer ${apiToken}`,
      },
    });
  if (!response.data) throw new Error(
    'Did not receive the webhook verification key.',
  );
  return response.data;
};

// Retrieve from cache or API as needed.
const handleVerificationKeyCache = async () => {
  if (webhookVerificationKey) {
    console.log(`Reusing key.`);
    return webhookVerificationKey;
  } else {
    console.log('Retrieving key.');
    const verificationKey = await retrieveVerificationKey();
    if (verificationKey) webhookVerificationKey = verificationKey;
    return verificationKey;
  }
};

const isValidWebhook = async (eventId, verificationPayload, body) => {
  if (!crypto) throw new Error('The Crypto module is not loaded!');
  const publicKey = await handleVerificationKeyCache();

  const parsedHeader = JSON.parse(verificationPayload);
  const {encoding, nonce, signAlgorithm, signature} = parsedHeader;

  const isValid = crypto
    .createVerify(signAlgorithm)
    .update(eventId)
    .update(JSON.stringify(body))
    .update(nonce)
    .verify(publicKey, signature, encoding);
  return isValid;
};

app.post('/webhook', async (req, res) => {
  // Immediately send 200 to webhook service.
  res.sendStatus(200);
  try {
    // Retrieve necessary header values for webhook validation.
    const eventId = req.header('X-Ironclad-Webhook-Event-Id');
    const webhookPayload = req.header('X-Ironclad-Webhook-Verification');

    const isValid = await isValidWebhook(eventId, webhookPayload, req.body);
    if (isValid) {
      console.log(
        `The webhook was valid! Now we can process the payload as wanted.`
      );
    } else {
      console.log(
        `The webhook was not signed correctly. You may want to retry the check.`
      );
    }
  } catch (err) {
    // Be sure to handle your errors appropriately!
    console.log(err);
  }
});

app.listen(port, () =>
  console.log(
    `Web server started on port ${port}`,
  ),
);