Hooks signature verification
After confirming that the webhook endpoint is working as expected, secure the connection by implementing web hook verification.
This is especially important to verify that Fliqa generated a webhook request and that it didn’t come from a server acting like Fliqa.
Signature
Each web hook POST request call will contain a X-Fliqa-Signature header in the following format:
t=1696242888,v=1e49bf8db353a19c3924822601452a7aacfd4f24487b7d4568bc9c5f9ac7386b
or alternatively in case a new secret was regenerated withing 24 hours
t=1696242888,v=1e49bf8db353a19c3924822601452a7aacfd4f24487b7d4568bc9c5f9ac7386b,v0=f8f9c3507c01c2582241ca1b70c93aebea808757be800afbf374aeb402d4a101
where:
- t = epoch timestamp in seconds - when hook signature was created to prevent replay attacks
- v = the verification signature that is calculated from the secret, timestamp, hookUrl and body (JSON payload)
- v0 = the old verification signature that is calculated from the old secret if secret was regenerated altered in last 24 hours
The verification is calculated in the following way:
- time, hook URL and payload
JSONbody are combined into a single string like this:time.URL.body - this string is then signed with the hook
secretusing theHmacSHA256algorithm - output is encoded into hex string format
info
Use this online tool to check your algorithm matches ours!
Verification example
timestamp = 1698224457
URL = https://my.server.url/hook
body = {"paymentId":"00000000000000000000000000000000","status":"successful","created":"2023-10-25T08:59:27.327069Z","modified":"2023-10-25T09:00:57.327093Z","paymentData":null,"providerId":"hooked-bank","name":"Test hook call","description":"Manual hook trigger","pointOfSaleId":"fb6e0508e9714066b45ea598acfe2df4","sourceIban":"SI56010000000100090","sourceName":"Janez Novak","targetIban":"SI56044030255412331","targetName":"Fliqa top up","amount":1.23,"currency":"EUR","country":"SI","data":[{"key":"customer_id","value":"0000-00-0000"}],"locale":"en"}
secret = 0ddf43e8-43fa-46ce-8bb0-c6aab3c0b511
Should produce the following signature:
t=1698224457,
v=0a492fc70a2bf572e9eb05e66f8e490200ad6a68809d5501e23511efaf1814de
Verification implementation example
- Java
- PHP
/**
See full implementation example at:
https://github.com/fliqa-io/examples/blob/main/java/src/main/java/io/fliqa/example/webhook/WebHookUtils.java
**/
package io.fliqa.example.webhook;
public class WebHookUtils {
private static final String DIGEST = "HmacSHA256";
public static boolean checkSignature(String signature, String secret, String oldSecret, String hookUrl, String body) {
// Split signature to t={time},v={verification},v0={old_verification}
String[] timeAndSignature = signature.split(",");
if (timeAndSignature.length < 2 || timeAndSignature.length > 3) {
throw new IllegalArgumentException(String.format("Invalid signature, expected time and verification but got: '%s'!", signature));
}
Long time = getSignatureTime(timeAndSignature);
String oldVerification = null;
String oldCompare = null;
if (timeAndSignature.length == 3) { // there is the old signature present (double check)
oldVerification = getOldSignatureVerification(timeAndSignature);
oldCompare = sign(oldSecret, time.toString(), hookUrl, body);
}
String verification = getSignatureVerification(timeAndSignature);
String compare = sign(secret, time.toString(), hookUrl, body);
return verification.equals(compare) || (oldVerification != null && oldVerification.equals(oldCompare));
}
protected static String sign(String secret, String time, String hookUrl, String content) {
String input = String.format("%s.%s.%s", time, hookUrl, content);
try {
Mac mac = Mac.getInstance(DIGEST);
mac.init(new SecretKeySpec(secret.getBytes(), DIGEST));
return toHexString(mac.doFinal(input.getBytes()));
} catch (InvalidKeyException | NoSuchAlgorithmException e) {
// Failed to create SHA signature!
return "";
}
}
public static String toHexString(byte[] arg) {
return String.format("%x", new BigInteger(1, arg));
}
}
class WebHookUtils
{
private const DIGEST = 'sha256';
public static function checkSignature(string $signature, string $secret, string $oldSecret, string $hookUrl, string $body): bool
{
// Split signature to t={time},v={verification},v0={old_verification}
$parts = explode(',', $signature);
if (count($parts) < 2 || count($parts) > 3) {
throw new InvalidArgumentException(sprintf(
"Invalid signature, expected time and verification but got: '%s'!",
$signature));
}
$time = self::getSignatureTime($parts);
$oldVerification = null;
$oldCompare = null;
if (count($parts) === 3) {
$oldVerification = self::getOldSignatureVerification($parts);
$oldCompare = self::sign($oldSecret, $time, $hookUrl, $body);
}
$verification = self::getSignatureVerification($parts);
$compare = self::sign($secret, $time, $hookUrl, $body);
// It might be that we have not updated the secret jet, but on the other side it was already updated
return $verification === $compare || $oldVerification === $compare || $verification === $oldCompare || $oldVerification === $oldCompare;
}
protected static function sign(string $secret, string $time, string $hookUrl, string $content): string
{
$input = sprintf('%s.%s.%s', $time, $hookUrl, $content);
return hash_hmac(self::DIGEST, $input, $secret);
}
protected static function getSignatureTime(array $parts): string
{
foreach ($parts as $part) {
if (str_starts_with($part, 't='))
return substr($part, 2);
}
throw new InvalidArgumentException('Missing time (t=) in signature.');
}
protected static function getSignatureVerification(array $parts): string
{
foreach ($parts as $part) {
if (str_starts_with($part, 'v='))
return substr($part, 2);
}
throw new InvalidArgumentException('Missing verification (v=) in signature.');
}
protected static function getOldSignatureVerification(array $parts): string
{
foreach ($parts as $part) {
if (str_starts_with($part, 'v0='))
return substr($part, 3);
}
throw new InvalidArgumentException('Missing old verification (v0=) in signature.');
}
}