Comfino sends webhook requests to your shop to notify about order status changes and other events. All webhooks are signed with a CR-Signature header to prove they originate from Comfino and haven't been tampered with.
This document explains how to verify webhook signatures using the WebhookSignatureVerifier class provided by the API client.
When Comfino sends a webhook to your endpoint:
-
The server computes a CR-Signature header:
CR-Signature: SHA3-256_HMAC(requestBody, apiKey) -
Your shop receives the request and verifies the signature using the same API key.
-
If the signature matches, you can trust the request came from Comfino.
use Comfino\Api\WebhookSignatureVerifier;
$verifier = new WebhookSignatureVerifier(
apiKey: 'your-api-key' // Same key used for API requests.
);// Get the raw POST body (not parsed JSON, but raw bytes).
$rawBody = file_get_contents('php://input');
// Get the signature header.
$signature = $_SERVER['HTTP_CR_SIGNATURE'] ?? null;
// Verify.
try {
$verified = $verifier->verifySignature(
requestBody: $rawBody,
signature: $signature
);
if ($verified) {
// Signature is valid — parse and process the webhook.
$payload = json_decode($rawBody, associative: true);
handleWebhook($payload);
} else {
// Signature is invalid — reject the request.
http_response_code(403);
exit('Forbidden');
}
} catch (InvalidArgumentException $e) {
// Missing or malformed signature header.
http_response_code(400);
exit('Bad Request');
}The verifier uses timing-safe comparison to prevent timing attacks:
public function verifySignature(string $requestBody, ?string $signature): bool
{
// Compute the expected signature.
$expected = hash_hmac('sha3-256', $requestBody, $this->apiKey, false);
// Compare using hash_equals() — O(n) time, constant-time comparison.
return hash_equals($expected, $signature ?? '');
}Timing-safe comparison means the comparison time is independent of where the signatures differ. This prevents attackers from using timing measurements to guess the correct signature byte-by-byte.
If your framework provides PSR-7 request objects:
use Psr\Http\Message\ServerRequestInterface;
function handleComfinoWebhook(ServerRequestInterface $request)
{
// Get raw body.
$body = (string) $request->getBody();
// Get signature header (case-insensitive).
$signature = $request->getHeaderLine('CR-Signature');
// Verify.
$verifier = new WebhookSignatureVerifier('your-api-key');
if (!$verifier->verifySignature($body, $signature)) {
return $response->withStatus(403);
}
// Parse and process.
$payload = json_decode($body, associative: true);
// ... process payload ...
return $response->withStatus(200);
}If you run both sandbox and production endpoints on the same server:
$verifyWithMultipleKeys = function(string $body, string $signature, array $apiKeys): bool {
foreach ($apiKeys as $key) {
$verifier = new WebhookSignatureVerifier($key);
if ($verifier->verifySignature($body, $signature)) {
return true; // Matched one of the keys.
}
}
return false;
};
$apiKeys = [
'sandbox' => 'sandbox_key_...',
'production' => 'live_key_...',
];
$verified = $verifyWithMultipleKeys(
$rawBody,
$signatureHeader,
array_values($apiKeys)
);
if (!$verified) {
http_response_code(403);
exit('Forbidden');
}Incorrect:
// WRONG — JSON parsing re-encodes, changing byte sequence.
$parsed = json_decode(file_get_contents('php://input'), assoc: true);
$rawBody = json_encode($parsed); // Different bytes = different hash!Correct:
// RIGHT — Use the exact bytes received.
$rawBody = file_get_contents('php://input');
$verifier->verifySignature($rawBody, $signature); // Verifies against original bytes.
// Parse only after verification.
$payload = json_decode($rawBody, assoc: true);Always check that the signature header is present:
$signature = $_SERVER['HTTP_CR_SIGNATURE'] ?? null;
if ($signature === null) {
// Reject unsigned requests.
http_response_code(400);
exit('Missing CR-Signature header');
}
$verified = $verifier->verifySignature($rawBody, $signature);Log webhook events for debugging:
// Log the attempt (before verification for diagnostics).
error_log("Webhook received: {$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}");
if ($verified) {
error_log("Webhook verified: " . substr($signature, 0, 16) . '...');
// Process webhook.
} else {
error_log("Webhook verification FAILED. Expected signature for body hash: " .
hash('sha3-256', $rawBody));
}Webhook signatures alone don't prevent replay attacks (same valid request sent multiple times). For protection, maintain a list of processed signature hashes:
// After successful verification.
$signatureHash = md5($signature); // Or store the signature itself.
// Check if we've already processed this.
if ($processedSignatures[$signatureHash] ?? false) {
http_response_code(200); // Idempotent: return success but don't reprocess.
exit;
}
// First time seeing this signature — process it.
$processedSignatures[$signatureHash] = true;
processWebhook($payload);Store processed signatures with a TTL (e.g., 24 hours) in Redis or a database:
$redis = new Redis();
$signatureKey = "webhook:sig:" . $signature;
if ($redis->exists($signatureKey)) {
// Already processed.
http_response_code(200);
exit;
}
// Process the webhook.
processWebhook($payload);
// Mark as processed for the next 24 hours.
$redis->setex($signatureKey, 86400, true);Webhooks should be processed quickly. If your endpoint takes longer than ~30 seconds, Comfino may timeout and retry. For long-running tasks:
// 1. Verify and queue the webhook.
if (!$verifier->verifySignature($rawBody, $signature)) {
http_response_code(403);
exit;
}
// 2. Acknowledge the receipt immediately.
http_response_code(202); // Accepted
// 3. Queue for background processing (async).
Queue::push(new ProcessComfinoWebhook($payload));Possible causes:
- Wrong API key — Ensure you're using the correct sandbox/production key.
- Body was modified — Check that no middleware (compression, encoding) altered the request body.
- Signature header case — The header name is
CR-Signature(exact case on Comfino's side, but HTTP headers are case-insensitive in most servers). - Body read twice — If you read
php://inputbefore verification, the stream is consumed. Read it once and store it.
Debug:
// Print what we received.
echo "Body length: " . strlen($rawBody) . "\n";
echo "Signature header: " . $signature . "\n";
// Compute what we expect.
$verifier = new WebhookSignatureVerifier('your-api-key');
$expected = hash_hmac('sha3-256', $rawBody, 'your-api-key', false);
echo "Expected: " . $expected . "\n";
echo "Received: " . $signature . "\n";
echo "Match: " . ($expected === $signature ? 'YES' : 'NO') . "\n";If you're seeing the same webhook multiple times:
- Check your deduplication logic — Ensure the key is unique and TTL is adequate.
- Comfino retry logic — Comfino may retry on timeout or 5xx errors. Your endpoint should be idempotent.
- Network issues — If a request times out but actually succeeds server-side, Comfino won't retry.
- Never log the API key — Don't include it in error messages or logs.
- Never expose the signature — Don't return it in responses or logs (except for debugging in private logs).
- Use HTTPS — Always receive webhooks over HTTPS; unencrypted HTTP can leak the body and signature.
- Validate the signature first — Verify before parsing or processing the payload.
- Implement replay protection — Store processed signatures to prevent accidental re-processing.
- hash_hmac() documentation
- hash_equals() documentation — Timing-safe comparison
- WebhookSignatureVerifier source