# Verifikasi tanda tangan webhook di PHP dan Laravel

> Verifikasi tanda tangan webhook Ikiform di PHP biasa dengan hash_hmac dan hash_equals, dan di Laravel dengan library Standard Webhooks dan pengecualian CSRF.

Terbit 2026-10-25 · https://ikiform.com/id/guides/webhook-signature-verification/php

Baca body mentah dari `php://input`, ambil ketiga header `webhook-*`, dan hitung `base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true))` dengan secret `whsec_` hasil decode base64 sebagai key. Bandingkan dengan setiap tanda tangan `v1` memakai `hash_equals`, dan tolak timestamp yang meleset lebih dari 5 menit. Di Laravel, gunakan `$request->getContent()`, paket `standard-webhooks/standard-webhooks`, dan kecualikan route dari pemeriksaan CSRF.

Dua penyiapan: satu file PHP yang berjalan di host mana pun dengan PHP 8, termasuk shared hosting, dan controller Laravel. Keduanya membaca tiga header yang sama dan body mentah yang sama; bedanya ada di mana semua itu berada dan apa yang menghalanginya.

## Apa yang berbeda di PHP

PHP hanya mengisi `$_POST` untuk encoding formulir. Ikiform mengirim JSON, jadi `$_POST` kosong dan body hanya ada di `php://input`, yang persis berisi byte mentah yang Anda butuhkan. Header tiba di `$_SERVER` sebagai `HTTP_WEBHOOK_ID` dan seterusnya. Di Laravel jebakannya berbeda: setiap route di `routes/web.php` melewati verifikasi CSRF, dan webhook tidak punya token CSRF, jadi Laravel menjawab `419` sebelum kode Anda berjalan.

Diagram: Webhook Ikiform yang sampai ke route Laravel dengan dan tanpa pengecualian CSRF

1. Secara default, POST ke route di routes/web.php melewati pemeriksaan CSRF Laravel.
2. Pengiriman Ikiform tidak punya token CSRF, jadi Laravel menjawab 419 dan controller tidak pernah berjalan.
3. Dengan path tercantum di except validateCsrfTokens, request sampai ke controller.
4. Controller memverifikasi body mentah dengan library Standard Webhooks dan menjawab 204.

Tanda tangan menggantikan CSRF untuk route ini. Setiap route lain tetap terlindungi.

## PHP biasa

`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` bisa dibaca lebih dari sekali, jadi framework atau library yang sudah membacanya tidak menghalangi Anda membacanya lagi.
- `hash_hmac(..., true)` mengembalikan byte mentah, yang kemudian di-encode base64. Tanpa `true` Anda mendapat hex, yang tidak akan pernah cocok.
- `base64_decode(..., true)` bersifat ketat: secret yang rusak mengembalikan `false` alih-alih diam-diam di-decode menjadi key yang salah.
- `hash_equals` bersifat constant-time. Berikan nilai yang Anda hitung sebagai argumen pertama dan nilai dari request sebagai argumen kedua.
- Menyisipkan `$body` ke dalam string aman untuk biner di PHP, jadi karakter non-ASCII di kiriman tidak mengubah apa pun.
- `$_SERVER['HTTP_WEBHOOK_ID']` berfungsi di Apache, nginx dengan PHP-FPM, dan server bawaan, tidak seperti `getallheaders()` di beberapa penyiapan lama.
- Simpan `IKIFORM_WEBHOOK_SECRET` di environment server atau file di luar web root, jangan pernah di `webhook.php`.

### Memeriksanya dengan test vector

Script ini menjalankan [test vector](https://ikiform.com/id/guides/webhook-signature-verification.md#test-vector) melalui function. Jalankan dengan `php check-signature.php`; script keluar dengan kode non-nol pada kegagalan apa pun, jadi bisa dijalankan di CI apa adanya atau dijadikan kasus PHPUnit nanti:

`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);
```

### Menjawab lebih dulu di PHP biasa

Script PHP biasa umumnya mengirim responsnya saat selesai, jadi pekerjaan lambat di `webhook.php` menunda `2xx` untuk Ikiform, dan apa pun yang lebih dari 15 detik dianggap gagal dan dicoba ulang. Di PHP-FPM, `fastcgi_finish_request()` mengirim respons dan membiarkan script terus berjalan. Versi bagian akhir `webhook.php` ini menjawab lebih dulu, lalu mencatat id pengiriman dengan unique constraint agar percobaan ulang dilewati:

`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` hanya ada di PHP-FPM, karena itu ada pemeriksaannya; di `mod_php` Apache script berjalan sampai akhir sebelum jawaban dikirim. Klausa `ON CONFLICT` adalah sintaks PostgreSQL dan SQLite. Di MySQL, gunakan `INSERT IGNORE`, yang juga melaporkan jumlah baris `0` untuk pengulangan.

## Laravel

Instal library Standard Webhooks dengan 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);
```

Kecualikan path dari verifikasi CSRF di `bootstrap/app.php`:

`bootstrap/app.php`:

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

Dan baca secret melalui config, agar `php artisan config:cache` tetap berfungsi:

`config/services.php`:

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

- `$request->getContent()` adalah body mentah. Jangan verifikasi `json_encode($request->all())`; datanya sama tetapi byte-nya berbeda.
- Library mencari header dengan key huruf kecil yang persis, jadi susun array secara eksplisit seperti contoh. Memberikan `$request->headers->all()` tidak berfungsi: nilai-nilainya berupa array.
- `new Webhook($secret)` menghapus `whsec_` dan men-decode key. Library melempar `WebhookVerificationException` untuk header yang hilang, timestamp lama atau dari masa depan, atau tanda tangan yang tidak cocok.
- `response()->noContent()` menjawab `204`. Masukkan pekerjaan lambat ke antrean dengan job agar jawaban kembali jauh di bawah 15 detik Ikiform. Unique index di kolom `delivery_id`, yang ditulis dengan `insertOrIgnore`, membuat job aman diterima dua kali.

> Route di `routes/api.php` melewati CSRF tanpa pengecualian apa pun dan mendapat awalan `/api`. Jika aplikasi Anda sudah punya route API, taruh webhook di sana dan lewati perubahan `bootstrap/app.php`.

## Pastikan berfungsi

1. Jalankan `php check-signature.php`. Setiap baris diawali `ok`.
2. Deploy di balik URL `https:` publik dan tambahkan di **Webhooks** di dashboard.
3. Atur `IKIFORM_WEBHOOK_SECRET` ke secret `whsec_` milik endpoint. Di Laravel, jalankan `php artisan config:clear` jika config di-cache.
4. Pilih **Send test**. Log pengiriman menampilkan `204`, bukan `419`, dan 4 KB pertama dari body error apa pun yang dicetak kode Anda.
5. Ubah satu karakter secret dan kirim uji lagi. Log menampilkan `400` beserta alasannya.

## Hal yang sering menjebak

| Masalah | Penyebab dan solusi |
| --- | --- |
| `419` di log pengiriman | Pemeriksaan CSRF Laravel. Tambahkan path ke `validateCsrfTokens(except: [...])` |
| `$_POST` kosong | Itu wajar untuk JSON. Baca `file_get_contents('php://input')` |
| Tanda tangan tidak pernah cocok di PHP biasa | `hash_hmac` dipanggil tanpa `true`, jadi mengembalikan hex. Atau key-nya seluruh string `whsec_`, bukan byte hasil decode-nya |
| `Missing required headers` dari library | Array header punya key yang salah atau nilai berupa array. Gunakan key huruf kecil dan `$request->header()`, yang mengembalikan string |
| Secret bernilai `null` di produksi | Config di-cache dan `env()` dipanggil di luar file config. Baca melalui `config('services.ikiform.webhook_secret')` |

## Pertanyaan

### Bagaimana cara mendapatkan body POST mentah di PHP?

Baca file_get_contents('php://input'). Untuk request JSON, $_POST kosong, dan php://input menyimpan body persis seperti dikirim, yang dicakup oleh tanda tangan.

### Mengapa Laravel mengembalikan 419 untuk webhook saya?

Route di routes/web.php dilindungi CSRF, dan webhook tidak bisa mengirim token CSRF. Kecualikan path dengan validateCsrfTokens(except: [...]) di bootstrap/app.php, atau definisikan route di routes/api.php.

### Apakah hash_equals aman untuk membandingkan tanda tangan webhook?

Ya. Itu perbandingan string constant-time milik PHP. Gunakan alih-alih === agar waktu yang dibutuhkan tidak mengungkap seberapa banyak tanda tangan palsu yang benar.

### Paket Composer mana yang memverifikasi Standard Webhooks di PHP?

standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) menerima secret whsec_, dan verify($body, $headers) mengembalikan payload hasil decode atau melempar WebhookVerificationException.

### Mengapa hasil hash_hmac saya tidak cocok dengan header webhook-signature?

Secara default hash_hmac mengembalikan hex. Berikan true sebagai argumen keempat untuk mendapat byte mentah, lalu base64_encode byte itu. Periksa juga bahwa key-nya adalah bagian hasil decode base64 setelah whsec_, bukan seluruh string secret.

## Panduan lainnya

- [Verifikasi tanda tangan webhook](https://ikiform.com/id/guides/webhook-signature-verification.md): Pastikan webhook formulir benar-benar berasal dari Ikiform: skema HMAC-SHA256 Standard Webhooks, test vector, body mentah, timestamp, dan rotasi secret.
- [Ruby](https://ikiform.com/id/guides/webhook-signature-verification/ruby.md): Verifikasi tanda tangan webhook Ikiform di Sinatra dengan OpenSSL dan test Minitest, dan di Rails dengan gem standardwebhooks dan request.raw_post.
- [Python](https://ikiform.com/id/guides/webhook-signature-verification/python.md): Verifikasi tanda tangan webhook Ikiform di Flask, FastAPI, dan Django, dengan paket standardwebhooks atau standard library, ditambah test pytest.
- [Node.js](https://ikiform.com/id/guides/webhook-signature-verification/nodejs.md): Verifikasi tanda tangan webhook Ikiform di Node.js: body mentah di Express dan Fastify, SDK, paket standardwebhooks, dan versi node:crypto yang sudah diuji.
- [Go net/http](https://ikiform.com/id/guides/webhook-signature-verification/go.md): Verifikasi tanda tangan webhook Ikiform di Go dengan net/http: baca body sekali, periksa dengan crypto/hmac, uji, atau gunakan library Standard Webhooks.
