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.

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.

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.

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.
business_id ID of the business the project runs under: a client’s for a platform team, otherwise the team’s own.
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.

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"] ?? ""),
);

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

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.

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.

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.

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,
"business_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"
}

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,
"business_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 — null 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.id — null for ad-hoc items created with price_data instead of a stored price.
  • amount_total and amount_subtotal — strings 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

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,
"business_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.

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,
"business_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.

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,
"business_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).

Confirm or reject the transfer in the dashboard or through the Bank transfers API; this is how a platform team confirms its clients’ transfers from its own UI.

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.