Skip to content

Webhook Signing

Recuro can sign every outbound HTTP request with HMAC-SHA256 so your endpoints can verify the request genuinely came from Recuro and has not been tampered with.

How it works

When you generate a signing secret in Settings → Webhook Signing, Recuro adds two headers to every outbound request (crons, jobs, and callbacks):

HeaderDescription
X-Recuro-SignatureHMAC-SHA256 hex digest of the signed string
X-Recuro-TimestampUnix timestamp (seconds) when the request was signed

The signed string is built by concatenating the timestamp, a period, and the raw request body:

{timestamp}.{body}

For example, if the timestamp is 1742659200 and the body is {"status":"ok"}, the signed string is:

1742659200.{"status":"ok"}

Verifying signatures

To verify a request, recompute the HMAC-SHA256 digest using the same signed-string format and compare it to the X-Recuro-Signature header using a constant-time comparison.

PHP

$secret = env('RECURO_SIGNING_SECRET');
$signature = $request->header('X-Recuro-Signature');
$timestamp = $request->header('X-Recuro-Timestamp');
$body = $request->getContent();
$expected = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
if (! hash_equals($expected, $signature)) {
abort(403, 'Invalid signature');
}

Node.js

import crypto from 'node:crypto';
function verifySignature(body, signature, timestamp, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex'),
);
}

Python

import hashlib
import hmac
def verify_signature(body: str, signature: str, timestamp: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
f"{timestamp}.{body}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)

Replay protection

The X-Recuro-Timestamp header lets you reject stale requests. A common approach is to reject any request where the timestamp is more than 5 minutes old:

$timestamp = (int) $request->header('X-Recuro-Timestamp');
if (abs(time() - $timestamp) > 300) {
abort(403, 'Request timestamp too old');
}

This protects against replay attacks where a captured request is resent later.

Managing your secret

  1. Go to Settings → Webhook Signing in the Recuro dashboard.
  2. Click Generate secret (or Regenerate secret if one already exists).
  3. Copy the secret and store it in your application’s environment variables.