Papers Verifying Signed Webhooks

Verifying Signed Webhooks

How to verify that a payment event really came from U.CASH Pay: the signature header, the HMAC, and the replay guard. Re-authored from the canonical merchant doc.

Updated · canonical source: pay.u.cash/docs/webhook-signing

The request

When a payment is confirmed, U.CASH Pay sends an HTTP POST to your webhook URL (configured in Settings, then Webhook). The body is JSON carrying the transaction object and an event_id, a 32-character hex idempotency key. Two headers matter: X-Webhook-Signature and X-Webhook-Event-Id, which repeats the body event_id.

POST <your webhook URL>
Content-Type: application/json
X-Webhook-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>
X-Webhook-Event-Id: <32-char hex>

{
  "transaction": { "id": "...", "status": "C", "...": "..." },
  "event_id": "<32-char hex idempotency key>"
}

The signature

The signature is an HMAC-SHA256 of the string "<t>.<raw-body>", keyed with your webhook secret key from Settings, then Webhook. Verify in three steps: parse the t and v1 components from the header; reject anything whose timestamp is more than five minutes stale (replay protection); recompute the HMAC over the RAW request body, never a re-encoded copy, and compare in constant time.

<?php
$signature_header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$body = file_get_contents('php://input'); // MUST be the raw body

$t = null; $v1 = null;
foreach (explode(',', $signature_header) as $component) {
    [$k, $v] = explode('=', $component, 2) + [null, null];
    if ($k === 't')  { $t  = $v; }
    if ($k === 'v1') { $v1 = $v; }
}
if ($t === null || $v1 === null) { http_response_code(401); exit('Missing signature'); }

// Replay protection: reject anything older than 5 minutes.
if (abs(time() - (int) $t) > 300) { http_response_code(401); exit('Stale timestamp'); }

// Recompute and compare in constant time.
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
if (!hash_equals($expected, $v1)) { http_response_code(401); exit('Invalid signature'); }

// Verified: fulfill against the event_id, idempotently.
http_response_code(200);

Idempotency

Deliveries can be retried. Treat the event_id as the idempotency key: fulfill each event exactly once, and answer 200 once done. Retries that arrive after fulfillment should be acknowledged, not re-processed.

The canonical doc

This paper is a condensate. The canonical, always-current version, including key rotation, lives in the merchant documentation.

This paper condenses a canonical source. When details drift, the upstream wins: pay.u.cash/docs/webhook-signing.