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

# Menerima Webhook

> Cara membuat endpoint penerima webhook Simplus, mencobanya di lokal, dan memprosesnya dengan aman sampai production.

Buat endpoint di server kamu untuk menerima, memverifikasi, dan memproses webhook Simplus dari awal sampai siap production.

## Alur singkat

1. Kamu membuat endpoint `POST` di server, misalnya `/webhooks/simplus`.
2. Simplus mengirim pesan JSON ke endpoint itu, lengkap dengan header `svix-id`, `svix-timestamp`, dan `svix-signature`.
3. Endpoint kamu membaca **raw body**, memverifikasi signature dengan **Signing Secret**, lalu membalas `2xx` secepatnya.
4. Pekerjaan berat (simpan ke database, sinkronisasi ke ERP, dan sebagainya) dijalankan di background lewat queue.

## Membuat endpoint penerima

Siapkan dulu Signing Secret endpoint kamu dari portal webhook (lihat [Mengaktifkan Webhook](/integration/webhook/mengaktifkan)), lalu simpan di environment variable:

```bash .env theme={null}
SIMPLUS_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
```

Contoh di bawah memakai library resmi [Svix](https://docs.svix.com/receiving/verifying-payloads/how). Semua contoh melakukan hal yang sama:

* Membaca raw body persis seperti yang dikirim.
* Memverifikasi signature. Kalau gagal, membalas `400`.
* Menangani event `sales.persisted`.
* Membalas `204` secepatnya, lalu memproses data di background.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  // npm install express svix
  import express from "express";
  import { Webhook, WebhookVerificationError } from "svix";

  const app = express();
  const wh = new Webhook(process.env.SIMPLUS_WEBHOOK_SECRET); // whsec_...

  // Pakai express.raw() khusus untuk route ini, jangan express.json()
  app.post("/webhooks/simplus", express.raw({ type: "application/json" }), (req, res) => {
    const payload = req.body.toString("utf8"); // raw body
    try {
      wh.verify(payload, {
        "svix-id": req.header("svix-id"),
        "svix-timestamp": req.header("svix-timestamp"),
        "svix-signature": req.header("svix-signature"),
      });
    } catch (err) {
      if (err instanceof WebhookVerificationError) {
        return res.status(400).send("Invalid signature");
      }
      throw err;
    }

    // Parse JSON hanya setelah signature valid
    const event = JSON.parse(payload);

    // Balas dulu, baru proses
    res.sendStatus(204);

    if (event.type === "sales.persisted") {
      enqueue(req.header("svix-id"), event.data);
    }
  });

  function enqueue(messageId, sale) {
    // Di production, kirim ke queue (misalnya BullMQ, SQS, atau RabbitMQ)
    setImmediate(() => console.log("Transaksi diterima:", messageId, sale.id, sale.order_number, sale.paid_amount));
  }

  app.listen(3000, () => console.log("Listening on http://localhost:3000"));
  ```

  ```php PHP (Laravel) theme={null}
  <?php
  // composer require svix/svix
  //
  // config/services.php:
  //   'simplus' => ['webhook_secret' => env('SIMPLUS_WEBHOOK_SECRET')],
  //
  // routes/api.php (route API tidak memakai CSRF):
  //   Route::post('/webhooks/simplus', \App\Http\Controllers\SimplusWebhookController::class);

  namespace App\Http\Controllers;

  use App\Jobs\ProcessSimplusSale;
  use Illuminate\Http\Request;
  use Svix\Exception\WebhookVerificationException;
  use Svix\Webhook;

  class SimplusWebhookController extends Controller
  {
      public function __invoke(Request $request)
      {
          $payload = $request->getContent(); // raw body
          $headers = [
              'svix-id' => $request->header('svix-id'),
              'svix-timestamp' => $request->header('svix-timestamp'),
              'svix-signature' => $request->header('svix-signature'),
          ];

          try {
              $wh = new Webhook(config('services.simplus.webhook_secret'));
              $wh->verify($payload, $headers);
          } catch (WebhookVerificationException $e) {
              return response('Invalid signature', 400);
          }

          $event = json_decode($payload, true);

          if (($event['type'] ?? null) === 'sales.persisted') {
              // Proses berat di queue, bukan di request ini
              ProcessSimplusSale::dispatch($headers['svix-id'], $event['data']);
          }

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

  ```php PHP (native) theme={null}
  <?php
  // composer require svix/svix
  require __DIR__ . '/vendor/autoload.php';

  use Svix\Exception\WebhookVerificationException;
  use Svix\Webhook;

  $payload = file_get_contents('php://input'); // raw body
  $headers = [
      'svix-id' => $_SERVER['HTTP_SVIX_ID'] ?? '',
      'svix-timestamp' => $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '',
      'svix-signature' => $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '',
  ];

  try {
      $wh = new Webhook(getenv('SIMPLUS_WEBHOOK_SECRET'));
      $wh->verify($payload, $headers);
  } catch (WebhookVerificationException $e) {
      http_response_code(400);
      exit('Invalid signature');
  }

  $event = json_decode($payload, true);

  if (($event['type'] ?? null) === 'sales.persisted') {
      // Simpan ke tabel antrian, lalu proses dengan worker/cron terpisah
      // queue_push($headers['svix-id'], $event['data']);
  }

  http_response_code(204);
  ```

  ```python Python (FastAPI) theme={null}
  # pip install fastapi uvicorn svix
  # Jalankan: uvicorn main:app --port 8000
  import json
  import os

  from fastapi import BackgroundTasks, FastAPI, Request, Response
  from svix.webhooks import Webhook, WebhookVerificationError

  app = FastAPI()
  wh = Webhook(os.environ["SIMPLUS_WEBHOOK_SECRET"])  # whsec_...


  def process_sale(message_id: str, sale: dict) -> None:
      # Di production, kirim ke queue (misalnya Celery, RQ, atau SQS)
      print("Transaksi diterima:", message_id, sale["id"], sale["order_number"], sale["paid_amount"])


  @app.post("/webhooks/simplus")
  async def simplus_webhook(request: Request, background_tasks: BackgroundTasks):
      payload = await request.body()  # raw body (bytes)

      try:
          wh.verify(payload, request.headers)
      except WebhookVerificationError:
          return Response("Invalid signature", status_code=400)

      event = json.loads(payload)  # parse setelah signature valid

      if event["type"] == "sales.persisted":
          background_tasks.add_task(process_sale, request.headers["svix-id"], event["data"])

      return Response(status_code=204)
  ```

  ```python Python (Flask) theme={null}
  # pip install flask svix
  import json
  import os

  from flask import Flask, request
  from svix.webhooks import Webhook, WebhookVerificationError

  app = Flask(__name__)
  wh = Webhook(os.environ["SIMPLUS_WEBHOOK_SECRET"])  # whsec_...


  @app.post("/webhooks/simplus")
  def simplus_webhook():
      payload = request.get_data()  # raw body (bytes)
      headers = {
          "svix-id": request.headers.get("svix-id"),
          "svix-timestamp": request.headers.get("svix-timestamp"),
          "svix-signature": request.headers.get("svix-signature"),
      }

      try:
          wh.verify(payload, headers)
      except WebhookVerificationError:
          return "Invalid signature", 400

      event = json.loads(payload)  # parse setelah signature valid

      if event["type"] == "sales.persisted":
          # Kirim ke queue (misalnya Celery atau RQ), jangan proses di sini
          print("Transaksi diterima:", headers["svix-id"], event["data"]["id"])

      return "", 204
  ```
</CodeGroup>

<Warning>
  Verifikasi selalu dari **raw body**. Kalau body sudah di-parse lalu di-stringify ulang (misalnya lewat `express.json()` atau `$request->all()`), urutan atau spasi bisa berubah dan verifikasi akan gagal.
</Warning>

Mau tahu apa yang terjadi di dalam library? Lihat [Verifikasi Signature](/integration/webhook/verifikasi-signature) untuk cara kerjanya dan contoh verifikasi tanpa library.

## Mencoba di lokal

Portal webhook hanya bisa mengirim ke URL publik, jadi `localhost` perlu dibuka ke internet dulu. Pilih salah satu cara berikut.

<Tabs>
  <Tab title="Svix CLI">
    [Svix CLI](https://github.com/svix/svix-webhooks/tree/main/svix-cli) bisa membuat URL publik sementara yang meneruskan semua request ke server lokal kamu. Tidak perlu akun Svix.

    ```bash theme={null}
    # Install (pilih salah satu)
    npm install -g svix-cli
    brew install svix/svix/svix

    # Teruskan ke server lokal kamu
    svix listen http://localhost:3000/webhooks/simplus
    ```

    CLI akan menampilkan URL publik dengan format `https://play.svix.com/in/<id>/`, plus link untuk melihat log request. Pakai URL `https://play.svix.com/in/...` itu sebagai **Endpoint URL** di portal.
  </Tab>

  <Tab title="ngrok">
    Kalau sudah memakai [ngrok](https://ngrok.com), jalankan:

    ```bash theme={null}
    ngrok http 3000
    ```

    Salin URL `https` yang muncul, lalu tambahkan path endpoint kamu, misalnya `https://xxxx.ngrok-free.app/webhooks/simplus`.
  </Tab>

  <Tab title="Svix Play (tanpa kode)">
    Mau lihat bentuk payload dulu sebelum menulis kode? Buka [Svix Play](https://www.svix.com/play/). Gratis dan tanpa daftar. Kamu akan mendapat URL unik `https://play.svix.com/in/<id>/` yang menampilkan semua request yang masuk, lengkap dengan header dan body.

    Di form **New Endpoint** portal webhook, kamu juga bisa klik link **with Svix Play** di bawah kolom **Endpoint URL**.
  </Tab>
</Tabs>

Setelah punya URL publik:

<Steps>
  <Step title="Daftarkan URL di portal">
    Buka portal webhook, klik **Add Endpoint**, isi **Endpoint URL** dengan URL publik tadi, centang `sales.persisted`, lalu klik **Create**. Detailnya ada di [Mengaktifkan Webhook](/integration/webhook/mengaktifkan#mendaftarkan-endpoint-di-portal).
  </Step>

  <Step title="Pakai Signing Secret endpoint ini">
    Setiap endpoint punya Signing Secret sendiri. Salin Signing Secret endpoint baru ini ke `SIMPLUS_WEBHOOK_SECRET` di lokal, lalu restart server.
  </Step>

  <Step title="Kirim contoh event">
    Di halaman endpoint, buka tab **Testing**, pilih `sales.persisted`, lalu klik **Send Example**. Server lokal kamu akan menerima contoh payload, dan statusnya muncul di **Message Attempts**.
  </Step>
</Steps>

<Tip>
  Hapus endpoint uji coba dari portal setelah selesai, supaya event asli tidak terkirim ke URL sementara.
</Tip>

## Checklist sebelum production

* **HTTPS saja.** Endpoint production harus memakai `https`.
* **Selalu verifikasi signature.** Jangan percaya isi payload sebelum signature valid.
* **Balas `2xx` dalam beberapa detik.** Svix memberi waktu sekitar [15 detik](https://docs.svix.com/retries). Lebih dari itu dianggap gagal, jadi pindahkan pekerjaan berat ke queue.
* **Idempotent dengan `svix-id`.** Pesan yang sama bisa diterima lebih dari sekali karena retry. Simpan `svix-id` yang sudah diproses, dan lewati kalau datang lagi.
* **Simpan versi terbaru.** Satu transaksi bisa memicu beberapa event `sales.persisted`, dan urutan sampainya tidak dijamin. Setiap event berisi data transaksi lengkap, jadi simpan berdasarkan `data.id`. Kalau urutan penting, ambil ulang transaksi lewat API Simplus sebelum menyimpan.
* **Siap untuk retry.** Pesan yang gagal dicoba ulang otomatis dengan jeda yang makin lama. Jadwal lengkapnya ada di [dokumentasi retry Svix](https://docs.svix.com/retries).
* **Simpan secret dengan aman.** Taruh Signing Secret di environment variable atau secret manager. Kalau bocor, rotasi secret dari halaman endpoint di portal webhook, lalu update server kamu.
* **Ambil ulang data kalau perlu.** Untuk data yang sangat penting, ambil ulang transaksi dari API Simplus memakai [API Key](/integration/api-key) sebelum diproses.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Verifikasi selalu gagal (Invalid signature)">
    * **Body sudah di-parse.** Pastikan kamu memakai raw body: `express.raw()` di Express, `$request->getContent()` di Laravel, `await request.body()` di FastAPI, `request.get_data()` di Flask.
    * **Secret salah.** Setiap endpoint punya Signing Secret sendiri. Pastikan secret yang dipakai berasal dari endpoint yang menerima pesan, termasuk prefix `whsec_`.
    * **Endpoint tertukar.** Kalau kamu punya beberapa endpoint (misalnya lokal dan production), cek URL di portal dan secret di server sesuai pasangannya.
    * **Jam server tidak sinkron.** Pesan dengan timestamp lebih dari sekitar 5 menit dari jam server akan ditolak. Aktifkan sinkronisasi waktu (NTP) di server kamu.
  </Accordion>

  <Accordion title="Endpoint dinonaktifkan otomatis">
    Kalau semua pengiriman ke endpoint gagal terus dalam waktu lama, endpoint bisa dinonaktifkan. Perbaiki penyebabnya dulu (cek response di **Message Attempts**), aktifkan kembali endpoint di portal, lalu kirim ulang pesan yang gagal dengan **Replay**.
  </Accordion>

  <Accordion title="Tidak ada pesan yang masuk">
    * **Webhook belum aktif.** Pastikan toggle **Webhook** sudah aktif di aplikasi Simplus (lihat [Mengaktifkan Webhook](/integration/webhook/mengaktifkan)).
    * **Filter event.** Cek **Subscribed events** di halaman endpoint. Pastikan `sales.persisted` tercentang.
    * **URL tidak bisa diakses.** Pastikan URL publik, memakai `https`, dan tidak diblokir firewall. Coba kirim dari tab **Testing** lalu cek **Message Attempts**.
    * **Route salah.** Pastikan path dan method (`POST`) di server sama persis dengan URL di portal.
  </Accordion>
</AccordionGroup>

## Bagaimana Simplus bisa membantu

Dengan webhook Simplus, setiap transaksi dari kasir langsung sampai ke sistem kamu tanpa polling. Pengiriman, retry, signature, dan log sudah diurus otomatis, jadi kamu cukup fokus memproses data, misalnya untuk sinkronisasi ke software akuntansi, ERP, atau dashboard internal.

## Langkah berikutnya

<CardGroup cols={2}>
  <Card title="Verifikasi Signature" icon="shield-check" href="/integration/webhook/verifikasi-signature">
    Pahami cara kerja signature dan verifikasi tanpa library.
  </Card>

  <Card title="Event sales.persisted" icon="receipt" href="https://registry.scalar.com/@simplus/apis/public#tag/webhook-overview">
    Lihat struktur payload transaksi penjualan.
  </Card>
</CardGroup>


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