Skip to main content

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.

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'));
}

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.