> ## Documentation Index
> Fetch the complete documentation index at: https://docs.talario.eu/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify signatures

> Make sure a webhook comes from Talario and was not changed or replayed.

Every webhook is signed with the signing secret of its endpoint. Verify the signature **before** you process or store anything from the request, and reject requests that fail.

## Algorithm

1. Read the headers `X-Talario-Timestamp`, `X-Talario-Delivery-Id`, `X-Talario-Key-Id` and `X-Talario-Signature`.

2. Select the signing secret for `X-Talario-Key-Id`. Reject unknown key IDs.

3. Reject the request if the timestamp differs from your clock by more than **5 minutes**. Keep your server clock synchronized (NTP).

4. Decode the secret from base64url (without padding) to its 32 raw bytes. Use these bytes as the HMAC key, not the secret string.

5. Build the signed content from the timestamp, the delivery ID and the **raw request body**, joined by dots:

   ```text theme={null}
   {timestamp}.{delivery_id}.{raw body}
   ```

6. Compute HMAC-SHA256 and encode it as lowercase hex.

7. Compare it with the value after `v1=` in `X-Talario-Signature` using a constant-time comparison.

> [!WARNING]
>
> Use the body bytes exactly as received. Do not decode and re-encode the JSON before verifying: a different key order, whitespace or escaping changes the signature.

The signature covers the timestamp and the delivery ID, so a captured request cannot be replayed later or for a different delivery. Each attempt has a new timestamp and a new signature.

## Laravel

Store the secret and its key ID in your configuration:

```php config/services.php theme={null}
'talario' => [
    'api_key' => env('TALARIO_API_KEY'),

    // Key ID => signing secret. Keep two entries while you rotate a secret.
    'webhook_secrets' => array_filter([
        env('TALARIO_WEBHOOK_KEY_ID') => env('TALARIO_WEBHOOK_SECRET'),
        env('TALARIO_WEBHOOK_PREVIOUS_KEY_ID') => env('TALARIO_WEBHOOK_PREVIOUS_SECRET'),
    ]),
],
```

Verify the signature in a middleware:

```php app/Http/Middleware/VerifyTalarioWebhook.php theme={null}
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyTalarioWebhook
{
    /**
     * Maximum difference between the timestamp of the request and the local clock, in seconds.
     */
    private const TOLERANCE_SECONDS = 300;

    public function handle(Request $request, Closure $next): Response
    {
        $timestamp = (string) $request->header('X-Talario-Timestamp');
        $deliveryId = (string) $request->header('X-Talario-Delivery-Id');
        $signature = (string) $request->header('X-Talario-Signature');
        $secret = config('services.talario.webhook_secrets')[(string) $request->header('X-Talario-Key-Id')] ?? null;

        abort_if($secret === null || $deliveryId === '', 401, 'Unknown signing key.');
        abort_unless(ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= self::TOLERANCE_SECONDS, 401, 'Timestamp outside the tolerance.');

        $expected = hash_hmac(
            'sha256',
            $timestamp.'.'.$deliveryId.'.'.$request->getContent(),
            base64_decode(strtr($secret, '-_', '+/')),
        );

        abort_unless(str_starts_with($signature, 'v1=') && hash_equals($expected, substr($signature, 3)), 401, 'Invalid signature.');

        return $next($request);
    }
}
```

Register the route in `routes/api.php`, which has no CSRF protection:

```php routes/api.php theme={null}
use App\Http\Controllers\TalarioWebhookController;
use App\Http\Middleware\VerifyTalarioWebhook;

Route::post('webhooks/talario', TalarioWebhookController::class)
    ->middleware(VerifyTalarioWebhook::class);
```

> [!NOTE]
>
> If your application has no `routes/api.php` yet, create it with `php artisan install:api`. If you register the route in `routes/web.php` instead, exclude it from CSRF protection in `bootstrap/app.php` with `$middleware->validateCsrfTokens(except: ['webhooks/talario'])`.

## Plain PHP

```php theme={null}
function verifyTalarioWebhook(array $secretsByKeyId, array $headers, string $rawBody): bool
{
    $timestamp = $headers['X-Talario-Timestamp'] ?? '';
    $deliveryId = $headers['X-Talario-Delivery-Id'] ?? '';
    $signature = $headers['X-Talario-Signature'] ?? '';
    $secret = $secretsByKeyId[$headers['X-Talario-Key-Id'] ?? ''] ?? null;

    if ($secret === null || $deliveryId === '' || ! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$deliveryId.'.'.$rawBody, base64_decode(strtr($secret, '-_', '+/')));

    return str_starts_with($signature, 'v1=') && hash_equals($expected, substr($signature, 3));
}

// $rawBody = file_get_contents('php://input');
```

Header names are case-insensitive. Normalize them before the lookup if your framework does not.

## Rotate the secret

In the dashboard, choose **Generate new secret** for the endpoint. Talario shows the new secret and its new key ID once. The mail servers sign with the new secret as soon as they have applied the change, usually within a minute; attempts already running may still use the previous secret.

1. Add the new key ID and secret to your configuration, keeping the previous one.
2. Generate the new secret in the dashboard and deploy the configuration.
3. Remove the previous secret once deliveries arrive with the new key ID.

Because Talario selects the secret by `X-Talario-Key-Id`, both secrets work during the change.

## Test your endpoint with cURL

Send a signed test request to your endpoint to check your verification. You need `openssl` and the secret of the endpoint:

```bash theme={null}
SECRET='your-signing-secret'
KEY_ID='your-key-id'
URL='https://app.example.com/webhooks/talario'

TIMESTAMP=$(date +%s)
DELIVERY_ID='019a0520-0000-7000-8000-000000000001'

cat > payload.json <<'JSON'
{"schema_version":1,"event":"message.received","delivery_id":"019a0520-0000-7000-8000-000000000001","message":{"id":"019a0520-0000-7000-8000-000000000002","domain":"inbound.example.com","received_at":"2026-10-21T16:00:00Z","envelope_from":"erika@example.org","recipient":"support@inbound.example.com","message_id":"<external-id@example.org>","size_bytes":14557,"sha256":"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae","expires_at":"2026-10-28T16:00:00Z"},"download":{"url":"https://example.invalid/original-download","expires_at":"2026-10-21T17:00:00Z"}}
JSON

# The secret is base64url without padding: 43 characters, 32 bytes.
KEY_HEX=$(printf '%s=' "$SECRET" | tr '_-' '/+' | openssl base64 -d -A | od -An -tx1 | tr -d ' \n')
SIGNATURE=$( { printf '%s.%s.' "$TIMESTAMP" "$DELIVERY_ID"; cat payload.json; } \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -r | cut -d' ' -f1)

curl -i "$URL" \
  -H 'Content-Type: application/json' \
  -H "X-Talario-Delivery-Id: $DELIVERY_ID" \
  -H "X-Talario-Timestamp: $TIMESTAMP" \
  -H "X-Talario-Key-Id: $KEY_ID" \
  -H "X-Talario-Signature: v1=$SIGNATURE" \
  --data-binary @payload.json
```

Use `--data-binary`, not `-d`: `-d` removes line breaks and changes the signed bytes. Your endpoint should answer with `2xx`, and with `401` after you change a single character in `payload.json` without signing again.

> [!NOTE]
>
> A test request with a made-up message ID is not a real message: downloads and API calls for it return `404`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.