---
title: Webhook
source: https://se-hari.com/docs/webhook
updated: 2026-08-16T01:05:08.513019+00:00
---

> Berhenti polling. Daftarkan endpoint sekali, dan Se-Hari yang memberi tahu Anda saat notulen selesai atau kredit menipis.

Webhook membalik arah komunikasi. Alih-alih Anda menanyakan status berulang kali, Se-Hari yang memanggil endpoint Anda begitu ada kabar.

Untuk notulen, ini bukan sekadar optimasi. Pemrosesan bisa memakan beberapa menit, dan polling setiap detik menghasilkan ratusan request yang semuanya menjawab "masih diproses" — sampai Anda menyentuh [rate limit](/docs/rate-limit).

## Event yang tersedia

| Event | Kapan dikirim |
|---|---|
| `note.completed` | Ringkasan dan action item siap |
| `note.failed` | Pemrosesan gagal; payload memuat `retryable` |
| `meeting.created` | Meeting Zoom dibuat, dari mana pun asalnya |
| `meeting.ended` | Zoom melaporkan meeting berakhir |
| `recording.ready` | Rekaman selesai diarsipkan dan siap diunduh |
| `credit.low` | Saldo turun di bawah ambang |
| `credit.topup.succeeded` | Pembayaran lunas dan kredit sudah masuk |

## Mendaftarkan endpoint

```bash
curl -X POST "https://se-hari.com/api/v1/webhooks" \
  -H "Authorization: Bearer $SEHARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://sistem-anda.com/webhook/sehari",
    "event_types": ["note.completed", "note.failed"],
    "description": "Notulen ke Slack"
  }'
```

Responsnya memuat `secret`. **Secret ini hanya ditampilkan sekali.** Simpan sekarang — ia dibutuhkan untuk memverifikasi setiap kiriman. Kalau hilang, `POST /webhooks/{id}/rotate-secret` memberi yang baru.

URL wajib `https` di port 443, dan tidak boleh mengarah ke jaringan privat, loopback, atau alamat metadata cloud. Pembatasan ini bukan formalitas: tanpanya, endpoint pendaftaran webhook berubah menjadi pemindai port jaringan internal kami.

## Bentuk kiriman

```json
{
  "id": "evt_01J8X4K2M9",
  "type": "note.completed",
  "created_at": "2026-08-15T09:12:34Z",
  "data": {
    "note": {
      "id": "…",
      "title": "Rapat mingguan produk",
      "status": "completed",
      "summary": "…",
      "key_decisions": [ … ],
      "action_items": [ … ],
      "duration_minutes": 47,
      "speaker_count": 3,
      "credits_deducted": 3,
      "url": "https://se-hari.com/dashboard/notes/…"
    }
  }
}
```

Perhatikan satu tingkat tambahan: entitasnya ada di bawah **namanya sendiri**, bukan langsung di `data`. Jadi ringkasan ada di `data.note.summary`, bukan `data.summary`.

Bentuknya konsisten di semua event yang membawa entitas:

| Event | Isi `data` |
|---|---|
| `note.completed` | `{ note }` |
| `note.failed` | `{ note, retryable }` — `retryable` sejajar dengan `note`, bukan di dalamnya |
| `meeting.created`, `meeting.ended` | `{ meeting }` |
| `recording.ready` | `{ recording }` |
| `credit.topup.succeeded` | `{ topup }` |
| `credit.low` | `{ balance, threshold, triggered_by }` — tidak membungkus, karena tidak ada entitas tunggal yang bisa dinamai |

Transkrip **tidak** disertakan di payload mana pun: transkrip rapat satu jam bisa ratusan kilobyte, dan hampir tidak pernah dibutuhkan pada saat event tiba. Ambil terpisah lewat `GET /notes/{id}/transcript` kalau memang perlu.

Beserta header:

```
X-Sehari-Signature: t=1755230000,v1=5a3f…
X-Sehari-Event: note.completed
X-Sehari-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Sehari-Attempt: 1
```

## URL endpoint tidak bisa diubah

`PATCH /webhooks/{id}` menerima `event_types`, `description`, dan `is_active` — tidak `url`. Mengirimnya menghasilkan `invalid_request` dengan pesan `Unrecognized key: "url"`, yang benar tapi buntu kalau Anda tidak tahu aturannya lebih dulu.

Kalau endpoint Anda pindah alamat: daftarkan yang baru, pastikan ia menerima kiriman, lalu hapus yang lama. Riwayat pengiriman melekat pada endpoint, jadi memisahkannya membuat "kiriman ini gagal ke alamat yang mana" tetap bisa dijawab — sesuatu yang hilang begitu satu baris endpoint pernah menunjuk dua alamat berbeda.

Mengaktifkan kembali endpoint yang dinonaktifkan otomatis juga mereset penghitung kegagalan beruntun. Perbaiki dulu penyebabnya: kalau tidak, ia akan dinonaktifkan lagi setelah 20 kegagalan berikutnya.

## Satu event bisa tiba lebih dari sekali

Pengiriman kami bersifat **at-least-once**, bukan exactly-once. Rancanglah penerima Anda dengan asumsi itu sejak awal — bukan setelah duplikat pertama membuat notulen ganda di Slack Anda.

Penyebabnya bukan kesalahan yang bisa kami hilangkan sepenuhnya. Kalau endpoint Anda memproses kiriman dengan benar tapi responsnya tidak sampai kembali ke kami — koneksi putus, timeout tepat setelah Anda menulis ke database, load balancer memutus di tengah — kami hanya tahu bahwa kiriman itu gagal, dan kami mencobanya lagi. Dari sisi kami kedua kemungkinan itu terlihat persis sama.

`X-Sehari-Delivery` adalah kunci deduplikasinya. Nilainya tetap sama di seluruh percobaan untuk kiriman yang sama, dan `X-Sehari-Attempt` memberi tahu percobaan keberapa ini.

```javascript
// Simpan id kiriman sebelum memproses, bukan sesudah. Kalau proses Anda mati di
// tengah, percobaan berikutnya menemukan barisnya dan tahu pekerjaan itu sudah
// diklaim — persis alasan Idempotency-Key ada di sisi request.
const deliveryId = req.headers['x-sehari-delivery'];

const { rowCount } = await db.query(
  'INSERT INTO webhook_diterima (delivery_id) VALUES ($1) ON CONFLICT DO NOTHING',
  [deliveryId]
);

if (rowCount === 0) {
  return res.status(200).end(); // sudah pernah diproses
}

await prosesEvent(req.body);
res.status(200).end();
```

Balas **200** untuk kiriman duplikat, bukan error. Menolaknya membuat kami mencoba lagi, dan cukup banyak percobaan gagal berturut-turut akan menonaktifkan endpoint Anda karena sesuatu yang sebenarnya sudah Anda tangani dengan benar.

## Memverifikasi tanda tangan

Ini bukan langkah opsional. Tanpa verifikasi, siapa pun yang menebak URL endpoint Anda bisa mengirim notulen palsu ke dalam sistem Anda.

Tanda tangan adalah HMAC-SHA256 atas string `"{timestamp}.{body mentah}"` memakai secret Anda.

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifikasi(secret, rawBody, header, toleransi = 300) {
	const bagian = Object.fromEntries(
		header.split(',').map((kv) => {
			const i = kv.indexOf('=');
			return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
		})
	);

	const t = Number(bagian.t);
	if (!Number.isFinite(t) || !bagian.v1) return false;

	// Jendela kesegaran: tanpa ini, satu kiriman yang pernah tercatat bisa
	// diputar ulang selamanya oleh siapa pun yang menyimpannya.
	if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleransi) return false;

	const harapan = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
	const a = Buffer.from(harapan, 'utf8');
	const b = Buffer.from(bagian.v1, 'utf8');

	// Perbandingan constant-time: perbandingan biasa membocorkan berapa
	// karakter awal yang cocok lewat perbedaan waktu eksekusi.
	return a.length === b.length && timingSafeEqual(a, b);
}
```

Tiga hal yang wajib dan sering dilewatkan:

1. **HMAC dihitung dari body MENTAH.** Kalau Anda mem-parse JSON lalu menyusunnya ulang untuk verifikasi, urutan kunci dan spasi tidak dijamin sama, dan setiap verifikasi akan gagal.
2. **Perbandingan harus constant-time.** `===` biasa membocorkan informasi lewat waktu eksekusi.
3. **Jendela kesegaran wajib ada.** Tanpanya, kiriman lama bisa diputar ulang tanpa batas.

Versi PHP:

```php
<?php
function verifikasi(string $secret, string $rawBody, string $header, int $toleransi = 300): bool {
    $bagian = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
        $bagian[trim($k)] = trim($v);
    }

    if (!isset($bagian['t'], $bagian['v1'])) return false;
    if (abs(time() - (int) $bagian['t']) > $toleransi) return false;

    $harapan = hash_hmac('sha256', $bagian['t'] . '.' . $rawBody, $secret);
    return hash_equals($harapan, $bagian['v1']);
}

$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SEHARI_SIGNATURE'] ?? '';

if (!verifikasi(getenv('SEHARI_WEBHOOK_SECRET'), $raw, $sig)) {
    http_response_code(401);
    exit;
}

http_response_code(200);
```

## Jawab cepat, kerjakan belakangan

Kami menunggu respons maksimal 10 detik. Balas `2xx` secepatnya, lalu kerjakan proses beratnya setelah membalas — masukkan ke antrean Anda sendiri, jangan proses di dalam handler.

Endpoint yang lambat akan dianggap gagal dan memicu retry, sehingga Anda menerima kiriman yang sama berkali-kali sementara pekerjaan pertama masih berjalan.

## Menguji sebelum punya server publik

Se-Hari memanggil endpoint Anda, bukan sebaliknya — jadi endpoint itu harus bisa dijangkau dari internet. Ini penghalang pertama bagi hampir semua orang yang baru mulai, karena kodenya masih berjalan di laptop.

Dua cara mengatasinya. Pertama, **tunnel**: alat seperti cloudflared atau ngrok memberi URL https publik yang meneruskan ke port lokal Anda, sehingga kiriman sungguhan sampai ke kode yang sedang Anda debug.

Kedua, tombol **Kirim uji** di tab Webhooks pada [dashboard developer](/dashboard/developer). Tombol itu mengirim event contoh dan menunggu hasilnya, lalu menampilkan kode respons beserta waktu tempuhnya. Berguna untuk memastikan endpoint benar-benar hidup sebelum menunggu event sungguhan yang mungkin baru datang berjam-jam kemudian.

URL localhost ditolak saat pendaftaran, bersama seluruh alamat jaringan privat. Itu bukan untuk merepotkan Anda: tanpa pembatasan itu, endpoint pendaftaran webhook berubah menjadi alat pemindai jaringan internal kami.

## Retry dan penonaktifan

Kiriman yang gagal diulang dengan jeda yang membesar: **1 menit, 5 menit, 30 menit, 2 jam, 6 jam** — enam percobaan, mencakup sekitar 8,5 jam. Cukup untuk melewati pemeliharaan singkat maupun gangguan sepanjang malam.

Setelah **20 pengiriman berturut-turut menyerah**, endpoint dinonaktifkan otomatis dan Anda diberi tahu. Perhatikan satuannya: yang dihitung adalah pengiriman yang sudah habis keenam percobaannya, bukan percobaan satuan — jadi ambang itu berarti sekitar 120 percobaan HTTP yang tersebar berhari-hari.

Angkanya sengaja longgar. Menonaktifkan endpoint yang sebenarnya sehat tapi sedang mengalami gangguan sesaat jauh lebih merugikan daripada mencoba beberapa kali lebih lama, karena event yang lewat selama penonaktifan tidak akan pernah dikirim ulang.

Satu pengiriman yang berhasil mereset penghitungnya ke nol. Dan kalau konsumen menjawab **410 Gone**, endpoint dinonaktifkan saat itu juga tanpa menunggu ambang — 410 adalah pernyataan eksplisit "jangan kirim lagi", dan menghormatinya lebih sopan daripada memaksa lima percobaan lagi.

Kiriman yang masih mengantre saat endpoint dinonaktifkan ditandai gagal, bukan dibiarkan menggantung. Ini terdengar seperti detail, tapi tanpa itu baris-baris tersebut akan terus dipilih ulang oleh penjadwal setiap menit selamanya — dan karena antrean diproses dari yang paling lama jatuh tempo, mereka akan menempati kepala antrean dan menghambat pengiriman milik orang lain.

Aktifkan kembali lewat dashboard setelah endpoint diperbaiki. Penghitung kegagalan direset saat diaktifkan — kalau tidak, endpoint akan langsung mati lagi pada kegagalan berikutnya.

## Menelusuri masalah

Tab **Webhooks** di [dashboard developer](/dashboard/developer) memuat riwayat pengiriman lengkap: kode respons, jumlah percobaan, dan cuplikan balasan endpoint Anda. Ada juga tombol **Kirim uji** yang mengirim event contoh dan **menunggu** hasilnya, sehingga Anda melihat apa yang sebenarnya terjadi alih-alih sekadar "terkirim".

## Selanjutnya

- [n8n](/docs/n8n) — node trigger mendaftarkan webhook-nya sendiri
- [Referensi webhook](/docs/api) — endpoint CRUD lengkap
- [Menangani error](/docs/error) — termasuk kegagalan asinkron