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.
Inspect the callback request
Each callback request has the following properties:
- Method:
POST - Body: JSON mutation-result payload.
- Headers: Signature-related metadata, described below.
Callbacks use a max timeout of 15 seconds. If your server does not respond within that window, the Mutation Engine will throw an error and retry.
Signed Headers
Every callback request includes these HTTP headers:
x-mutationengine-timestampx-mutationengine-noncex-mutationengine-signaturex-mutationengine-sandbox-enabled
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:
v2is 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.
- The canonical string has no version line.
<body_sha256_hex>is the lowercase hexadecimal SHA-256 hash of the raw JSON body.
Field Definitions
<timestamp>
Value ofx-mutationengine-timestamp(Unix time in milliseconds)<nonce>
Value ofx-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...
Verify a callback
To verify a callback request:
Read the raw body
Read the raw request body before parsing JSON.
Hash and reconstruct
Compute the body’s hex-encoded SHA-256 hash and reconstruct the canonical string exactly.
Calculate the expected signature
Use the regional signing secret to create an HMAC-SHA256, then Base64-encode it.
Compare securely
Remove v2= from the received signature, compare values in constant time, and reject failures.
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)
)
}
<?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);
}
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)
Use a constant-time comparison in every implementation: timingSafeEqual in Node.js, hash_equals in PHP, and hmac.compare_digest in Python. Always read raw request bytes and combine verification with timestamp freshness checks and optional nonce tracking.
Keep in touch with the latest
Sign up for our monthly deep dives - straight to your inbox.