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:
- Register an endpoint (a receiving URL) in the dashboard.
- When an event occurs, we
POSTthe event's details to that URL. - 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 —
localhostand 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:
| Field | Description |
|---|---|
id | The event's unique identifier. |
project_id | ID of the project the event belongs to. |
type | The event type (e.g. invoice.paid). |
object | Indicates what kind of object is in data.object (e.g. invoice). |
data.object | The full object the event relates to (an invoice, a checkout, etc.). |
created_at | When 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:
$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:
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:
- Node.js — Express example
Automatic retries
When a delivery fails, we automatically retry it with exponentially increasing intervals:
| Attempt | Delay after previous attempt |
|---|---|
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 5 hours |
| 6 | 10 hours |
| 7 | 24 hours |
| 8 | 48 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.
| Event | Description |
|---|---|
invoice.paid | An invoice was successfully paid. |
invoice.void | An invoice was voided. |
checkout.completed | A checkout was successfully paid. |
subscription.created | A new subscription was created. |
subscription.renewed | A subscription's billing period was renewed. |
subscription.updated | The plan was changed, or cancellation was requested or reversed. |
subscription.renewal_due | A renewal reminder was sent. |
subscription.past_due | The subscription is overdue (grace period). |
subscription.canceled | The subscription was fully canceled. |
product.stock_low | A product's stock ran low or out. |
payment.awaiting_verification | A customer reported a bank transfer; the merchant has to verify it. |
payment.verification_due | An 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.
{
"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.
{
"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:
customer—nullwhen the checkout has no customer attached (guest checkout).subscription_id— only set for renewal or plan-switch checkouts. It isnullon a checkout that starts a new subscription; thesubscription.createdevent arrives separately once the subscription exists.items[].price.lookup_key— your own identifier for the price, ornullif you did not set one. Keying your plan logic on this instead ofprice.idmeans you can swap the price later without touching your code.items[].price.idanditems[].product.id—nullfor ad-hoc items created withprice_datainstead of a stored price.amount_totalandamount_subtotal— strings on the checkout (decimal values), numbers on each item.amount_subtotalis the amount before discounts andamount_totalis 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 codecoupon_name: The coupon's namediscount_amount: The discount amountredeemed_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.
{
"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.
{
"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.
{
"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.