Skip to content

Webhook

The moment something happens in your Byl account — an invoice gets paid, a subscription renews — Byl sends a POST request to your server. That request is a webhook. Instead of repeatedly polling for payment status, you learn about events as they happen and can automate things like confirming orders or granting access.

It works in three steps:

  1. Register an endpoint (a receiving URL) in the dashboard.
  2. When an event occurs, we POST the event's details to that URL.
  3. Your server verifies the request's signature and responds with 2xx.

Registering an endpoint

Register your endpoint URL under your project's Webhooks menu in the dashboard. You can register multiple endpoints per project, up to your plan's limit.

The endpoint URL must:

  • Start with https://.
  • Be reachable from the public internet — localhost and private network addresses will not work.

For local development, you can temporarily expose your local server with a tunnel such as ngrok.

Responding to requests

A delivery counts as successful when your server responds with a 2xx status within 5 seconds. Any other status code, a redirect, or a timeout is treated as a failure and the delivery is retried.

Respond first, process later

If you do slow work (sending emails, calling external APIs, etc.) before responding, you risk exceeding the 5-second window and having a successfully processed event counted as failed. Accept the event, return 200 right away, and process it asynchronously on a queue.

Payload structure

Every event is JSON with the same structure:

FieldDescription
idThe event's unique identifier.
project_idID of the project the event belongs to.
typeThe event type (e.g. invoice.paid).
objectIndicates what kind of object is in data.object (e.g. invoice).
data.objectThe full object the event relates to (an invoice, a checkout, etc.).
created_atWhen the event was created.

See below for examples of each event type.

Guarding against duplicate events

In rare cases the same event may be delivered to you more than once (for example, when your server responded but the response never reached us due to a network error). We recommend storing the id of each processed event and skipping events whose id you have already seen (idempotency).

Do not rely on ordering

Events are not guaranteed to arrive in the order they occurred — retries can cause a later event to arrive before an earlier one. Write your logic so it does not depend on a specific order.

Verifying signatures

Your webhook URL is open to the internet, so anyone could send it a forged request. To protect against this, every request we send includes a signature in the Byl-Signature header — we recommend verifying it on every request.

Verification takes three steps:

1. Take the raw request body. Decoding the JSON and re-encoding it can reorder keys and produce a signature that does not match, so use the body exactly as it arrived.

2. Compute an HMAC-SHA256 signature with your secret. You can find the secret under Signing secret on the endpoint's detail page in the dashboard. The secret is the same across all of your team's projects.

3. Compare your computed signature to the Byl-Signature header. Use a constant-time comparison rather than ===hash_equals() in PHP, crypto.timingSafeEqual() in Node.js.

PHP example:

php
$payload = $request->getContent(); // the raw body. Do not decode and re-encode it.
$computedSignature = hash_hmac('sha256', $payload, $secret);

if (! hash_equals($computedSignature, $request->header('Byl-Signature'))) {
    abort(401);
}

Node.js example:

js
const crypto = require("crypto");

const computedSignature = crypto
  .createHmac("sha256", secret)
  .update(rawRequestBody) // the raw body, not JSON.stringify(req.body)
  .digest("hex");

const isValid = crypto.timingSafeEqual(
  Buffer.from(computedSignature),
  Buffer.from(req.headers["byl-signature"] ?? ""),
);

Watch out for your framework's body parser

Most frameworks (Express's express.json(), Laravel, etc.) parse the body automatically, so you may need extra configuration to access the raw body. In Express, for example: express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }).

If you need complete working sample code, see the following repository:

Automatic retries

When a delivery fails, we automatically retry it with exponentially increasing intervals:

AttemptDelay after previous attempt
25 minutes
330 minutes
42 hours
55 hours
610 hours
724 hours
848 hours

In other words, we make up to 8 attempts over roughly 4 days after the first try. As soon as any attempt succeeds, retries stop.

If every attempt fails, the delivery moves to the failed state and your team is notified by email (at most once a day per endpoint). Once you have fixed the issue, you can, from the dashboard:

  • Open a single delivery and click Resend, or
  • Click Resend failed on the endpoint's page to re-queue every failed delivery at once.

The endpoint's page shows recently sent requests, their payloads, and the response status your server returned. Delivery history is kept for 30 days.

Automatic endpoint disabling

If deliveries to an endpoint fail continuously for 7 days (with not a single successful delivery in that window), we automatically disable the endpoint and notify your team by email.

You can re-enable a disabled endpoint with the Enable button on its dashboard page. Once re-enabled, pending retries resume.

Events during the disabled period are not delivered

Events that occur while an endpoint is disabled are not delivered to it, and they are not sent retroactively after re-enabling. If you receive a disabled-endpoint email, fix the issue and re-enable as soon as you can.

Webhook events

We currently send the following event types.

EventDescription
invoice.paidAn invoice was successfully paid.
invoice.voidAn invoice was voided.
checkout.completedA checkout was successfully paid.
subscription.createdA new subscription was created.
subscription.renewedA subscription's billing period was renewed.
subscription.updatedThe plan was changed, or cancellation was requested or reversed.
subscription.renewal_dueA renewal reminder was sent.
subscription.past_dueThe subscription is overdue (grace period).
subscription.canceledThe subscription was fully canceled.
product.stock_lowA product's stock ran low or out.
payment.awaiting_verificationA customer reported a bank transfer; the merchant has to verify it.
payment.verification_dueAn unverified bank transfer expires in 24 hours.

invoice.paid

When an invoice is successfully paid, an event of type invoice.paid is sent. data.object contains the paid invoice object.

json
{
  "id": 3,
  "project_id": 1,
  "type": "invoice.paid",
  "object": "invoice",
  "data": {
    "object": {
      "id": 71,
      "amount": 10,
      "number": "TEST-0003",
      "status": "paid",
      "due_date": "2025-08-08T16:14:07.000000Z",
      "created_at": "2025-08-07T16:14:07.000000Z",
      "project_id": 1,
      "updated_at": "2025-08-07T16:14:16.000000Z",
      "description": "First invoice",
      "url": "https://byl.mn/h/invoice/5708/XN3GbRBxTslkMCeDj10CJtqlHiPfcmZ8"
    }
  },
  "created_at": "2025-08-07T16:14:16.000000Z",
  "updated_at": "2025-08-07T16:14:16.000000Z"
}

checkout.completed

When a checkout is successfully paid, an event of type checkout.completed is sent. data.object contains the paid checkout object.

json
{
  "id": 59,
  "project_id": 5,
  "type": "checkout.completed",
  "object": "checkout",
  "data": {
    "object": {
      "id": 13338,
      "project_id": 5,
      "mode": "payment",
      "status": "complete",
      "url": "https://byl.mn/h/checkout/13338/Yi7smBuk",
      "is_guest": false,
      "customer": {
        "id": 12,
        "client_reference_id": "user_842",
        "name": "Bat-Erdene"
      },
      "amount_total": "27000.000000000000",
      "amount_subtotal": "30000.000000000000",
      "customer_email": "[email protected]",
      "client_reference_id": "order_1042",
      "subscription_id": null,
      "items": [
        {
          "product": {
            "id": 7,
            "name": "Starter plan",
            "client_reference_id": "starter"
          },
          "price": {
            "id": 3,
            "type": "recurring",
            "unit_amount": 30000,
            "recurring_interval": "month",
            "recurring_interval_count": 1,
            "lookup_key": "starter_monthly"
          },
          "quantity": 1,
          "amount_unit": 30000,
          "amount_subtotal": 30000,
          "amount_total": 30000,
          "adjustable_quantity": false
        }
      ],
      "phone_number_collection": true,
      "phone_number": "99999999",
      "email_collection": true,
      "delivery_address_collection": false,
      "delivery_address": null,
      "custom_fields": [
        {
          "key": "company",
          "label": "Company name",
          "type": "text",
          "optional": false,
          "value": "Kitelab LLC"
        },
        {
          "key": "seat",
          "label": "Seat",
          "type": "dropdown",
          "optional": true,
          "dropdown": {
            "options": [
              { "label": "VIP", "value": "vip" },
              { "label": "Regular", "value": "regular" }
            ]
          },
          "value": "vip"
        }
      ],
      "coupon_codes": [
        {
          "code": "SUMMER2026",
          "coupon_name": "Summer sale",
          "discount_amount": "3000.00",
          "redeemed_at": "2026-08-14T09:30:00.000000Z"
        }
      ],
      "expires_at": "2026-08-14T16:00:00.000000Z",
      "created_at": "2026-08-14T09:12:00.000000Z",
      "updated_at": "2026-08-14T09:30:00.000000Z"
    }
  },
  "created_at": "2026-08-14T09:30:00.000000Z",
  "updated_at": "2026-08-14T09:30:00.000000Z"
}

Fields worth noting:

  • customernull when the checkout has no customer attached (guest checkout).
  • subscription_id — only set for renewal or plan-switch checkouts. It is null on a checkout that starts a new subscription; the subscription.created event arrives separately once the subscription exists.
  • items[].price.lookup_key — your own identifier for the price, or null if you did not set one. Keying your plan logic on this instead of price.id means you can swap the price later without touching your code.
  • items[].price.id and items[].product.idnull for ad-hoc items created with price_data instead of a stored price.
  • amount_total and amount_subtotalstrings on the checkout (decimal values), numbers on each item. amount_subtotal is the amount before discounts and amount_total is what was actually paid; item amounts are always pre-discount.

The coupon_codes field contains details of the promotion codes applied to the checkout. If no promotion code was used, this field is an empty array.

The custom_fields field lists the custom questions defined on the checkout (via a checkout link or the API) together with the customer's answer in value. For dropdowns, value is the option's value, not its label; an optional field left blank has a null value. When no custom fields were defined, this is an empty array.

Each element of the coupon_codes array contains the following fields:

  • code: The promotion code
  • coupon_name: The coupon's name
  • discount_amount: The discount amount
  • redeemed_at: When the code was redeemed

subscription.*

Whenever a subscription's state changes, an event prefixed with subscription. is sent (see the table above for the types). data.object contains the subscription object along with customer, product, and price details.

json
{
  "id": 87,
  "project_id": 1,
  "type": "subscription.renewed",
  "object": "subscription",
  "data": {
    "object": {
      "id": 4,
      "project_id": 1,
      "status": "active",
      "customer": {
        "id": 12,
        "client_reference_id": "user_842",
        "name": "Бат-Эрдэнэ",
        "email": "[email protected]"
      },
      "product": {
        "id": 7,
        "name": "Starter багц",
        "client_reference_id": null
      },
      "price": {
        "id": 3,
        "type": "recurring",
        "unit_amount": 30000,
        "recurring_interval": "month",
        "recurring_interval_count": 1,
        "lookup_key": "starter_monthly"
      },
      "current_period_start": "2026-07-23T04:10:00.000000Z",
      "current_period_end": "2026-08-23T15:59:59.000000Z",
      "trial_ends_at": null,
      "canceled_at": null,
      "is_test": false,
      "created_at": "2026-06-23T04:10:00.000000Z",
      "updated_at": "2026-07-23T04:10:00.000000Z"
    }
  },
  "created_at": "2026-07-23T04:10:00.000000Z",
  "updated_at": "2026-07-23T04:10:00.000000Z"
}

Revoke the customer's access when subscription.canceled arrives — access remains valid until the end of the billing period even after cancellation is requested, so do not revoke access on subscription.updated (with canceled_at populated). Read more on the Subscriptions page.

product.stock_low

When the stock of a product with a stock limit crosses a threshold (5 or below, 0, or negative), an event of type product.stock_low is sent. data.object contains a summary of the product and the remaining stock.

json
{
  "id": 112,
  "project_id": 1,
  "type": "product.stock_low",
  "object": "product",
  "data": {
    "object": {
      "id": 7,
      "project_id": 1,
      "name": "Handmade vase",
      "client_reference_id": null,
      "stock": 4
    }
  },
  "created_at": "2026-08-14T09:30:00.000000Z",
  "updated_at": "2026-08-14T09:30:00.000000Z"
}

Repeated sales inside the low zone don't re-fire the event — it is sent once per boundary crossing. Read more on the Product stock page.

payment.awaiting_verification

When a customer paying by bank transfer clicks "I've transferred", an event of type payment.awaiting_verification is sent. data.object carries the payment, the reference code and the checkout/invoice it belongs to. This event does not mean the money arrived — once the merchant checks their statement and confirms, the regular checkout.completed / invoice.paid event follows.

json
{
  "id": 118,
  "project_id": 1,
  "type": "payment.awaiting_verification",
  "object": "payment",
  "data": {
    "object": {
      "id": "9d2f6c1e-1b1c-4a2e-9c7d-2d1f4a8b9e10",
      "project_id": 1,
      "status": "pending",
      "driver": "bank_transfer",
      "amount": 45000,
      "description": "Checkout - 231",
      "reference": "482913",
      "bank_name": "Хаан банк",
      "account_number": "5012345678",
      "claimed_at": "2026-08-27T09:30:00.000000Z",
      "expires_at": "2026-08-30T09:30:00.000000Z",
      "payable": {
        "type": "checkout",
        "id": 231,
        "url": "https://byl.mn/h/checkout/231/…",
        "status": "pending"
      },
      "customer_email": "[email protected]",
      "phone_number": "99112233",
      "is_test": false,
      "created_at": "2026-08-27T09:20:00.000000Z"
    }
  },
  "created_at": "2026-08-27T09:30:00.000000Z",
  "updated_at": "2026-08-27T09:30:00.000000Z"
}

payable.type is checkout or invoice (invoices also carry a number).

payment.verification_due

While a reported bank transfer is still unverified, a payment.verification_due event is sent once, 24 hours before the waiting window closes. data.object has the same shape as payment.awaiting_verification; expires_at is the end of the window.