Webhook signature verification
After confirming that the webhook endpoint is working as expected, secure the connection by implementing webhook signature verification.
This is especially important to verify that the webhook request was generated by Fliqa, and that it did not originate from a server impersonating Fliqa.
Signature
Each webhook POST request contains an X-Fliqa-Signature header in the following format:
t=1696242888,v=1e49bf8db353a19c3924822601452a7aacfd4f24487b7d4568bc9c5f9ac7386b
Alternatively, if a secret was regenerated within the last 24 hours:
t=1696242888,v=1e49bf8db353a19c3924822601452a7aacfd4f24487b7d4568bc9c5f9ac7386b,v0=f8f9c3507c01c2582241ca1b70c93aebea808757be800afbf374aeb402d4a101
where:
t= epoch timestamp in seconds: when the webhook signature was created to prevent replay attacksv= the signature calculated from the secret, timestamp, webhook URL, and request body (JSON payload)v0= the signature calculated using the previous secret, if the secret was regenerated within the last 24 hours
The signature is calculated as follows:
- the timestamp, webhook URL, and JSON request body are combined into a single string:
timestamp.webhookUrl.body - the string is signed using the webhook secret and the
HmacSHA256algorithm - the result is encoded as a hexadecimal string
info
Use this online tool to check your algorithm matches ours!
Verification example
timestamp = 1698224457
URL = https://my.server.url/webhook
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 webhook call","description":"Manual webhook 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 examples
- 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 the secret has not yet been updated on this side, while it was already updated on the sender side
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.');
}
}