PAY-003
Verify a Coinbase Commerce webhook
HMAC-SHA256 of the raw body against the X-CC-Webhook-Signature header, and the event types that actually mean money arrived.
Coinbase Commerce posts a JSON event and signs the raw body with your webhook shared secret using HMAC-SHA256. Verification is one comparison against the X-CC-Webhook-Signature header — again over the untouched request body.
The event vocabulary is the part worth learning. charge:pending means the transaction was seen but not confirmed. charge:confirmed is the one that means funds are yours. charge:failed covers expiry and underpayment. Crediting on pending is crediting on something that can still disappear.
The metadata you attach when creating the charge comes back on every event, which is where your own reference belongs.
function coinbase_commerce_event(string $sharedSecret): ?array
{
$raw = (string) file_get_contents('php://input');
$sent = (string) ($_SERVER['HTTP_X_CC_WEBHOOK_SIGNATURE'] ?? '');
if ($raw === '' || $sent === '') {
return null;
}
if (!hash_equals(hash_hmac('sha256', $raw, $sharedSecret), $sent)) {
return null; // forged or wrong secret
}
$data = json_decode($raw, true);
return is_array($data['event'] ?? null) ? $data['event'] : null;
}
function handle_commerce(PDO $pdo, string $secret): void
{
$event = coinbase_commerce_event($secret);
if ($event === null) {
http_response_code(403);
exit('bad signature');
}
$type = (string) ($event['type'] ?? '');
$charge = $event['data'] ?? [];
$ref = (string) ($charge['metadata']['ref'] ?? ''); // set when the charge was created
switch ($type) {
case 'charge:confirmed': // the only one that means paid
$paid = $charge['payments'][0]['value']['local']['amount'] ?? '0';
credit_deposit($pdo, $ref, (int) round(((float) $paid) * 100000000), (string) ($charge['id'] ?? ''));
break;
case 'charge:failed':
mark_deposit_failed($pdo, $ref, 'expired or underpaid');
break;
// charge:created, charge:pending, charge:delayed — record, credit nothing
}
http_response_code(200);
echo 'ok';
}
Using it
Attach your own reference in metadata when you create the charge. It is the only field that survives the round trip under your control.
Return 200 for every event you understand, including the ones you deliberately ignore. Anything else queues a retry.
What bites people
charge:delayed means it arrived after expiry. That is a policy decision, not an error — decide whether you honour it.
Read the local currency amount, not the crypto amount, if you priced in dollars. They are different fields and confusing them changes what you credit.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.