Webhooks Signature

MoneyHash can sign all webhook events sent to your endpoints with a signature. This signature appears in each event's MoneyHash-Signature header. It allows you to verify that the events were sent by MoneyHash rather than a third party.

Our Signature verification makes use of the Hash-based Message Authentication Code (HMAC) for authenticating and validating webhooks with (SHA256). The result HMAC is calculated using a secret key and the event payload.

Verifying signatures

The MoneyHash-Signature header included in each webhook event contains a timestamp and the current version. The timestamp is prefixed by t= and the version is prefixed by v=

Our Current signature versions are 1, 2 and 3

Header sample

t=1698744604,v1=5e92be4afca58c407f59c71e763021caf0df4140e79b36e53f6201aa99e29edc,v2=23021d841c21af5e7deb1f5b79f13dcf2cdefab55124aac78460fd884c864ecb,v3=5ae747e562f95b621859f7846edf6b3546e02dca4ad53d26bc788535d2f4660b

Header information

The header will contain t for time and v for the version:

  • t: the timestamp this request was created
  • v: the current version of signature you will validate.
    Currently, we are passing v1 , v2 and v3 and the version used to allow us to change the signature calculation in the future, your version should stay the same (For backward compatibility)

🚧

Note

MoneyHash-Signature header is case-sensitive, so make sure to handle it properly.


Verification steps

❗️

Warning

Please make sure to use the latest signature version to stay up to date with our enhancements.


Signature v3

Step 1 Extract the signature and the time from the header

timestamp = "1698744604"
signature = "5ae747e562f95b621859f7846edf6b3546e02dca4ad53d26bc788535d2f4660b"

Step 2: Get your organization signature key

You will need to get your organization secret key to pass it to HMAC function to be used as your secret key, to get this key you can set GET request to /api/v1/organizations/get-webhook-signature-key/ and pass your account API key as header

curl --location --request GET 'https://web.moneyhash.io/api/v1/organizations/get-webhook-signature-key/' \
--header 'X-Api-Key: YOUR_ACCOUNT_API_KEY'
{
    "status": {
        "code": 200,
        "message": "success",
        "errors": []
    },
    "data": {
        "webhook_signature_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    },
    "count": 1,
    "next": null,
    "previous": null
}

📔

Signature key doesn't change, so you can store it as an environment variable instead of calling get signature endpoint on each signature verification

Step 3: Calculate the signature

import hashlib
import hmac

secret = "<your-organization-webhook-secret>"
timestamp = "<request-timestamp>"
decoded_body = base64.b64encode(request.body).decode('utf-8')

to_sign = f"{decoded_body}{timestamp}".encode('utf-8')

signature = hmac.new(
    secret.encode(),
    to_sign,
    digestmod=hashlib.sha256,
)

calculated_signature = signature.hexdigest()
const crypto = require('crypto');

const secret = '<your-organization-webhook-secret>';
const timestamp = '<request-timestamp>';
const decoded_body = Buffer.from(request.body).toString('base64');

const to_sign = Buffer.from(decoded_body + timestamp, 'utf-8');

const signature = crypto.createHmac('sha256', secret)
    .update(to_sign)
    .digest('hex');

const calculated_signature = signature;



Signature v2

Step 1: Extract the timestamp from the header

You will need to extract the value of the time by getting the value of t from the header will be 1658594891 in our sample

time = "1658594891"

Step 2: Extract the signature from the header

You will need to extract the value of the signature by getting the value of v1 from the header will be 3b6ed5f3ecadcb1c60d9e7684ac9a80581a55d805d678a074c6fc2fbe5378878 in our sample

signature = "3b6ed5f3ecadcb1c60d9e7684ac9a80581a55d805d678a074c6fc2fbe5378878"

Step 3: Convert the json payload to dictionary and sort it

You will need to convert the json payload to dictionary, and sort this dictionary using lexicographical sort, you should make sure the nested json values are sorted as well after converting it to a dictionary.

After converting the payload to dictionary and sorting it, you should convert the dictionary back to json object and save this json object to use it in next steps.

sorted_payload = json.dumps(
        payload_as_dict, cls=DjangoJSONEncoder, separators=(",", ":"), sort_keys=True
)
function sortJson(jsonObj) {
  if (typeof jsonObj !== "object" || jsonObj === null) {
    return jsonObj; // Return non-objects as is
  }

  if (Array.isArray(jsonObj)) {
    return jsonObj.map((item) => sortJson(item)); // If it's an array, sort its elements recursively
  }

  const sortedKeys = Object.keys(jsonObj).sort(); // Sort the keys

  const sortedObject = {};
  for (const key of sortedKeys) {
    sortedObject[key] = sortJson(jsonObj[key]); // Recursively sort nested objects
  }

  return sortedObject;
}

const sorted_payload = sortJson(payload)

Example:
{
  "type": "transaction.purchase.successful",
  "status_id": "8xgNYLk",
  "intent": {
    "id": "ldLWMZD",
    "created": "2022-12-25 15:45:25.659438+00:00",
    "custom_fields": null,
    "custom_form_answers": null,
    "amount": {
      "value": 50,
      "currency": "USD"
    }
  },
  "account": {
    "id": "YVglAZx"
  },
  "transaction": {
    "type": "payment",
    "id": "a1f90648-aa49-4798-b7cb-07a866009522",
    "created": "2022-12-25 15:45:31.615820+00:00",
    "status": "purchase.successful",
    "billing_data": null,
    "external_action_message": [],
    "operations": [
      {
        "id": "K1ZvzZB",
        "type": "purchase",
        "status": "successful",
        "amount": {
          "value": 50,
          "currency": "USD"
        },
        "statuses": [
          {
            "id": "8xgNYLk",
            "value": "successful",
            "created": "2022-12-25 15:45:31.664841+00:00"
          }
        ]
      }
    ],
    "custom_message": "",
    "method": {
      "id": "k9m63Lb",
      "display_name": "Kashier - Card",
      "service_provider": {
        "id": "ldLWMZD"
      }
    }
  },
  "api_version": "1.1"
}
{
  "account": {
    "id": "YVglAZx"
  },
  "api_version": "1.1",
  "intent": {
    "amount": {
      "currency": "USD",
      "value": 50
    },
    "created": "2022-12-25 15:45:25.659438+00:00",
    "custom_fields": null,
    "custom_form_answers": null,
    "id": "ldLWMZD"
  },
  "status_id": "8xgNYLk",
  "transaction": {
    "billing_data": null,
    "created": "2022-12-25 15:45:31.615820+00:00",
    "custom_message": "",
    "external_action_message": [],
    "id": "a1f90648-aa49-4798-b7cb-07a866009522",
    "method": {
      "display_name": "Kashier - Card",
      "id": "k9m63Lb",
      "service_provider": {
        "id": "ldLWMZD"
      }
    },
    "operations": [
      {
        "amount": {
          "currency": "USD",
          "value": 50
        },
        "id": "K1ZvzZB",
        "status": "successful",
        "statuses": [
          {
            "created": "2022-12-25 15:45:31.664841+00:00",
            "id": "8xgNYLk",
            "value": "successful"
          }
        ],
        "type": "purchase"
      }
    ],
    "status": "purchase.successful",
    "type": "payment"
  },
  "type": "transaction.purchase.successful"
}

Step 4: Remove all spaces and new lines from the payload

You will need to remove all spaces and new lines sent on the event payload, you can achieve that by replacing all spaces and new lines with empty string

payload_without_spaces_or_lines = sorted_payload.replace(" ", "").replace("\n", "")
const payload_without_spaces_or_lines = sorted_payload.replace(/\s+/g, '');

Step 5: Create the hash message to validate

You will need to create the message that will be used to validate the signature by concatenating both the time and the payload

concatenated_msg = f"{payload_without_spaces_or_lines}{time}".encode("utf-8")
const concatenated_msg = Buffer.from(payload_without_spaces_or_lines + time, 'utf-8');

const hmac = crypto.createHmac('sha256', organization_key);
hmac.update(concatenated_msg);

Step 6: Get your organization signature key

You will need to get your organization secret key to pass it to HMAC function to be used as your secret key, to get this key you can set GET request to /api/v1/organizations/get-webhook-signature-key/ and pass your account API key as header

curl --location --request GET 'https://web.moneyhash.io/api/v1/organizations/get-webhook-signature-key/' \
--header 'X-Api-Key: YOUR_ACCOUNT_API_KEY'
{
    "status": {
        "code": 200,
        "message": "success",
        "errors": []
    },
    "data": {
        "webhook_signature_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    },
    "count": 1,
    "next": null,
    "previous": null
}

Step 7: Calculate the signature

Pass the output of the concatenated_msg and your organization api key key to the HMAC function to generate the signature and the mode should be sha256

digest = hmac.new(
    key=organization_key.encode("utf-8"),
    msg=concatenated_msg,
    digestmod=hashlib.sha256,
)
const crypto = require('crypto');

const organization_key = '<YOUR_ORGANIZATION_KEY>';
const concatenated_msg = '<YOU_CONCATENATED_MESSAGE';

const hmac = crypto.createHmac('sha256', organization_key);
hmac.update(concatenated_msg);

const digest = hmac.digest('hex');

Step 8: Compare the signatures

Compare the signature in the header to the expected signature, if they are equal then the event is valid and received from MoneyHash

is_valid = digest.hexdigest() == signature

Signature v1

Step 1: Extract the timestamp from the header

You will need to extract the value of the time by getting the value of t from the header will be 1658594891 in our sample

time = "1658594891"

Step 2: Extract the signature from the header

You will need to extract the value of the signature by getting the value of v1 from the header will be 3b6ed5f3ecadcb1c60d9e7684ac9a80581a55d805d678a074c6fc2fbe5378878 in our sample

signature = "3b6ed5f3ecadcb1c60d9e7684ac9a80581a55d805d678a074c6fc2fbe5378878"

Step 3: Remove all spaces and new lines from the payload

You will need to remove all spaces and new lines sent on the event payload, you can achieve that by replacing all spaces and new lines with empty string

payload_without_spaces_or_lines = payload.replace(" ", "").replace("\n", "")
const payload_without_spaces_or_lines = sorted_payload.replace(/\s+/g, '');

Step 4: Create the hash message to validate

You will need to create the message that will be used to validate the signature by concatenating both the time and the payload

concatenated_msg = f"{payload_without_spaces_or_lines}{time}".encode("utf-8")
const concatenated_msg = Buffer.from(payload_without_spaces_or_lines + time, 'utf-8');

const hmac = crypto.createHmac('sha256', organization_key);
hmac.update(concatenated_msg);

Step 5: Calculate the signature

Pass the output of the concatenatedmsg and your Account API key_ to the HMAC function to generate the signature and the mode should be sha256

digest = hmac.new(
    key=account_api_key.encode("utf-8"),
    msg=concatenated_msg,
    digestmod=hashlib.sha256,
)
const calculatedSignature = hmac.digest('hex');

Step 6: Compare the signatures

Compare the signature in the header to the expected signature, if they are equal then the event is valid and received from MoneyHash

is_valid = digest.hexdigest() == signature
const is_valid = calculatedSignature === signature;


Examples

import json
import hashlib
import hmac
import requests


# Step 1: Extract the timestamp from the header
time = "1697640557"

# Step 2: Extract the signature from the header
signature_v2 = "f02a4404b41aae5535eb8622b02deeda6823893c8582367d22b1688dc44d043d"

# Step 3: Convert the JSON payload to a dictionary and sort it
payload = '{"type":"intent.processed","intent_type":"PAYMENT","data":{"intent_id":"LWWAyzL","intent":{"id":"LWWAyzL","status":"PROCESSED","amount":50,"amount_currency":"USD","type":"Payin","account":"YVglAZx","custom_fields":null,"billing_data":{"first_name":"","last_name":"","email":"","phone_number":""},"active_transaction":{"id":"ebcdadbc-44c4-4814-9c7e-1e62a064bb67","custom_fields":null,"created":"2023-10-18T14:49:12.476917Z","status":"SUCCESSFUL","amount":"50.00","amount_currency":"USD","billing_data":null,"external_action_message":[],"payment_method":"CARD","payment_method_name":"Card","custom_form_answers":null,"custom_message":"","account":"YVglAZx","provider_transaction_fields":{},"provider_signature_match":false},"transactions_history":[{"id":"ebcdadbc-44c4-4814-9c7e-1e62a064bb67","custom_fields":null,"created":"2023-10-18T14:49:12.476917Z","status":"SUCCESSFUL","amount":"50.00","amount_currency":"USD","billing_data":null,"external_action_message":[],"payment_method":"CARD","payment_method_name":"Card","custom_form_answers":null,"custom_message":"","account":"YVglAZx","provider_transaction_fields":{},"provider_signature_match":false}],"method":{"display_name":"Stripe - Card","id":"jgxny9r"},"flow":null,"is_live":false,"created":"2023-10-18T14:49:03.132515Z"}},"api_version":"1.1"}'
payload = json.loads(payload)

# Sort the dictionary
sorted_payload = json.dumps(payload, separators=(",", ":"), sort_keys=True)

# Step 4: Remove all spaces and new lines from the payload
payload_without_spaces_or_lines = sorted_payload.replace(" ", "").replace("\n", "")

# Step 5: Create the hash message to validate
concatenated_msg = f"{payload_without_spaces_or_lines}{time}".encode("utf-8")

# Step 6: Get the organization secret key via API (replace with your actual API key)
api_key = "<YOUR_ACCOUNT_API_KEY>"

key_response = requests.get(
    "https://web.moneyhash.io/api/v1/organizations/get-webhook-signature-key/",
    headers={"X-Api-Key": api_key},
)
if key_response.status_code == 200:
    organization_key = key_response.json()["data"]["webhook_signature_secret"]
else:
    print("Failed to retrieve the organization secret key.")
    exit()

# Step 7: Calculate the signature
digest = hmac.new(
    key=organization_key.encode("utf-8"),
    msg=concatenated_msg,
    digestmod=hashlib.sha256,
)

# Step 8: Compare the signatures
is_valid = digest.hexdigest() == signature_v2

if is_valid:
    print("Signature is valid. The event is received from MoneyHash.")
else:
    print("Signature is not valid. The event may be tampered with or invalid.")

import crypto from "crypto";
import fetch from "node-fetch";

function sortJson(jsonObj) {
  if (typeof jsonObj !== "object" || jsonObj === null) {
    return jsonObj; // Return non-objects as is
  }

  if (Array.isArray(jsonObj)) {
    return jsonObj.map((item) => sortJson(item)); // If it's an array, sort its elements recursively
  }

  const sortedKeys = Object.keys(jsonObj).sort(); // Sort the keys

  const sortedObject = {};
  for (const key of sortedKeys) {
    sortedObject[key] = sortJson(jsonObj[key]); // Recursively sort nested objects
  }

  return sortedObject;
}

// Step 1: Extract the timestamp from the header
const time = "1697640557";

// Step 2: Extract the signature from the header
const signature_v2 =
  "f02a4404b41aae5535eb8622b02deeda6823893c8582367d22b1688dc44d043d";

// Step 3: Convert the JSON payload to an object and sort it
const payload = JSON.parse(
  '{"type":"intent.processed","intent_type":"PAYMENT","data":{"intent_id":"LWWAyzL","intent":{"id":"LWWAyzL","status":"PROCESSED","amount":50,"amount_currency":"USD","type":"Payin","account":"YVglAZx","custom_fields":null,"billing_data":{"first_name":"","last_name":"","email":"","phone_number":""},"active_transaction":{"id":"ebcdadbc-44c4-4814-9c7e-1e62a064bb67","custom_fields":null,"created":"2023-10-18T14:49:12.476917Z","status":"SUCCESSFUL","amount":"50.00","amount_currency":"USD","billing_data":null,"external_action_message":[],"payment_method":"CARD","payment_method_name":"Card","custom_form_answers":null,"custom_message":"","account":"YVglAZx","provider_transaction_fields":{},"provider_signature_match":false},"transactions_history":[{"id":"ebcdadbc-44c4-4814-9c7e-1e62a064bb67","custom_fields":null,"created":"2023-10-18T14:49:12.476917Z","status":"SUCCESSFUL","amount":"50.00","amount_currency":"USD","billing_data":null,"external_action_message":[],"payment_method":"CARD","payment_method_name":"Card","custom_form_answers":null,"custom_message":"","account":"YVglAZx","provider_transaction_fields":{},"provider_signature_match":false}],"method":{"display_name":"Stripe - Card","id":"jgxny9r"},"flow":null,"is_live":false,"created":"2023-10-18T14:49:03.132515Z"}},"api_version":"1.1"}'
);

const sorted_payload = sortJson(payload);

// Step 4: Remove all spaces and new lines from the payload
const payload_without_spaces_or_lines = JSON.stringify(sorted_payload).replace(
  /\s+/g,
  ""
);

// Step 5: Create the hash message to validate
const concatenated_msg = Buffer.from(
  payload_without_spaces_or_lines + time,
  "utf-8"
);

// Step 6: Get the organization secret key via API (replace with your actual API key)
const api_key = "<YOUR_ACCOUNT_API_KEY>";
fetch(
  "https://web.moneyhash.io/api/v1/organizations/get-webhook-signature-key/",
  {
    method: "GET",
    headers: { "X-Api-Key": api_key },
  }
)
  .then((key_response) => {
    if (key_response.status === 200) {
      return key_response.json();
    } else {
      throw new Error("Failed to retrieve the organization secret key.");
    }
  })
  .then((keyData) => {
    const organization_key = keyData.data.webhook_signature_secret;

    // Step 7: Calculate the signature
    const digest = crypto
      .createHmac("sha256", organization_key)
      .update(concatenated_msg.toString())
      .digest("hex");

    // Step 8: Compare the signatures
    const is_valid = digest === signature_v2;

    if (is_valid) {
      console.log("Signature is valid. The event is received from MoneyHash.");
    } else {
      console.log(
        "Signature is not valid. The event may be tampered with or invalid."
      );
    }
  })
  .catch((error) => {
    console.error(error);
  });