# Verificar assinaturas de webhook em PHP e Laravel

> Verifique assinaturas de webhook do Ikiform em PHP puro com hash_hmac e hash_equals, e no Laravel com a biblioteca do Standard Webhooks e uma exceção de CSRF.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/webhook-signature-verification/php

Leia o corpo bruto de `php://input`, pegue os três headers `webhook-*` e calcule `base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true))` com o segredo `whsec_` decodificado de base64 como chave. Compare com cada assinatura `v1` usando `hash_equals` e rejeite timestamps com mais de 5 minutos de diferença. No Laravel, use `$request->getContent()`, o pacote `standard-webhooks/standard-webhooks` e exclua a rota das verificações de CSRF.

Duas configurações: um único arquivo PHP que roda em qualquer host com PHP 8, incluindo hospedagem compartilhada, e um controller Laravel. Os dois leem os mesmos três headers e o mesmo corpo bruto; a diferença é onde eles ficam e o que atrapalha o caminho.

## O que muda no PHP

O PHP só preenche `$_POST` para codificações de formulário. O Ikiform envia JSON, então `$_POST` fica vazio e o corpo está só em `php://input`, que são exatamente os bytes brutos de que você precisa. Os headers chegam em `$_SERVER` como `HTTP_WEBHOOK_ID` e assim por diante. No Laravel a armadilha é outra: toda rota em `routes/web.php` passa pela verificação de CSRF, e um webhook não tem token CSRF, então o Laravel responde `419` antes de o seu código rodar.

Diagrama: Um webhook do Ikiform chegando a uma rota Laravel com e sem exceção de CSRF

1. Por padrão, um POST para uma rota em routes/web.php passa pela verificação de CSRF do Laravel.
2. A entrega do Ikiform não tem token CSRF, então o Laravel responde 419 e o controller nunca roda.
3. Com o caminho listado no except de validateCsrfTokens, a requisição chega ao controller.
4. O controller verifica o corpo bruto com a biblioteca do Standard Webhooks e responde 204.

A assinatura substitui o CSRF nesta rota. Todas as outras rotas mantêm a proteção.

## PHP puro

`verify.php`:

```php
<?php

declare(strict_types=1);

const IKIFORM_TOLERANCE_SECONDS = 300;

function verify_ikiform_signature(string $body, array $headers, string $secret, ?int $now = null): ?string
{
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    $signatures = $headers['webhook-signature'] ?? '';
    if ($id === '' || $timestamp === '' || $signatures === '') {
        return 'missing_headers';
    }
    if (preg_match('/^[0-9]{1,12}$/', $timestamp) !== 1) {
        return 'invalid_timestamp';
    }

    $age = ($now ?? time()) - (int) $timestamp;
    if ($age > IKIFORM_TOLERANCE_SECONDS) {
        return 'timestamp_too_old';
    }
    if ($age < -IKIFORM_TOLERANCE_SECONDS) {
        return 'timestamp_too_new';
    }

    $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
    if ($key === false || $key === '') {
        return 'invalid_secret';
    }
    $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));

    foreach (explode(' ', $signatures) as $entry) {
        [$version, $value] = array_pad(explode(',', $entry, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $value)) {
            return null;
        }
    }
    return 'no_matching_signature';
}
```

`webhook.php`:

```php
<?php

declare(strict_types=1);

require __DIR__ . '/verify.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

$body = file_get_contents('php://input');
$headers = [
    'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
    'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
    'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
];

$failure = verify_ikiform_signature($body, $headers, getenv('IKIFORM_WEBHOOK_SECRET') ?: '');
if ($failure !== null) {
    http_response_code(400);
    echo $failure;
    exit;
}

$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if ($event['type'] === 'submission.created') {
    error_log('Ikiform submission ' . $event['data']['submission_id']);
}
http_response_code(204);
```

- `php://input` pode ser lido mais de uma vez, então um framework ou biblioteca que já o leu não impede você de lê-lo de novo.
- `hash_hmac(..., true)` devolve bytes brutos, que depois são codificados em base64. Sem o `true` você recebe hex, que nunca bate.
- `base64_decode(..., true)` é estrito: um segredo danificado devolve `false` em vez de virar silenciosamente a chave errada.
- `hash_equals` é de tempo constante. Passe primeiro o valor que você calculou e depois o da requisição.
- Interpolar `$body` na string é seguro para binários em PHP, então caracteres não ASCII num envio não mudam nada.
- `$_SERVER['HTTP_WEBHOOK_ID']` funciona no Apache, no nginx com PHP-FPM e no servidor embutido, ao contrário de `getallheaders()` em algumas configurações mais antigas.
- Mantenha `IKIFORM_WEBHOOK_SECRET` no ambiente do servidor ou num arquivo fora da raiz web, nunca em `webhook.php`.

### Conferindo com o vetor de teste

Este script passa o [vetor de teste](https://ikiform.com/pt-br/guides/webhook-signature-verification.md#test-vector) pela função. Rode com `php check-signature.php`; ele sai com código diferente de zero em qualquer falha, então pode rodar no CI como está ou virar casos de PHPUnit depois:

`check-signature.php`:

```php
<?php

declare(strict_types=1);

require __DIR__ . '/verify.php';

$secret = 'whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=';
$rolled = 'whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A=';
$body = '{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}';
$headers = [
    'webhook-id' => 'dlv_01k7v2d4n8r2p6s0w3y5b7c9e1',
    'webhook-timestamp' => '1792300000',
    'webhook-signature' => 'v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=',
];
$now = 1792300000 + 30;
$both = ['webhook-signature' => $headers['webhook-signature'] . ' v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak='] + $headers;
$pretty = json_encode(json_decode($body), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

$cases = [
    'accepts the vector' => [verify_ikiform_signature($body, $headers, $secret, $now), null],
    'accepts the old secret during a roll' => [verify_ikiform_signature($body, $both, $secret, $now), null],
    'accepts the new secret during a roll' => [verify_ikiform_signature($body, $both, $rolled, $now), null],
    'rejects a re-serialized body' => [verify_ikiform_signature($pretty, $headers, $secret, $now), 'no_matching_signature'],
    'rejects a stale timestamp' => [verify_ikiform_signature($body, $headers, $secret, $now + 600), 'timestamp_too_old'],
];

$failed = 0;
foreach ($cases as $name => [$got, $want]) {
    $ok = $got === $want;
    $failed += $ok ? 0 : 1;
    printf("%s %s\n", $ok ? 'ok  ' : 'FAIL', $name);
}
exit($failed === 0 ? 0 : 1);
```

### Respondendo primeiro em PHP puro

Um script PHP puro normalmente envia a resposta quando termina, então trabalho lento em `webhook.php` atrasa o `2xx` para o Ikiform, e qualquer coisa acima de 15 segundos conta como falha e gera nova tentativa. No PHP-FPM, `fastcgi_finish_request()` envia a resposta e deixa o script continuar. Esta versão do final de `webhook.php` responde primeiro e depois registra o id da entrega com uma restrição de unicidade, para que uma nova tentativa seja ignorada:

`webhook.php`:

```php
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
http_response_code(204);

if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

$pdo = new PDO(getenv('DATABASE_DSN'), getenv('DATABASE_USER'), getenv('DATABASE_PASSWORD'));
$insert = $pdo->prepare('INSERT INTO webhook_deliveries (delivery_id) VALUES (?) ON CONFLICT (delivery_id) DO NOTHING');
$insert->execute([$event['id']]);
if ($insert->rowCount() === 1 && $event['type'] === 'submission.created') {
    error_log('Ikiform submission ' . $event['data']['submission_id']);
}
```

`fastcgi_finish_request` só existe no PHP-FPM, daí a verificação; com o `mod_php` do Apache, o script roda até o fim antes de a resposta sair. A cláusula `ON CONFLICT` é sintaxe de PostgreSQL e SQLite. No MySQL, use `INSERT IGNORE`, que também informa uma contagem de linhas `0` para uma repetição.

## Laravel

Instale a biblioteca do Standard Webhooks com o Composer:

`Terminal`:

```bash
composer require standard-webhooks/standard-webhooks
```

`app/Http/Controllers/IkiformWebhookController.php`:

```php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use StandardWebhooks\Exception\WebhookVerificationException;
use StandardWebhooks\Webhook;

class IkiformWebhookController extends Controller
{
    public function __invoke(Request $request): Response
    {
        $webhook = new Webhook(config('services.ikiform.webhook_secret'));

        try {
            $event = $webhook->verify($request->getContent(), [
                'webhook-id' => $request->header('webhook-id'),
                'webhook-timestamp' => $request->header('webhook-timestamp'),
                'webhook-signature' => $request->header('webhook-signature'),
            ]);
        } catch (WebhookVerificationException $exception) {
            return response($exception->getMessage(), 400);
        }

        if ($event['type'] === 'submission.created') {
            Log::info('Ikiform submission', ['id' => $event['id'], 'fields' => $event['data']['fields']]);
        }

        return response()->noContent();
    }
}
```

`routes/web.php`:

```php
<?php

use App\Http\Controllers\IkiformWebhookController;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/ikiform', IkiformWebhookController::class);
```

Exclua o caminho da verificação de CSRF em `bootstrap/app.php`:

`bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: [
        'webhooks/ikiform',
    ]);
})
```

E leia o segredo pela config, para que `php artisan config:cache` continue funcionando:

`config/services.php`:

```php
'ikiform' => [
    'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],
```

- `$request->getContent()` é o corpo bruto. Não verifique `json_encode($request->all())`; são os mesmos dados e bytes diferentes.
- A biblioteca busca os headers por chaves exatas em minúsculas, então monte o array explicitamente como mostrado. Passar `$request->headers->all()` não funciona: esses valores são arrays.
- `new Webhook($secret)` tira o `whsec_` e decodifica a chave. Ele lança `WebhookVerificationException` quando falta um header, quando o timestamp é antigo ou futuro, ou quando nenhuma assinatura bate.
- `response()->noContent()` responde `204`. Coloque o trabalho lento numa fila com um job para que a resposta volte bem dentro dos 15 segundos do Ikiform. Um índice único numa coluna `delivery_id`, gravado com `insertOrIgnore`, deixa o job seguro para receber a mesma entrega duas vezes.

> Rotas em `routes/api.php` pulam o CSRF sem precisar de exceção e ganham o prefixo `/api`. Se o seu app já tem rotas de API, coloque o webhook lá e pule a mudança em `bootstrap/app.php`.

## Confira se funciona

1. Rode `php check-signature.php`. Toda linha começa com `ok`.
2. Faça o deploy atrás de uma URL `https:` pública e adicione-a em **Webhooks** no painel.
3. Defina `IKIFORM_WEBHOOK_SECRET` com o segredo `whsec_` do endpoint. No Laravel, rode `php artisan config:clear` se a config estiver em cache.
4. Escolha **Send test**. O log de entregas mostra `204`, não `419`, e os primeiros 4 KB de qualquer corpo de erro que o seu código imprimiu.
5. Mude um caractere do segredo e envie outro teste. O log mostra `400` com o motivo.

## Pegadinhas

| Problema | Causa e correção |
| --- | --- |
| `419` no log de entregas | A verificação de CSRF do Laravel. Adicione o caminho em `validateCsrfTokens(except: [...])` |
| `$_POST` está vazio | Esperado para JSON. Leia `file_get_contents('php://input')` |
| A assinatura nunca bate em PHP puro | O `hash_hmac` foi chamado sem o `true`, então devolveu hex. Ou a chave é a string `whsec_` inteira em vez dos bytes decodificados |
| `Missing required headers` da biblioteca | O array de headers tem as chaves erradas ou valores em array. Use chaves em minúsculas e `$request->header()`, que devolve uma string |
| O segredo é `null` em produção | A config está em cache e `env()` foi chamado fora de um arquivo de config. Leia-o via `config('services.ikiform.webhook_secret')` |

## Perguntas

### Como pego o corpo bruto do POST em PHP?

Leia file_get_contents('php://input'). Numa requisição JSON, $_POST fica vazio, e php://input guarda o corpo exatamente como foi enviado, que é o que a assinatura cobre.

### Por que o Laravel devolve 419 para o meu webhook?

Rotas em routes/web.php são protegidas contra CSRF, e um webhook não consegue enviar um token CSRF. Exclua o caminho com validateCsrfTokens(except: [...]) em bootstrap/app.php, ou defina a rota em routes/api.php.

### O hash_equals é seguro para comparar assinaturas de webhook?

Sim. É a comparação de strings em tempo constante do PHP. Use-o em vez de === para que o tempo gasto não revele quanto de uma assinatura forjada estava certo.

### Qual pacote do Composer verifica Standard Webhooks em PHP?

standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) recebe o segredo whsec_, e verify($body, $headers) devolve o payload decodificado ou lança WebhookVerificationException.

### Por que o resultado do meu hash_hmac não bate com o header webhook-signature?

Por padrão, o hash_hmac devolve hex. Passe true como quarto argumento para receber bytes brutos e depois aplique base64_encode neles. Confira também se a chave é a parte decodificada de base64 depois de whsec_, não a string inteira do segredo.

## Mais guias

- [Verificar assinaturas de webhook](https://ikiform.com/pt-br/guides/webhook-signature-verification.md): Confira se um webhook de formulário veio mesmo do Ikiform: o esquema HMAC-SHA256 do Standard Webhooks, um vetor de teste, corpos brutos, timestamps e rotação de segredo.
- [Ruby](https://ikiform.com/pt-br/guides/webhook-signature-verification/ruby.md): Verifique assinaturas de webhook do Ikiform no Sinatra com OpenSSL e um teste Minitest, e no Rails com a gem standardwebhooks e request.raw_post.
- [Python](https://ikiform.com/pt-br/guides/webhook-signature-verification/python.md): Verifique assinaturas de webhook do Ikiform no Flask, FastAPI e Django, com o pacote standardwebhooks ou a biblioteca padrão, mais um teste com pytest.
- [Node.js](https://ikiform.com/pt-br/guides/webhook-signature-verification/nodejs.md): Verifique assinaturas de webhook do Ikiform em Node.js: corpos brutos no Express e no Fastify, o SDK, o pacote standardwebhooks e uma versão testada com node:crypto.
- [Go net/http](https://ikiform.com/pt-br/guides/webhook-signature-verification/go.md): Verifique assinaturas de webhook do Ikiform em Go com net/http: leia o corpo uma vez, confira com crypto/hmac, teste, ou use a biblioteca do Standard Webhooks.
