Webhook signature (HMAC)
When Kindly sends data to external services (e.g. when triggering a webhook to a service owned by you), the payload will be authenticated with a hash-based message authentication code (HMAC).
The key used to create the HMAC is a shared secret, and you verify it by running the algorithm yourself with the payload and the key to re-create the HMAC.
This verifies that the sender of the message knows the secret key.
Signed requests
Webhooks, and Actions with Sign requests with Kindly HMAC turned on, carry the same signature headers. Use them to check that a request came from Kindly and that it isn't an old request being replayed. One verifier handles both.
Kindly-Signature follows the same pattern as Stripe's webhook signatures, so if you've verified those, this will look familiar. The difference is that our signed string also covers the bot, the method and the path, so GET requests are protected too.
The key depends on what sends the request:
Request | Key |
|---|---|
Dialogue webhooks and signed Actions | The bot's signing key, under Settings › General › Security |
External handover webhooks | The API key in the bot's handover settings |
App webhooks | The app's API key |
Headers
Header | Example | Meaning |
|---|---|---|
Kindly-Signature | t=1790841600,v1=3EMuJf6s… | The signing time (Unix seconds) and one or more signatures |
Kindly-Request-Id | 9f1c2b7e4d0a4c3e8b6f5a2d1e0c9b8a | Unique per request. Use it to ignore repeats. |
Kindly-Bot-Id | 42 | The bot that sent the request |
Kindly-HMAC | HhSFTAQ8… | The original signature, of the body only. Sent on every request that has a body. |
Verify Kindly-Signature. Kindly-HMAC stays so existing verifiers keep working, but it doesn't cover the URL and has no timestamp, so it can't protect against replays or GET requests.
If your endpoint redirects a webhook, the request that arrives at the new location was signed for the original path, so only Kindly-HMAC verifies there.
What is signed
The signature is an HMAC-SHA256 with your signing key, base64-encoded, over this string:
v1:<t>:<bot id>:<METHOD>:<path and query>:<body>- <t> is the t= value from Kindly-Signature.
- <bot id> is the Kindly-Bot-Id header.
- <METHOD> is the HTTP method in capitals, such as GET or POST.
- <path and query> is the request target exactly as received, such as /v1/refunds?order=7. Don't decode or reorder it. If a gateway in front of your API rewrites the path, verify against the original one.
- <body> is the raw request body bytes, before any JSON parsing. For a GET it's empty, so the string ends with :.
The host isn't part of the signature.
How to verify
- Read t and every v1 value from Kindly-Signature. There can be more than one v1, for example while a key is being replaced, so accept the request if any of them matches.
- Reject the request if t is more than 5 minutes away from your clock.
- Build the signed string and compute the HMAC-SHA256 with your signing key, base64-encoded.
- Compare it with each v1 using a constant-time comparison.
- Optionally, remember Kindly-Request-Id for 5 minutes and reject a request whose id you've already seen.
Python
import base64, hashlib, hmac, time
def verify(key: str, headers: dict, method: str, target: str, body: bytes, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in headers["Kindly-Signature"].split(",")]
t = int(next(v for k, v in parts if k == "t"))
if abs(time.time() - t) > tolerance:
return False
signed = f"v1:{t}:{headers['Kindly-Bot-Id']}:{method.upper()}:{target}:".encode() + body
expected = base64.b64encode(hmac.new(key.encode(), signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")Node.js
const crypto = require('crypto');
function verify(key, headers, method, target, body, tolerance = 300) {
const parts = headers['kindly-signature'].split(',').map((p) => p.split(/=(.*)/s, 2));
const t = Number(parts.find(([k]) => k === 't')[1]);
if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const signed = Buffer.concat([
Buffer.from(`v1:${t}:${headers['kindly-bot-id']}:${method.toUpperCase()}:${target}:`),
body, // a Buffer with the raw body
]);
const expected = crypto.createHmac('sha256', key).update(signed).digest();
return parts
.filter(([k]) => k === 'v1')
.some(([, v]) => {
const given = Buffer.from(v, 'base64');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}Worked examples
With the signing key test-key and bot id 42:
Request | Signed string | v1 |
|---|---|---|
POST /v1/refunds?order=7 with body {"amount":100}, t=1790841600 | v1:1790841600:42:POST:/v1/refunds?order=7:{"amount":100} | 3EMuJf6sfrrd9qtEHq5q8SrBu5WPay60bK0unAo4WG8= |
GET /v1/members/me?x=%20y, t=1790841600 | v1:1790841600:42:GET:/v1/members/me?x=%20y: | wLhzg5kPeinsNpZDdNEe29ftTaoso3sFWiNpqgsGr1o= |
You can reproduce them with:
printf '<signed string>' | openssl dgst -sha256 -hmac test-key -binary | base64Regenerating the key
Regenerating the signing key under Settings › General › Security takes effect immediately for webhooks and signed Action requests alike. Update your systems at the same time.
The original signature: Kindly-HMAC
Kindly-HMAC signs the body only. It's still sent on every request that has a body, so existing verifiers keep working, but new integrations should verify Kindly-Signature, described above. The rest of this page describes Kindly-HMAC.
Secret key
The secret key used in the algorithm depends on which kind of request is being sent, and you can find your keys in the Kindly Platform, under your Workspace Settings -> General -> Security.
If you suspect that the key might have been compromised, you can revoke it and issue a new key in the web interface.
Algorithm
The HMAC is created with the HMAC_SHA256 algorithm, then encoded in base 64.
The HMAC is attached to the request in the Kindly-HMAC header. You should also read the value of the Kindly-HMAC-algorithm request header and verify that it matches HMAC-SHA-256 (base64 encoded). If it becomes necessary to change the algorithm in the future, this value will change to let you know.
request.headers["kindly-hmac"] = base64_encode(hmac_sha256(request.body, secret_key))
request.headers["kindly-hmac-algorithm"] = "HMAC-SHA-256 (base64 encoded)"Examples
Verifying a HMAC-SHA256-value should be quite simple by using a crypto library in your preferred language.
JS/Node
const CryptoJS = require('crypto-js');
function verifyHmac(
receivedHmac, // received in Kindly-HMAC request header
dataString, // received in request body (stringified JSON)
key // shared secret, known by your service
) {
const generatedHmac = CryptoJS.HmacSHA256(dataString, key);
const generatedHmacBase64 = CryptoJS.enc.Base64.stringify(generatedHmac);
return generatedHmacBase64 === receivedHmac;
}
// test the function
const receivedHmac = "uEeD0Q7eW9btdx6LFvvlpwkzQBWdbknsQkg1C27Cx7Q=";
const dataString = '{"foo":1,"bar":2}';
const key = "examplekey";
if (!verifyHmac(receivedHmac, dataString, key)) {
throw new Error("HMAC did not authenticate");
}Python
The hashing algorithm works on bytes, so we make sure to do any necessary conversions from unicode str to bytes.
import base64
import hashlib
import hmac
def verify_kindly_hmac(headers: dict, request_data: bytes, secret_key: str):
# Note: HTTP headers are case insensitive
alg = headers.get('kindly-hmac-algorithm')
mac = headers.get('kindly-hmac')
if not (alg and mac):
print("HMAC was not provided")
return False
if alg != "HMAC-SHA-256 (base64 encoded)":
print("Unexpected HMAC algorithm")
return False
msg = request_data
key = secret_key.encode('utf-8')
received_hmac_b64 = mac.encode('utf-8')
generated_hmac = hmac.new(key=key, msg=msg, digestmod=hashlib.sha256).digest()
generated_hmac_b64 = base64.b64encode(generated_hmac)
match = hmac.compare_digest(received_hmac_b64, generated_hmac_b64)
if not match:
print('HMACs did not match: {} {}'.format(received_hmac_b64, generated_hmac_b64))
return False
return TrueC# / .NET 7
Depending on your setup you may need to rewind the request body stream after consuming it (see code comment).
public async Task<bool> VerifyHash(HttpRequest request, string signingKey)
{
if (!request.Headers.TryGetValue("kindly-hmac-algorithm", out var hmacAlgorithm) || hmacAlgorithm != "HMAC-SHA-256 (base64 encoded)")
{
throw new HttpRequestException("Unexpected HMAC algorithm", null, System.Net.HttpStatusCode.BadRequest);
}
if (!request.Headers.TryGetValue("kindly-hmac", out var receievedHmac) || string.IsNullOrEmpty(receievedHmac))
{
throw new HttpRequestException("HMAC was not provided", null, System.Net.HttpStatusCode.BadRequest);
}
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(signingKey));
var hash = await hasher.ComputeHashAsync(request.Body);
// Uncomment the line below to rewind the stream if request buffering is not enabled
// request.Body.Seek(0, SeekOrigin.Begin);
var hash_b64 = Convert.ToBase64String(hash);
return receievedHmac == hash_b64;
}