# Webhooks

Tell your system automatically about every order, registration, cancellation or check-in.

> Source: https://help.nyebar.id/en/organizer/integrations/webhooks

With webhooks, Nyebar tells your system as soon as something happens, for example to add buyers to your CRM, send a welcome WhatsApp message from your own system, or update an attendance board. Your system doesn't have to keep asking the API.

Webhooks are set up by the owner or an Admin in the **Integrations** menu. The last part of this article is for the developer who sets up the receiver.

## Events you can send

| Event | When it's sent |
| --- | --- |
| **Order confirmed** (`order.confirmed`) | An order is paid (or free) and its tickets are issued. This includes free registrations you approve. |
| **Registration received** (`registration.received`) | A new registration waits for your approval. |
| **Order cancelled** (`order.cancelled`) | A registration is rejected, or an order expires or its payment fails. |
| **Ticket checked in** (`ticket.checked_in`) | A ticket is checked in, from the dashboard, the crew link or the API. |

## Adding a webhook

1. Open **Integrations**, then under **Webhooks** click **Add webhook**.

   ![The Webhooks section of the Integrations page with the list of webhooks and the Add webhook button.](https://help.nyebar.id/screenshots/en/org-webhooks.webp "Your webhooks on the Integrations page.")

2. Fill in the **Endpoint URL**, the address in your system that receives deliveries. It must be https and reachable from the internet.
3. Pick the **Events to send**, then click **Save**.

   ![The Add webhook window with the endpoint URL, description and event choices.](https://help.nyebar.id/screenshots/en/org-webhook-form.webp "Enter the URL and pick the events to send.")

4. Copy the **secret** that appears and set it up in your system to verify deliveries. You can also see the secret again on the webhook's page.

An organizer can have up to 5 webhooks.

## Watching deliveries

In the webhook list, each row has two icons:

- **Deliveries** (clock icon): deliveries from the last 30 days. Click one to see what was sent and how your system answered, and **Send again** if needed.
- **Settings** (gear icon): change the URL, description or events with **Edit Endpoint**, turn the webhook off for a while, see or rotate the **Signing secret**, and delete the webhook.

At the top of both pages, **Send test** checks that your endpoint receives deliveries correctly.

![A webhook's deliveries with their status, response code and attempts.](https://help.nyebar.id/screenshots/en/org-webhook-detail.webp "A webhook's deliveries.")

If your system doesn't answer successfully, Nyebar retries automatically after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. If deliveries to a webhook keep failing for over a day, the webhook is turned off automatically and the owner and Admins get a notification in the dashboard. Once the endpoint is fixed, send a test and turn it back on. Events from while it was off are still sent once it's back on, as long as they're less than 30 days old.

## For developers

### Delivery format

Nyebar sends a `POST` with JSON to your URL, with these headers:

| Header | Value |
| --- | --- |
| `Nyebar-Event-Id` | The event ID, for example `evt_1042`. The same for every retry. |
| `Nyebar-Event-Type` | The event type, for example `order.confirmed`. |
| `Nyebar-Signature` | The signature, `t=<unix time>,v1=<hex>`. |

```json
{
  "id": "evt_1042",
  "type": "order.confirmed",
  "created_at": "2026-10-11T14:36:46Z",
  "data": {
    "event": { "id": 134, "title": "Nusantara City Run 10K", "slug": "nusantara-city-run-10k" },
    "order": {
      "id": 1230,
      "code": "ORDRHMRQERV",
      "status": "confirmed",
      "buyer_name": "Andi Nasution",
      "buyer_email": "andi@example.com",
      "ticket_subtotal": 200000,
      "addon_subtotal": 0,
      "discount_amount": 0,
      "platform_fee_amount": 0,
      "total_price": 200000,
      "created_at": "2026-10-11T14:30:02Z"
    },
    "tickets": [
      {
        "id": 1583,
        "code": "NYE8KRXGKU8",
        "holder_name": "Andi Nasution",
        "holder_email": "andi@example.com",
        "ticket_type": { "id": 235, "name": "Reguler" },
        "bib_number": null,
        "checked_in_at": null,
        "created_at": "2026-10-11T14:36:46Z"
      }
    ]
  }
}
```

The `data` of each event:

- `order.confirmed`: `event`, `order` and the issued `tickets`.
- `registration.received`: `event` and `order` (status `awaiting_approval`).
- `order.cancelled`: `event`, `order` and `reason`: `rejected`, `expired` (the approval or payment deadline passed), `payment_failed` or `payment_expired`.
- `ticket.checked_in`: `event`, `ticket` and `source`: `dashboard`, `crew_link`, `api` or `support`.

`order` and `ticket` have the same shape as in the [Organizer API](/en/organizer/integrations/organizer-api).

### Answering deliveries

Answer with a 2xx status within 10 seconds. If your system needs longer, store the delivery first, answer 200, and process it afterwards. Any other status, a timeout or a redirect counts as a failure and is retried.

A delivery can arrive more than once (for example on a retry), and the order isn't guaranteed. Use the event `id` to skip deliveries you've already processed, and `created_at` for ordering.

### Verifying the signature

Compute the HMAC-SHA256 of `<t>.<raw body>` with the webhook's secret and compare it with the `v1` value. Also reject deliveries whose `t` is more than 5 minutes from now.

```js
// Node.js (Express): use the raw body, not the result of JSON.parse
const crypto = require("crypto");

app.post("/webhooks/nyebar", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Nyebar-Signature") || "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", process.env.NYEBAR_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  if (!fresh || !parts.v1 || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  // ... handle the event ...
  res.sendStatus(200);
});
```

```php
<?php
// PHP
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_NYEBAR_SIGNATURE'] ?? ''), $parts);
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, getenv('NYEBAR_WEBHOOK_SECRET'));
if (abs(time() - (int) ($parts['t'] ?? 0)) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit;
}
$event = json_decode($body, true);
// ... handle the event ...
http_response_code(200);
```

```python
# Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/nyebar")
def nyebar_webhook():
    parts = dict(p.split("=", 1) for p in request.headers.get("Nyebar-Signature", "").split(",") if "=" in p)
    body = request.get_data()
    expected = hmac.new(os.environ["NYEBAR_WEBHOOK_SECRET"].encode(), parts.get("t", "").encode() + b"." + body, hashlib.sha256).hexdigest()
    if abs(time.time() - int(parts.get("t", 0))) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)
    event = request.get_json()
    # ... handle the event ...
    return "", 200
```

If you rotate the secret with **Rotate secret**, the old secret stops working right away. Update the secret in your system right after.
