Onderwijsregio APIGuideAPI ReferenceChangelog
Status

Callback Signatures

Learn how to verify that requests to your callback endpoint are sent by the Mutation Engine.

It is important that your callback endpoint rejects requests from untrusted sources. You should verify the signature included with each callback to ensure it originated from the Mutation Engine, and have not been tampered with in transit

Overview

Mutation callbacks are delivered asynchronously via HTTP POST. Because callback URLs are user-provided and therefore untrusted, every callback request is cryptographically signed.

Signature verification allows you to confirm that:

  • The callback originated from the Mutation Engine

  • The request body and URL were not altered in transit

  • The request is not a replay of a previous callback

Whether or not you verify signatures in productions integrations is up to you, but we strongly recommend doing so.

Callback Request Shape

Each callback request has the following properties:

  • Method: POST

  • Body: JSON (mutation result payload)

  • Headers: Signature-related metadata (described below)

Signed Headers

Every callback request includes these HTTP headers:

  • x-mutationengine-timestamp

  • x-mutationengine-nonce

  • x-mutationengine-signature

There is no key identifier header. All callbacks currently use a single region-specific signing secret.

What Is Signed

The signature covers the following inputs:

  • The callback URL path and query string

  • The raw JSON request body

  • The timestamp

  • The nonce

Any change to any of these values will invalidate the signature.

Signature Format

The signature header has the form:

x-mutationengine-signature: v2=<base64-hmac>

Where:

  • v2 is the signature version

  • The value after v2= is a Base64-encoded HMAC-SHA256 digest

  • The HMAC secret is your region-specific callback signing secret

Retrieving Your Signing Secret

Your callback signing secret can be retrieved using the /auth/regions/me endpoint.

Signing secrets are region-scoped, not API-key–scoped. If you receive callbacks from multiple regions, you must verify them using the corresponding regional secret.

Canonical String

To verify a callback, you must reconstruct the canonical string-to-sign exactly as it was produced by the Mutation Engine.

<timestamp>
<nonce>
<path_and_query>
<body_sha256_hex>

Important details:

  • Fields are separated by newline characters (\n)

  • A trailing newline is required

  • There is no version line in the canonical string

  • <body_sha256_hex> is the lowercase hex SHA-256 hash of the raw JSON body

Field Definitions

  • <timestamp>
    Value of x-mutationengine-timestamp (Unix time in milliseconds)

  • <nonce>
    Value of x-mutationengine-nonce (UUID v4)

  • <path_and_query>
    Callback URL path plus query string (e.g. /webhooks/engine-callback?foo=bar)

  • <body_sha256_hex>
    SHA-256 hash of the raw request body, hex-encoded

Example Canonical String

1766494092286
550e8400-e29b-41d4-a716-446655440000
/webhooks/mutation
a9c2e4b7f4c8...

Verification Steps

To verify a callback request:

  1. Read the raw request body before parsing JSON

  2. Compute the SHA-256 hash of the raw body (hex-encoded)

  3. Reconstruct the canonical string exactly

  4. Compute an HMAC-SHA256 over the canonical string using your signing secret

  5. Base64-encode the result

  6. Remove the v2= prefix from the received signature

  7. Compare the two values using a constant-time comparison

  8. Reject the request if verification fails

Replay Protection

The timestamp and nonce are included to mitigate replay attacks. We recommend that you:

  • Reject callbacks with timestamps older than 15 minutes

  • Track recently seen nonces and reject duplicates (optional but recommended)

Failure Behavior

If your callback endpoint responds with a non-2xx status code:

  • The callback will be retried automatically

  • Retries use exponential backoff

  • Callback delivery failures do not affect mutation execution

Callbacks are a delivery mechanism only. A mutation may succeed even if all callback attempts ultimately fail.

Examples

The examples below mirror the Mutation Engine’s signing logic, including:

  • Canonical string layout

  • SHA-256 body hashing

  • Base64-encoded HMAC

  • Required trailing newline

import crypto from 'crypto'

export function verifyCallbackSignature(
  req: {
    headers: Record<string, string | undefined>
    rawBody: string
    url: string
  },
  signingSecret: string
): boolean {
  const timestamp = req.headers['x-mutationengine-timestamp']
  const nonce = req.headers['x-mutationengine-nonce']
  const signatureHeader = req.headers['x-mutationengine-signature']

  if (!timestamp || !nonce || !signatureHeader) return false
  if (!signatureHeader.startsWith('v2=')) return false

  const receivedSignature = signatureHeader.slice(3)

  const url = new URL(req.url)
  const pathAndQuery = `${url.pathname}${url.search}`

  const bodyHashHex = crypto
    .createHash('sha256')
    .update(req.rawBody, 'utf8')
    .digest('hex')

  const canonical = [
    timestamp,
    nonce,
    pathAndQuery,
    bodyHashHex,
    ''
  ].join('\n')

  const expectedSignature = crypto
    .createHmac('sha256', signingSecret)
    .update(canonical, 'utf8')
    .digest('base64')

  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(receivedSignature)
  )
}

Always use a constant-time comparison (timingSafeEqual). Never compare signatures using ===.

Usage Notes

  • Always read the raw request body (e.g. php://input) before decoding JSON

  • Header names may be normalized by your framework—ensure you read the correct values

  • Reject the request immediately if verification returns false

  • Use a timestamp freshness check and (optionally) nonce tracking for replay protection

<?php

/**
 * Verify a Mutation Engine callback signature.
 *
 * @param array  $headers       Associative array of HTTP headers (lowercased keys recommended)
 * @param string $rawBody       Raw request body (exact bytes received)
 * @param string $requestUrl    Full request URL (as received by your server)
 * @param string $signingSecret Region-specific callback signing secret
 *
 * @return bool True if the signature is valid
 */
function verifyCallbackSignature(
    array $headers,
    string $rawBody,
    string $requestUrl,
    string $signingSecret
): bool {
    $timestamp = $headers['x-mutationengine-timestamp'] ?? null;
    $nonce     = $headers['x-mutationengine-nonce'] ?? null;
    $signature = $headers['x-mutationengine-signature'] ?? null;

    if (!$timestamp || !$nonce || !$signature) {
        return false;
    }

    if (!str_starts_with($signature, 'v2=')) {
        return false;
    }

    $receivedSignature = substr($signature, 3);

    $url = parse_url($requestUrl);
    $path = $url['path'] ?? '';
    $query = isset($url['query']) ? '?' . $url['query'] : '';
    $pathAndQuery = $path . $query;

    $bodyHashHex = hash('sha256', $rawBody);

    $canonical =
        $timestamp . "\n" .
        $nonce . "\n" .
        $pathAndQuery . "\n" .
        $bodyHashHex . "\n";

    $expectedSignature = base64_encode(
        hash_hmac('sha256', $canonical, $signingSecret, true)
    );

    // Constant-time comparison
    return hash_equals($expectedSignature, $receivedSignature);
}

Usage Notes

  • Read the raw request body as bytes (do not re-serialize JSON)

  • Normalize or lowercase header keys before accessing them

  • Always use hmac.compare_digest for constant-time comparison

  • Reject requests that fail verification

  • Combine with timestamp freshness checks and optional nonce tracking for replay protection

import base64
import hashlib
import hmac
from urllib.parse import urlparse


def verify_callback_signature(
    headers: dict,
    raw_body: bytes,
    request_url: str,
    signing_secret: str,
) -> bool:
    """
    Verify a Mutation Engine callback signature.

    :param headers: Request headers (lowercased keys recommended)
    :param raw_body: Raw request body (exact bytes received)
    :param request_url: Full request URL
    :param signing_secret: Region-specific callback signing secret
    :return: True if the signature is valid
    """
    timestamp = headers.get("x-mutationengine-timestamp")
    nonce = headers.get("x-mutationengine-nonce")
    signature = headers.get("x-mutationengine-signature")

    if not timestamp or not nonce or not signature:
        return False

    if not signature.startswith("v2="):
        return False

    received_signature = signature[3:]

    parsed = urlparse(request_url)
    path_and_query = parsed.path
    if parsed.query:
        path_and_query += "?" + parsed.query

    body_hash_hex = hashlib.sha256(raw_body).hexdigest()

    canonical = (
        f"{timestamp}\n"
        f"{nonce}\n"
        f"{path_and_query}\n"
        f"{body_hash_hex}\n"
    )

    expected_signature = base64.b64encode(
        hmac.new(
            signing_secret.encode("utf-8"),
            canonical.encode("utf-8"),
            hashlib.sha256,
        ).digest()
    ).decode("ascii")

    return hmac.compare_digest(expected_signature, received_signature)