Webhook signature types
Webhook signatures ensure that messages sent to your endpoint are truly from our platform and have not been tampered with.
We currently provide three versions of webhook signatures. However, version 1 (v1) will be deprecated on May 1, 2025 and, from that date, all v1 webhooks will be forcibly migrated to version 3 (v3). Version 2 (v2) will not be affected.
Version 1 (v1) – Deprecated on May 1, 2025
- Uses a simpler authentication method.
- Will be officially deprecated on May 1, 2025; all existing v1 webhooks will be automatically switched to v3 on that date.
- We recommend migrating to v3 as soon as possible.
Version 2 (v2)
- Improves upon v1 with additional security features and hashing.
- Remains fully functional and is not slated for deprecation.
- May still be used if your integration relies on the v2 approach.
- However, for new integrations, we strongly encourage moving to v3.
Version 3 (v3)
- Uses an RSA private key to encrypt a checksum of the message (in addition to standard hashing).
- Recommended for all new integrations as it provides the highest level of security and will be the default version after v1’s deprecation.
- When a webhook is delivered, the headers
x-api-key
andx-api-signature
are included:- x-api-key: Identifies which service account was used to generate the signature.
- x-api-signature: Contains the RSA-encrypted checksum of the message body.
How version 3 works
- Our platform computes a checksum (e.g., using SHA-256) on a flattened version of the JSON payload.
- That checksum is then encrypted with the private RSA key associated with your service account.
- The encrypted string is placed in the
x-api-signature
header (base64-encoded). - On your side, you:
- Decode x-api-signature from base64.
- Use your matching private RSA key to decrypt the signature and obtain the original checksum.
- Generate your own checksum of the received JSON body.
- Compare the decrypted checksum with your locally computed checksum:
- If they match, the webhook is valid.
- If they do not match, reject the request as invalid.
NoteIf
x-api-key
is missing or empty, no signature check is performed because it indicates no active service account is available.
Example Code for Version 3
Below are simple PHP and JavaScript examples that demonstrate the signature validation process for v3. These snippets show how to flatten the JSON payload, compute the checksum, and compare it with the decrypted signature:
PHP example for V3
<?php
require __DIR__ . '/vendor/autoload.php';
use phpseclib3\Crypt\RSA;
// string utils
function UnwindObject($array, &$iterator=1) {
$result = array();
foreach($array as $key => $value) {
if (is_object($value)) {
$value = (array)$value;
}
if(is_array($value)) {
$result = $result + UnwindObject($value, $iterator);
}
else {
$key = strtolower($key . '_' . (string)$iterator);
$result[$key] = is_bool($value)? $value ? 'true' : 'false' : $value;
}
$iterator++;
}
return $result;
}
function SortByKeys($array) {
ksort($array, SORT_NATURAL);
return $array;
}
function FlatString($array) {
$sorted = SortByKeys(UnwindObject($array));
return join(array_values($sorted));
}
// Webhook data
// $sign = '';
$webhook = json_decode(file_get_contents('./webhook.json'), false);
// XXX: or use base64_decode
// $key = RSA::loadFormat('PKCS1', file_get_contents('../private-key.pem'), $password = false);
$calculated = hash('sha256', FlatString($webhook));
$decrypted = $key->decrypt(base64_decode($sign));
// var_dump(FlatString($webhook));
// print_r(SortByKeys(UnwindObject($webhook)));
// print_r(UnwindObject($webhook));
// print_r(FlatString($webhook));
// print_r($decrypted && $decrypted === $calculated ? 'Valid!' : 'invalid');
print_r($calculated);
JavaScript example for V3 and V2
const fs = require('fs');
const crypto = require('crypto');
// XXX use for v2
// const UnwindObject = (data) => {
// return Object.entries(data)
// .map((item) =>
// typeof item[1] === 'object'
// ? UnwindObject(item[1])
// : Object.fromEntries([item]),
// )
// .flat();
// };
const UnwindObjectAppend = (
data,
indexer,
) => {
if (!indexer || !indexer.value) {
indexer = { value: 0 };
}
return Object.entries(data)
.map((item) => {
if (typeof item[1] === 'object') {
return UnwindObjectAppend(item[1], indexer);
}
indexer.value++;
const key = (item.at(0) + '_' + indexer.value).toLocaleLowerCase();
return Object.fromEntries([[key, item.at(1)]]);
})
.flat();
};
// XXX: use for v2
// const SortByKeys = (data) => {
// return data.sort((a, b) =>
// (Object.keys(a).at(0) || 0) > (Object.keys(b).at(0) || 0) ? 1 : -1,
// );
// };
const SortByKeysNatural = (data) => {
return data.sort(
(a, b) => {
const _a = Object.keys(a).at(0) || "";
const _b = Object.keys(b).at(0) || "";
return _a.localeCompare(_b, undefined, {
numeric: true,
caseFirst: 'upper'
});
}
);
};
// XXX: use for v2
// const FlatString = (data) => {
// return SortByKeys(UnwindObject(data))
// .map((item) => Object.values(item).at(0))
// .join('');
// };
const FlatStringAppendNatural = (data) => {
return SortByKeysNatural(UnwindObjectAppend(data))
.map((item) => Object.values(item).at(0))
.join('');
};
const sign = "";
const body = {};
const account = JSON.parse(fs.readFileSync("../credentials.json").toString());
const decrypted = crypto.privateDecrypt(
{
key: Buffer.from(account.private_key, "base64").toString(),
passphrase: "",
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
Buffer.from(sign, "base64")
).toString()
const calculated = crypto
.createHash("sha256")
.update(FlatStringAppendNatural(body))
.digest("hex");
// console.log(FlatStringAppendNatural(body));
// console.log(SortByKeysNatural(UnwindObjectAppend(body)));
console.log("decrypted", decrypted);
console.log("calculated", calculated);
console.log("Valid?", calculated == decrypted? 'Yes!' : 'No');
Updated 7 months ago