Webhook signature verification
The receiving backend verifies the original request bytes with webhookSecret. These examples use standard libraries and do not require the Server SDK. For URL configuration and acknowledgment behavior, see Server webhooks.
Algorithm
signed_payload = UTF8(x-eduskit-timestamp + ".") + raw_request_body_bytes
expected = HMAC_SHA256(UTF8(webhookSecret), signed_payload)
x-eduskit-signature = "v1=" + hex(expected)
The examples accept timestamps within 300 seconds before or after the receiver's current time and compare digests in constant time. This is the receiver's replay-protection policy; the service does not supply a receiver time-window setting. Synchronize the receiving host's clock. An old event can have a recent delivery header timestamp.
Read the body before JSON middleware transforms it. Keep whitespace, newlines, key order and UTF-8 bytes unchanged. The signing secret is used as UTF-8 text; do not hex-decode it even if it looks hexadecimal.
Choose a language
Pass the raw body, x-eduskit-timestamp, x-eduskit-signature and the configured secret to the function. A missing header must be rejected or passed as an empty string. Reject duplicate signature/timestamp headers. The optional explicit time parameter is for deterministic tests; production uses the receiving host's current time.
Node.js expects a Buffer; Python expects bytes; Java and Go use byte arrays; PHP strings hold the raw bytes from php://input. Java targets Java 11+ and PHP targets PHP 8.1+. Java cryptographic failures must produce an error response, not a successful acknowledgment.
- Node.js
- Python
- Java
- Go
- PHP
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(
rawBody,
timestamp,
signature,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
if (
typeof timestamp !== 'string' ||
timestamp.trim() !== timestamp ||
!/^[0-9]{1,16}$/.test(timestamp)
)
return false;
if (
typeof signature !== 'string' ||
signature.length !== 67 ||
!/^v1=[0-9a-fA-F]{64}$/.test(signature)
)
return false;
const sentAt = Number(timestamp);
if (!Number.isSafeInteger(sentAt) || Math.abs(nowSeconds - sentAt) > 300) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.', 'utf8')
.update(rawBody)
.digest();
return timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'));
}
import hashlib
import hmac
import re
import time
def verify_webhook(raw_body, timestamp, signature, secret, now_seconds=None):
if not isinstance(raw_body, bytes) or not isinstance(secret, str) or not secret:
return False
if not isinstance(timestamp, str) or not re.fullmatch(r'[0-9]{1,16}', timestamp):
return False
if not isinstance(signature, str) or not re.fullmatch(r'v1=[0-9a-fA-F]{64}', signature):
return False
now = int(time.time()) if now_seconds is None else now_seconds
if abs(now - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode('utf-8'), timestamp.encode('ascii') + b'.' + raw_body, hashlib.sha256
).digest()
return hmac.compare_digest(expected, bytes.fromhex(signature[3:]))
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class WebhookSignature {
public static boolean verify(byte[] rawBody, String timestamp, String signature, String secret)
throws GeneralSecurityException {
return verify(rawBody, timestamp, signature, secret, Instant.now().getEpochSecond());
}
public static boolean verify(byte[] rawBody, String timestamp, String signature,
String secret, long nowSeconds) throws GeneralSecurityException {
if (rawBody == null || secret == null || secret.isEmpty()) return false;
if (timestamp == null || !timestamp.matches("[0-9]{1,16}")) return false;
if (signature == null || !signature.matches("v1=[0-9a-fA-F]{64}")) return false;
long sentAt = Long.parseLong(timestamp);
if (sentAt < nowSeconds - 300 || sentAt > nowSeconds + 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.US_ASCII));
byte[] expected = mac.doFinal(rawBody);
byte[] supplied = new byte[32];
for (int i = 0; i < supplied.length; i++) {
supplied[i] = (byte) Integer.parseInt(signature.substring(3 + i * 2, 5 + i * 2), 16);
}
return MessageDigest.isEqual(expected, supplied);
}
}
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var timestampPattern = regexp.MustCompile(`^[0-9]{1,16}$`)
var signaturePattern = regexp.MustCompile(`^v1=[0-9a-fA-F]{64}$`)
func Verify(rawBody []byte, timestamp, signature, secret string) bool {
return VerifyAt(rawBody, timestamp, signature, secret, time.Now().Unix())
}
func VerifyAt(rawBody []byte, timestamp, signature, secret string, nowSeconds int64) bool {
if secret == "" || !timestampPattern.MatchString(timestamp) || !signaturePattern.MatchString(signature) {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || sentAt < nowSeconds-300 || sentAt > nowSeconds+300 {
return false
}
supplied, err := hex.DecodeString(signature[3:])
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
return hmac.Equal(mac.Sum(nil), supplied)
}
<?php
declare(strict_types=1);
function verifyWebhook(
string $rawBody,
string $timestamp,
string $signature,
string $secret,
?int $nowSeconds = null
): bool {
if ($secret === '' || preg_match('/\A[0-9]{1,16}\z/', $timestamp) !== 1) {
return false;
}
if (preg_match('/\Av1=[0-9a-fA-F]{64}\z/', $signature) !== 1) {
return false;
}
$now = $nowSeconds ?? time();
if (abs($now - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret, true);
return hash_equals($expected, hex2bin(substr($signature, 3)));
}
After verification
A true result only confirms the signature and time window. Parse the body, check the application and header/body event identifiers, and persist idempotently before acknowledging. Verification failure can return HTTP 401; the platform treats that as a failed delivery and retries, so correct a key mismatch promptly.
Verification functions are available in examples/webhooks/ in the documentation repository. Connect them to your HTTP route and event persistence/deduplication logic.