# Verifying signatures

Every delivery is signed with your endpoint secret. Check it before you act on the payload.

The header reads t={unix timestamp},v1={64 hex characters}, with a second v1 for a day after you rotate the secret. Concatenate the timestamp, a dot and the raw request body, compute HMAC-SHA256 with the endpoint secret, and compare it to each v1 using a constant-time comparison: one match is enough. Reject timestamps more than five minutes away from now: each attempt is signed when it is sent, so a retry carries a fresh timestamp.

## Verify the raw request

Pass the exact request body, the X-VeliraPay-Signature header and your endpoint signing secret to the verifier. Load the secret from VELIRAPAY_WEBHOOK_SECRET on your server; it starts with whsec_ and is different from your API key. A missing header should be passed as an empty string.

All six tabs are available below. PHP, Node.js, Python and C# implement the signature check. Laravel uses the official package middleware, which also supports comma-separated signing secrets. The cURL tab sends a signed local challenge to exercise your receiver; cURL itself cannot receive or verify incoming webhooks.

```bash
# Bash, cURL and OpenSSL; use the same test secret in your receiver.
set -euo pipefail
: "${VELIRAPAY_WEBHOOK_SECRET:?Set the test signing secret for your local receiver}"
receiver_url='http://127.0.0.1:8080/webhooks/velirapay'
body='{"id":"local-verification","event":"webhook.verification","mode":"test","test":true,"data":{"challenge":"local-challenge"}}'
timestamp=$(date +%s)
signature=$(printf '%s.%s' "$timestamp" "$body" |
  openssl dgst -sha256 -hmac "$VELIRAPAY_WEBHOOK_SECRET" -r)
signature=${signature%% *}

curl "$receiver_url" \
  --fail-with-body --silent --show-error --max-time 15 \
  -H 'Content-Type: application/json' \
  -H 'X-VeliraPay-Event: webhook.verification' \
  -H 'X-VeliraPay-Delivery: local-verification' \
  -H "X-VeliraPay-Signature: t=$timestamp,v1=$signature" \
  --data-binary "$body"
# Expected: {"challenge":"local-challenge"}
```

```php
<?php

/**
 * Verify the signature before parsing the raw request body.
 */
function verifyVeliraPaySignature(string $body, string $header, string $secret): bool
{
    if ($secret === '' || ! preg_match('/^t=([0-9]{1,12})(?:,|$)/', $header, $time)) {
        return false;
    }

    if (abs(time() - (int) $time[1]) > 300) {
        return false;
    }

    preg_match_all('/(?:^|,)v1=([0-9a-f]{64})(?=,|$)/', $header, $signatures);
    $expected = hash_hmac('sha256', $time[1].'.'.$body, $secret);

    foreach ($signatures[1] as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}
```

```php
// routes/api.php; the default URL includes the /api prefix.
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
use VeliraPay\Laravel\Http\Middleware\VerifyWebhookSignature;

Route::post('/webhooks/velirapay', function (Request $request): JsonResponse|Response {
    try {
        $delivery = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);
    } catch (\JsonException) {
        return response('Invalid JSON.', 400);
    }

    if (($delivery['event'] ?? null) === 'webhook.verification') {
        $challenge = $delivery['data']['challenge'] ?? null;

        return is_string($challenge)
            ? response()->json(['challenge' => $challenge])
            : response('Invalid challenge.', 400);
    }

    return response('Add durable event handling before accepting payments.', 503);
})->middleware(VerifyWebhookSignature::class);
```

```javascript
import crypto from 'node:crypto';

export function verifyVeliraPaySignature(rawBody, header, secret) {
  if (!Buffer.isBuffer(rawBody) || !secret) {
    return false;
  }

  const timestamp = /^t=([0-9]{1,12})(?:,|$)/.exec(header)?.[1];
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }

  const expected = crypto.createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();

  return [...header.matchAll(/(?:^|,)v1=([0-9a-f]{64})(?=,|$)/g)]
    .some((match) => crypto.timingSafeEqual(expected, Buffer.from(match[1], 'hex')));
}
```

```python
import hashlib
import hmac
import re
import time

def verify_velirapay_signature(body: bytes, header: str, secret: str) -> bool:
    timestamp = re.match(r"t=([0-9]{1,12})(?:,|$)", header)
    if not secret or timestamp is None:
        return False
    if abs(time.time() - int(timestamp[1])) > 300:
        return False

    signatures = re.findall(r"(?:^|,)v1=([0-9a-f]{64})(?=,|$)", header)
    signed = (timestamp[1] + ".").encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)
```

```csharp
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

public static class VeliraPaySignature
{
    public static bool Verify(byte[] body, string header, string secret)
    {
        var time = Regex.Match(header, @"\At=([0-9]{1,12})(?:,|$)");
        if (string.IsNullOrEmpty(secret) || !time.Success
            || !long.TryParse(time.Groups[1].Value, NumberStyles.None,
                CultureInfo.InvariantCulture, out var timestamp)
            || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 300)
        {
            return false;
        }

        var prefix = Encoding.UTF8.GetBytes(time.Groups[1].Value + ".");
        var signed = new byte[prefix.Length + body.Length];
        prefix.CopyTo(signed, 0);
        body.CopyTo(signed, prefix.Length);
        var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);

        foreach (Match signature in Regex.Matches(header, @"(?:^|,)v1=([0-9a-f]{64})(?=,|$)"))
        {
            if (CryptographicOperations.FixedTimeEquals(expected,
                Convert.FromHexString(signature.Groups[1].Value)))
            {
                return true;
            }
        }

        return false;
    }
}
```

Sign the raw body exactly as it arrived: parsing and re-encoding the JSON changes the bytes and the signature will not match.

Read php://input in PHP, preserve a Buffer before JSON middleware in Node.js, use request bytes in Python, or copy Request.Body into a byte array in ASP.NET Core. Keep your server clock synchronized. The C# example uses only the standard .NET 8 libraries.

Save the PHP verifier as verify.php, Node.js as verify.mjs, Python as verify.py, or C# as VeliraPaySignature.cs. The receiver examples show how to call them, return the challenge and reject bad requests. Laravel needs velirapay/velirapay-laravel and VELIRAPAY_WEBHOOK_SECRET in .env; its middleware rejects invalid signatures before your route runs. [Open the receiver examples](https://velirapay.com/docs/webhooks#verifying-the-endpoint)

## After verification

If verification returns false, return 400 or 403 and stop. Otherwise, parse the JSON. For webhook.verification, answer with a JSON object whose challenge is the payload's data.challenge; the endpoint must pass this check before normal deliveries begin.

For payment events, check mode and ignore dashboard samples marked test: true. Save the event durably before returning a 2xx response, then process it once. A verified charge.paid event can complete its matching order after you check the expected amount and currency; other charge events must not trigger fulfilment.

## Duplicates

A delivery keeps its X-VeliraPay-Delivery header, which is also the payload's id, through every retry and every resend from the delivery log: store it to ignore a delivery you have already handled. The same event sent to two of your endpoints arrives as two deliveries with one event_id, so deduplicate on event_id when several endpoints feed the same system.

## Rotating the secret

Rotating an endpoint's secret, in the dashboard or with POST /v1/webhook-endpoints/{id}/secret, signs every delivery from then on, including retries of deliveries made before the rotation, with the new secret. For the next 24 hours each delivery also carries a v1 made with the old secret, so a receiver that still has the old one keeps passing deliveries until you update it.

Check every v1 in the header rather than only the first or the last, or the overlap does not help. The Laravel package also takes several secrets in VELIRAPAY_WEBHOOK_SECRET, separated by commas.

Using PHP or Laravel? The official libraries run this check for you. [See the libraries](https://velirapay.com/docs/libraries)
