ComfinoPay 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 ComfinoPay and haven't been tampered with.
This document explains how to verify webhook signatures using the WebhookSignatureVerifier class provided by the API client.
When ComfinoPay sends a webhook to your endpoint:
-
The server computes a CR-Signature header:
CR-Signature: SHA3-256(apiKey . requestBody) -
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 ComfinoPay.
WebhookSignatureVerifier is stateless — it takes no constructor arguments, and the API key is passed per call
instead of being bound at construction time:
use Comfino\Auth\WebhookSignatureVerifier;
$verifier = new WebhookSignatureVerifier();// 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'] ?? '';
// Verify: Empty signature or API key are rejected outright — no exception is thrown.
if ($verifier->verify($signature, 'your-api-key', $rawBody)) {
// Signature is valid — parse and process the webhook.
$payload = json_decode($rawBody, true);
handleWebhook($payload);
} else {
// Signature is invalid, or the signature/API key was empty — reject the request.
http_response_code(403);
exit('Forbidden');
}The verifier uses timing-safe comparison to prevent timing attacks. The real implementation, from
src/Auth/WebhookSignatureVerifier.php:
final class WebhookSignatureVerifier
{
public function verify(string $signature, string $apiKey, string $payload): bool
{
if ($signature === '' || $apiKey === '') {
return false;
}
return hash_equals(hash('sha3-256', $apiKey . $payload), $signature);
}
}Note this is a plain SHA3-256 hash of the API key concatenated with the payload — not an HMAC. An empty signature or API key fails closed rather than degrading to "hash of the payload alone", which would let anyone forge a valid 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();
if (!$verifier->verify($signature, 'your-api-key', $body)) {
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:
$verifier = new WebhookSignatureVerifier();
$verifyWithMultipleKeys = function(string $body, string $signature, array $apiKeys) use ($verifier): bool {
foreach ($apiKeys as $key) {
if ($verifier->verify($signature, $key, $body)) {
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->verify($signature, $apiKey, $rawBody); // Verifies against original bytes.
// Parse only after verification.
$payload = json_decode($rawBody, 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->verify($signature, $apiKey, $rawBody);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: " .
hash('sha3-256', $apiKey . $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, ComfinoPay may timeout and retry. For long-running tasks:
// 1. Verify and queue the webhook.
if (!$verifier->verify($signature, $apiKey, $rawBody)) {
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 ComfinoPay'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.
$expected = hash('sha3-256', 'your-api-key' . $rawBody);
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.
- ComfinoPay retry logic — ComfinoPay may retry on timeout or 5xx errors. Your endpoint should be idempotent.
- Network issues — If a request times out but actually succeeds server-side, ComfinoPay 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() documentation
- hash_equals() documentation — Timing-safe comparison
- WebhookSignatureVerifier source