# Webhook

> Payload webhook bertanda tangan, verifikasi tanda tangan HMAC-SHA256, jadwal percobaan ulang, dan log pengiriman.

Webhook mendorong setiap kiriman yang diterima ke server Anda saat itu juga. Pengiriman ditandatangani dengan skema [Standard Webhooks](https://www.standardwebhooks.com), dicoba ulang selama sekitar tiga hari, dan dicatat per percobaan di dashboard. Handler siap pakai ada di [Menerima webhook](https://ikiform.com/id/docs/guides/webhook-handlers.md).

## Tambahkan endpoint

1. Di dashboard, buka **Webhooks** dan pilih **Add endpoint**.
2. Masukkan URL HTTPS dan pilih satu formulir, atau semua formulir di workspace.
3. Salin signing secret. Secret diawali `whsec_` dan hanya ditampilkan utuh sekali.
4. Kirim event tes untuk memeriksa handler Anda.

### URL endpoint

- Hanya `https:`, di port default 443, tanpa nama pengguna atau kata sandi di URL, hingga 2.048 karakter.
- Hostname publik. Alamat IP, `localhost`, dan nama yang berakhiran `.local`, `.internal`, `.intranet`, `.lan`, `.home.arpa`, atau `.onion` ditolak, begitu pula ikiform.com, workers.dev, dan pages.dev.
- Hostname hanya boleh resolve ke alamat publik. Rentang private, loopback, link-local, dan carrier-grade NAT ditolak.
- URL diperiksa saat Anda menyimpannya dan lagi sebelum setiap pengiriman. Endpoint yang mulai resolve ke alamat private akan dinonaktifkan.
- Satu workspace bisa punya hingga 3 endpoint. Enterprise tanpa batas.

## Event

| Tipe | Dikirim saat | Dicoba ulang |
| --- | --- | --- |
| `submission.created` | Kiriman diterima, atau kiriman spam ditandai sebagai bukan spam | Ya |
| `endpoint.test` | Anda memilih **Send test** di halaman endpoint | Tidak, satu percobaan |

Spam tidak pernah memicu webhook. Begitu pula kiriman yang ditahan karena workspace melewati batas bulanannya.

## Request-nya

Setiap pengiriman berupa `POST` dengan body JSON dan tiga header tanda tangan, dikirim dari layanan pengiriman Ikiform dengan user agent `Ikiform-Webhooks/1.0`.

`Header`:

```http
POST /webhooks/ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

| Header | Nilai |
| --- | --- |
| `webhook-id` | ID pengiriman. Sama di setiap percobaan ulang untuk pengiriman yang sama, jadi gunakan untuk mengabaikan duplikat |
| `webhook-timestamp` | Detik Unix saat percobaan ini ditandatangani. Baru di setiap percobaan |
| `webhook-signature` | `v1,` ditambah HMAC-SHA256 dalam base64. Beberapa nilai dipisah spasi selama secret dirotasi |

### Payload

**submission.created** (`Body`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": false
  }
}
```

**endpoint.test** (`Body`)

```json
{
  "id": "dlv_01k7v2f0a6c8e2g4j6m8p0r2t4",
  "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."
  }
}
```

**Truncated** (`Lebih dari 256 KB`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": {},
    "meta": { "origin": "https://example.com", "country": "GB" },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": true
  }
}
```

| Field | Deskripsi |
| --- | --- |
| `id` | ID pengiriman, sama dengan `webhook-id` |
| `type` | `submission.created` atau `endpoint.test` |
| `timestamp` | Waktu pengiriman dibuat, dalam ISO 8601 UTC. Sama di setiap percobaan ulang |
| `api_version` | Versi payload saat endpoint dibuat, saat ini `2026-10-17` |
| `data.submission_id` | ID kiriman. Ambil dengan [REST API](https://ikiform.com/id/docs/api.md#get-submission) |
| `data.form` | `id` dan `name` formulir |
| `data.fields` | Nilai yang disimpan. Jika field didefinisikan, ini adalah nilai yang sudah divalidasi dan dikonversi |
| `data.meta` | Key yang sama dengan `meta` di REST API. Setiap key selalu ada dan nilai apa pun bisa `null`. IP pengunjung dan hash-nya tidak pernah dikirim |
| `data.meta.user_agent` | User agent browser, hingga 512 karakter |
| `data.meta.origin`, `referrer` | Asal kiriman, jika browser mengirimkannya |
| `data.meta.country` | Kode negara dua huruf, dari Cloudflare |
| `data.meta.subject`, `reply_to` | Dari field `_subject` dan `_replyto` |
| `data.created_at` | Waktu kiriman disimpan |
| `data.truncated` | `true` jika body melebihi 256 KB, dan dalam hal itu `fields` kosong |

Nilai `meta` apa pun bisa `null`. Perlakukan nilai field sebagai input yang tidak tepercaya, sama seperti kiriman formulir ke server Anda sendiri.

## Verifikasi tanda tangan

Tanda tangannya adalah HMAC-SHA256 dari `{webhook-id}.{webhook-timestamp}.{raw body}`, dengan kunci berupa bagian secret setelah `whsec_` yang sudah di-decode dari base64, lalu di-encode ke base64 dan dikirim setelah `v1,` di `webhook-signature`.

- Gunakan body request mentah persis seperti yang diterima. Mengurai lalu menyerialisasi ulang JSON akan mengubah byte-nya.
- Header bisa berisi beberapa tanda tangan yang dipisah spasi. Terima request jika ada satu tanda tangan `v1` yang cocok.
- Bandingkan dalam waktu konstan, dan tolak timestamp yang berselisih lebih dari 5 menit dari sekarang.

### Dengan SDK

`verifyWebhook` melakukan semua hal di atas dengan Web Crypto dan mengembalikan event yang sudah diurai, atau melempar `WebhookVerificationError` dengan `reason`. Fungsi ini menerima objek `Headers` atau record biasa seperti `req.headers` di Node.

`app/api/webhooks/ikiform/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type === "submission.created") {
      console.log(event.data.fields);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(error.reason, { status: 400 });
    }
    throw error;
  }
}
```

### Tanpa SDK

Library Standard Webhooks apa pun juga bisa. Secara manual, hanya butuh beberapa baris:

**Node** (`verify.js`)

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

export function verify(body, headers) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

**Python** (`verify.py`)

```python
import base64
import hashlib
import hmac
import os
import time

secret = base64.b64decode(os.environ["IKIFORM_WEBHOOK_SECRET"].removeprefix("whsec_"))


def verify(body: bytes, headers) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret, content, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

### Verifikator tanda tangan

Verifikator interaktif di halaman ini memeriksa secret, tiga header webhook, dan body mentah dengan `verifyWebhook` dari SDK, sepenuhnya di browser. Verifikator juga bisa membuat contoh pengiriman yang ditandatangani dengan benar. Tidak ada yang Anda tempel yang keluar dari halaman ini.

## Merespons

| Respons Anda | Yang dilakukan Ikiform |
| --- | --- |
| `2xx` apa pun dalam 15 detik | Menandai pengiriman berhasil |
| `410 Gone` | Menggagalkan pengiriman dan langsung menonaktifkan endpoint |
| `429` atau `503` dengan `Retry-After` | Mencoba ulang setelah jeda itu, tetapi tidak lebih lambat dari langkah berikutnya di jadwal |
| Redirect | Tidak diikuti. Dihitung sebagai kegagalan |
| Status lain, timeout, atau error jaringan | Mencoba ulang sesuai jadwal di bawah |

Kerjakan pekerjaan yang lambat setelah merespons, misalnya di antrean. 4 KB pertama body respons Anda disimpan di log pengiriman.

## Percobaan ulang

Percobaan yang gagal dicoba ulang dengan jadwal ini, masing-masing jeda dengan jitter acak hingga 20% ke dua arah. Setelah percobaan ke-9, sekitar 3 hari 6 jam setelah yang pertama, pengiriman ditandai gagal.

| Percobaan | Jeda setelah percobaan sebelumnya | Waktu sejak percobaan pertama |
| --- | --- | --- |
| 1 | Langsung | 0 |
| 2 | 5 dtk | 5 dtk |
| 3 | 5 mnt | 5 mnt |
| 4 | 30 mnt | 35 mnt |
| 5 | 2 jam | 2 jam 35 mnt |
| 6 | 5 jam | 7 jam 35 mnt |
| 7 | 10 jam | 17 jam 35 mnt |
| 8 | 1 hari | 1 hari 18 jam |
| 9 | 1 hari 12 jam | 3 hari 6 jam |

### Endpoint yang dinonaktifkan

Jika setiap pengiriman ke sebuah endpoint gagal selama 5 hari, endpoint dinonaktifkan dan pemilik workspace mendapat email. Respons `410 Gone` atau tujuan yang menjadi private langsung menonaktifkannya. Aktifkan kembali dari halaman endpoint setelah diperbaiki.

## Log pengiriman

Setiap halaman endpoint mencantumkan pengirimannya beserta statusnya. Buka salah satunya untuk melihat setiap percobaan: kapan dijalankan, apakah otomatis, manual, atau tes, kode status, durasi, dan 4 KB pertama respons Anda atau error-nya. Coba ulang pengiriman mana pun dari sana. Percobaan disimpan selama 30 hari dan pengiriman selama 90 hari.

## Merotasi secret

**Roll signing secret** membuat secret baru. Selama 24 jam berikutnya, setiap pengiriman ditandatangani dengan secret lama dan baru sekaligus, jadi `webhook-signature` berisi dua nilai dan Anda bisa men-deploy secret baru tanpa kehilangan event.
