# Webhooks

> Get a signed HTTPS request when a sale, order, stock level, product or customer changes, and verify it in Node or PHP.

Source: https://docs.bakasir.com/en/docs/developers/webhooks

A webhook is a request Bakasir sends to your server when something changes, so you do not need to poll the API. Add endpoints in **Settings → Developer → Webhooks**. A business can have up to 20 endpoints, and each endpoint chooses the events it wants.

## Requirements for the endpoint

- The address must start with `https://` and point to a public server. Addresses on private networks, `localhost` and cloud metadata addresses are refused.
- Bakasir does not follow redirects. Answer on the exact address you saved.
- Reply with any `2xx` status within 10 seconds. Anything else counts as a failure.

## Events

| Event | Sent when |
| --- | --- |
| `sale.created` | A sale is recorded in any channel |
| `sale.updated` | A sale changes, for example it is completed, voided or refunded |
| `order.created` | An online order is placed |
| `order.updated` | An online order changes status or payment |
| `stock.updated` | The stock of a product in an outlet changes |
| `product.created`, `product.updated`, `product.deleted` | A product is created, edited or removed |
| `customer.created`, `customer.updated`, `customer.deleted` | A customer is created, edited or removed |

You can also subscribe to a whole group such as `product.*`, or to everything with `*`. Changes made in the Demo outlet never send events. Several changes to the same record inside one operation are sent as one event.

## The request

Bakasir sends a `POST` with a JSON body:

```json
{
  "id": "evt_5f2b0c7e91d34a0c8d1e6b27",
  "type": "product.updated",
  "created_at": "2026-11-04T08:30:00Z",
  "data": {
    "id": "01K9ZT6Q2X3M8N4V7B5C1D0EFG",
    "name": "Es Kopi Susu",
    "price": 24000
  }
}
```

`data` has the same shape as the matching API object (a product, a sale with its items, an online order with its lines, a customer). For `stock.updated` it is the stock row with `store_id`, `product_id`, `variant_id`, `qty` and `avg_cost`. For a deleted record it holds only the `id`. The data is read when the first delivery is attempted and then kept, so retries send the same body.

Headers:

| Header | Value |
| --- | --- |
| `Bakasir-Event` | The event type |
| `Bakasir-Event-Id` | The event id, the same as `id` in the body. Use it to ignore duplicates |
| `Bakasir-Delivery` | The delivery id |
| `Bakasir-Attempt` | 1 for the first try, then 2, 3 and so on |
| `Bakasir-Signature` | `t=<unix time>,v1=<hex signature>` |

## Verify the signature

Every request is signed with the secret of the endpoint, shown once when you create it or rotate it. Compute an HMAC SHA-256 of the text `<t>.<raw body>` with the secret and compare it with `v1` in constant time. Use the raw body exactly as received, before any JSON parsing, and reject requests whose `t` is more than five minutes old.

Node.js:

```js
import crypto from "node:crypto";

export function verifyBakasir(rawBody, header, secret, toleranceSeconds = 300) {
    const parts = Object.fromEntries(header.split(",").map((pair) => pair.split("=")));
    if (!parts.t || !parts.v1) return false;
    if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
    const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
    const given = Buffer.from(parts.v1, "hex");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
```

With Express, read the raw body with `express.raw({ type: "application/json" })` and pass `req.body.toString("utf8")`.

PHP:

```php
<?php

function verifyBakasir(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(",", $header) as $pair) {
        [$key, $value] = array_pad(explode("=", $pair, 2), 2, "");
        $parts[$key] = $value;
    }
    if (!isset($parts["t"], $parts["v1"]) || abs(time() - (int) $parts["t"]) > $tolerance) {
        return false;
    }
    $expected = hash_hmac("sha256", $parts["t"] . "." . $rawBody, $secret);
    return hash_equals($expected, $parts["v1"]);
}

$raw = file_get_contents("php://input");
$signature = $_SERVER["HTTP_BAKASIR_SIGNATURE"] ?? "";
if (!verifyBakasir($raw, $signature, getenv("BAKASIR_WEBHOOK_SECRET"))) {
    http_response_code(401);
    exit;
}
http_response_code(204);
```

## Retries and the delivery log

If your endpoint fails or times out, Bakasir tries again 6 times, so up to 7 tries in total over about 21 hours: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. Order is not guaranteed and an event can arrive more than once, so use `Bakasir-Event-Id` to ignore repeats and use `created_at` to ignore an older state.

After the last try fails, the delivery is marked failed and the owner receives a notification. You can retry a failed delivery by hand.

**Settings → Developer → Deliveries** lists every delivery for 30 days with its status, number of tries, the answer of your server and the last error. Use **Send test** on an endpoint to send a signed `webhook.test` event right now.

## Rotate or stop

- **Rotate secret** issues a new secret at once. Update your server first or briefly accept both.
- Turn an endpoint off to pause it, or delete it to stop all events and remove its log.
- If you reset your business to factory settings, all keys and endpoints are removed.
