Log in

Guide 1928 min readUpdated

YallaBuy merchant webhooks — receive store events on your server

YallaBuy merchant webhooks push events to your server the moment they happen: a new order, a status change, a return or refund, a product edit, a stock movement, a redeemed coupon, a kitchen transition, a closed shift — a signed JSON POST to every endpoint you registered. This guide covers everything: creating an endpoint, the 17-type catalog, the envelope and payload shapes, verifying the signature, and handling retries and duplicates.

On this page

1.Overview — events come to you instead of you asking

Instead of your server polling for news, YallaBuy merchant webhooks push each event to you the moment it happens. You register one or more endpoints and pick the events each one subscribes to — the platform delivers every event to every active endpoint subscribed to it.

  • 17 event types across 6 domains: orders, returns+refunds, products, inventory, marketing, and restaurant+cashier — 16 are subscribable, and the 17th is ping, the test event (see the test events section). The whole catalog is documented below.
  • Every delivery is signed with the endpoint's own secret — verify it before trusting any event.
  • Every event carries a unified 4-key envelope (id, type, api_version, tenant_id) — and the first-version events kept all of their original fields with the envelope added on top, with nothing removed (see the event envelope section).
  • The atomic guarantee: the event row is written in the same database transaction as the business write itself — a committed order always has its event.
  • Delivery is at-least-once: duplicates are inherent to the model — handle them with the webhook-delivery-id key (details in the dedupe section).

2.Creating and managing endpoints

Everything webhooks-related lives in the dashboard: "Settings" then "Webhooks" (at /dashboard/settings/integrations/webhooks) — next to the API Keys screen. The screen is owner-only, and the server re-proves ownership on every operation.

  1. Press create endpoint and give it a clear name (1 to 100 characters).
  2. Enter a public HTTPS URL for the endpoint (up to 2048 characters) — the full rules are in the security requirements section.
  3. Pick the subscribed events: 1 to 25 types per endpoint — out of the 16 subscribable types (the ping test event is not selectable in the picker).
  4. Press create — copy the secret (it starts with whsec_) immediately into a secrets manager.

The cap is 10 endpoints per store — creating one past the cap answers 409 endpoint_limit. Each endpoint is fully independent: its own secret, its own subscription, its own delivery history.

  • Edit: change the name, the URL or the events, or enable/disable — disabling pauses delivery and preserves the entire delivery history.
  • Rotate the secret: a new secret is minted and shown once, while the old one stays valid for verification for 24 more hours (see the rotation section).
  • Send a test event: a button on every endpoint that mints a signed ping delivered to you immediately — the fastest way to prove everything works (see the test events section).
  • Delete: two modes — deactivate (the endpoint stops but its whole history remains) or permanent delete, which destroys the history.

3.The event catalog — 17 types across 6 domains

The event catalog is closed and fully documented: 17 types — 16 subscribable plus the ping test event. Events are generated automatically by database triggers inside the business write's own transaction, so no sales channel and no tool can bypass them: the online store, the POS, the staff app, the dashboard and payment confirmations all go through the same door.

Online-payment events are deferred for now — they will ship together with the YallaBuy online-payments integration, and until then they are not part of the catalog and cannot be subscribed to.

The orders domain:

Event typeFires when
order.createdAny order is created from any channel — online store, POS or the staff app.
order.status_changedAn order's status actually changes (dashboard transitions, payment confirmation). A write with the same value as before emits nothing.
order.cancellation_requestedA customer or staff member requests an order cancellation — the order enters the cancellation queue as PENDING, awaiting a decision.
order.cancellation_decidedA cancellation request is decided: approved or rejected. Approval also changes the order's own status, so a separate order.status_changed fires with it.

The returns and refunds domain:

Event typeFires when
return.createdA return is recorded already processed — a processed return has no later status, so its creation is its whole lifecycle.
return.requestedA customer submits a return request with their order receipt — it enters the review queue as PENDING.
return.request_decidedThe return request is reviewed: approved or rejected. Approval records the return itself and co-fires a separate return.created.
refund.executedThe refund is actually executed — recording is only preparation; execution is the durable accounting fact of the return.

The products domain:

Event typeFires when
product.createdA new product is added through the catalog screen — it fires for every actor: the owner, staff, or admin tooling.
product.updatedA merchant-meaningful product column actually changed (name, price, stock, availability, and peers). A save that changes nothing meaningful emits nothing, and automated operational stock syncs are excluded — those surface through the inventory events.
product.deletedA product is permanently deleted from the catalog — the payload carries the product snapshot as it was before deletion.

The inventory domain:

Event typeFires when
inventory.receivedStock is received: a new quantity lands on an item — a product, a variant or an ingredient.
inventory.adjustedA merchant-initiated stock adjustment: an increase or decrease (ADJUSTMENT_IN / ADJUSTMENT_OUT), or damage or loss (DAMAGE / LOSS).

Movements the system generates on its own — sale deductions, recipe consumption, returns — emit no inventory events, because you already receive them as order and return events. Untracked items (products born without stock tracking) produce no movements at all, hence no events. Receipts, manual adjustments, damage and loss are what emit.

The marketing domain:

Event typeFires when
coupon.redeemedA coupon is actually redeemed on an order — the usage counter ticks and the discount is applied. The payload carries the code, the order and the discount amount.

The restaurant and cashier domain:

Event typeFires when
kitchen.status_changedThe order's kitchen status changes (QUEUED → IN_PREP → READY → SERVED, with recall edges) — a state machine fully independent of the order status itself.
shift.closedA cashier shift is closed — the payload carries the closing (Z) report: sales per tender, expected versus counted, and the variance.

The test event:

Event typeFires when
pingA test event from the "Send test event" button on an endpoint — non-subscribable, and never emitted by any business operation (see the test events section).

The subscription defines everything: if not a single active endpoint subscribes to a type, the event itself is never stored — zero cost while the feature is unused. But that has a flip side: there is no "going back in time" for events during a period when you subscribed to nothing (the replay window covers events that actually exist — see the replay section).

4.The event envelope and payload shape

Every delivery body is a single JSON object. Since V2 the object carries a fixed 4-key envelope next to the event data — and the shape depends on the event's version:

KeyPresent onMeaning
idEvery eventThe event's string id (event_id) in the mwh:... form — the same value as the webhook-event-id header.
typeEvery eventThe event type — the same value as the webhook-event-type header.
api_versionEvery eventThe payload schema version — currently v2.
tenant_idEvery eventThe id (UUID) of the store the event belongs to.

The first-version events (order.created, order.status_changed, return.created) kept every one of their flat keys exactly as they were — literally no key moved or was renamed — and the envelope keys joined them. Any existing consumer reading the JSON by its familiar keys keeps working without changing a line.

And these are the business fields of each of them (the envelope arrives as four additional keys):

The order.created payload fields:

FieldCan be null?Meaning
order_idNoThe order id (UUID).
receipt_numberYesThe order receipt number — e.g. RCP-20261020-1482.
order_typeNoThe order type: DELIVERY, IN_STORE, TABLE, PICKUP or WHATSAPP.
order_sourceYesThe order source: WEBSITE or POS.
statusNoThe order status at creation — PENDING, for example.
customer_nameYesThe customer name, when present.
customer_phoneYesThe customer phone, when present.
delivery_addressYesThe delivery address — delivery orders only.
table_numberYesThe table number — dine-in orders only.
branch_idYesThe order branch id — when the store has more than one branch.
payment_methodYesThe payment method — e.g. CASH, COD, VODAFONE_CASH or INSTAPAY.
coupon_codeYesThe applied coupon code, when present.
total_amountNoThe order total in the store's currency.
tax_amountNoThe tax amount.
shipping_feeNoThe shipping fee.
discount_amountNoThe discount amount.
itemsNoArray of order items — each carries product_id, variant_id, name, price and quantity.
items_truncatedNotrue when the items passed 100 and were cut (details in the event identity section).
created_atNoThe order creation time in ISO 8601.

The order.status_changed payload fields: the exact full order summary from order.created (the table above — branch_id included) plus the transition keys below. Two notes: status inside the summary is the NEW status here, and created_at still says when the order was created, not when it changed.

FieldCan be null?Meaning
previous_statusNoThe order status before the transition.
statusNoThe order status after the transition.
changed_atNoThe change time in ISO 8601.

The return.created payload fields:

FieldCan be null?Meaning
return_idNoThe return id (UUID).
order_idNoThe original order id.
refund_amountNoThe refunded amount in the store's currency.
refund_methodNoThe refund method.
reasonNoThe return reason — e.g. DEFECTIVE, WRONG_SIZE or CHANGED_MIND. Capped at 500 characters in the payload.
itemsNoThe returned items, in the same shape as order items.
items_truncatedNotrue when the items passed 100 and were cut.
created_atNoThe return recording time in ISO 8601.
customer_nameYesThe customer name from the original order, when present.
customer_phoneYesThe customer phone from the original order, when present.
branch_idYesThe order branch id — when the store has more than one branch.

The V2 events (every newer type) ship {id, type, api_version, tenant_id, data} — the whole event lives inside the data object, and the data keys for each type are exactly the ones shown in its example in the payloads section — nothing else is ever added.

5.Test events — the "Send test event" button

Every endpoint carries a "Send test event" button — one press mints a real, signed ping event that reaches you within seconds, without waiting for any real business activity.

  • The ping type is non-subscribable: it never appears in the event picker at endpoint creation or edit — the button is the only way to receive one.
  • The delivery is stamped with the TEST source and shows in the delivery history with a Test chip — it can never pass for business data.
  • The cap is 10 test events per hour per store — past it the request answers an error.
  • It does not touch the replay budget (1000/hour/store) — deliberate test repetition is legitimate, and every press delivers a genuinely new delivery.
  • The button only works on ACTIVE endpoints — a deactivated one refuses the send.

6.Full payload examples

The values in these examples are illustrative, but the shape itself is exactly the real one: the same keys, within the same bounds the platform actually sends — first-version events with their flat fields plus the envelope, newer events with the envelope plus data. Key order in the examples is for reading only — rely on the key's name, never its position, when you parse JSON.

order.created — full payload example
{
  "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
  "receipt_number": "RCP-20261020-1482",
  "order_type": "DELIVERY",
  "order_source": "WEBSITE",
  "status": "PENDING",
  "customer_name": "أحمد محمود",
  "customer_phone": "+201001234567",
  "delivery_address": "15 شارع التحرير، الدقي، الجيزة",
  "table_number": null,
  "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
  "payment_method": "COD",
  "coupon_code": "WELCOME10",
  "total_amount": 624,
  "tax_amount": 62,
  "shipping_fee": 50,
  "discount_amount": 50,
  "items": [
    {
      "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
      "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
      "name": "تي شيرت قطن - أزرق / L",
      "price": 225,
      "quantity": 2
    },
    {
      "product_id": "9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
      "variant_id": null,
      "name": "كاب صيفي",
      "price": 112,
      "quantity": 1
    }
  ],
  "items_truncated": false,
  "created_at": "2026-10-20T12:34:56.789147+00:00",
  "id": "mwh:order-created:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
  "type": "order.created",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"
}
order.status_changed — full payload example
{
  "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
  "receipt_number": "RCP-20261020-1482",
  "order_type": "DELIVERY",
  "order_source": "WEBSITE",
  "status": "SHIPPED",
  "customer_name": "أحمد محمود",
  "customer_phone": "+201001234567",
  "delivery_address": "15 شارع التحرير، الدقي، الجيزة",
  "table_number": null,
  "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
  "payment_method": "COD",
  "coupon_code": "WELCOME10",
  "total_amount": 624,
  "tax_amount": 62,
  "shipping_fee": 50,
  "discount_amount": 50,
  "created_at": "2026-10-20T12:34:56.789147+00:00",
  "items": [
    {
      "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
      "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
      "name": "تي شيرت قطن - أزرق / L",
      "price": 225,
      "quantity": 2
    },
    {
      "product_id": "9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
      "variant_id": null,
      "name": "كاب صيفي",
      "price": 112,
      "quantity": 1
    }
  ],
  "items_truncated": false,
  "previous_status": "CONFIRMED",
  "changed_at": "2026-10-20T13:05:11.402318+00:00",
  "id": "mwh:order-status:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa:2",
  "type": "order.status_changed",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"
}
return.created — full payload example
{
  "return_id": "b4e1f7a2-9c30-4d58-8e6b-3f2a1d0c9b87",
  "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
  "refund_amount": 225,
  "refund_method": "VODAFONE_CASH",
  "reason": "WRONG_SIZE",
  "items": [
    {
      "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
      "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
      "name": "تي شيرت قطن - أزرق / L",
      "price": 225,
      "quantity": 1
    }
  ],
  "items_truncated": false,
  "created_at": "2026-10-21T09:12:40.115602+00:00",
  "customer_name": "أحمد محمود",
  "customer_phone": "+201001234567",
  "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
  "id": "mwh:return-created:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:b4e1f7a2-9c30-4d58-8e6b-3f2a1d0c9b87",
  "type": "return.created",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"
}
order.cancellation_requested — full payload example
{
  "id": "mwh:order-cancellation-requested:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:c7d1e4a9-2b35-4f68-9c01-5d7e8a9b0c12",
  "type": "order.cancellation_requested",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "request_id": "c7d1e4a9-2b35-4f68-9c01-5d7e8a9b0c12",
    "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
    "receipt_number": "RCP-20261020-1482",
    "status": "PENDING",
    "requested_by": "CUSTOMER",
    "reason": "CHANGED_MIND",
    "created_at": "2026-10-20T12:40:03.221104+00:00",
    "order": {
      "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
      "receipt_number": "RCP-20261020-1482",
      "order_type": "DELIVERY",
      "order_source": "WEBSITE",
      "status": "PENDING",
      "customer_name": "أحمد محمود",
      "customer_phone": "+201001234567",
      "delivery_address": "15 شارع التحرير، الدقي، الجيزة",
      "table_number": null,
      "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
      "payment_method": "COD",
      "coupon_code": "WELCOME10",
      "total_amount": 624,
      "tax_amount": 62,
      "shipping_fee": 50,
      "discount_amount": 50,
      "created_at": "2026-10-20T12:34:56.789147+00:00",
      "items": [
        {
          "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
          "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
          "name": "تي شيرت قطن - أزرق / L",
          "price": 225,
          "quantity": 2
        },
        {
          "product_id": "9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
          "variant_id": null,
          "name": "كاب صيفي",
          "price": 112,
          "quantity": 1
        }
      ],
      "items_truncated": false
    }
  }
}
order.cancellation_decided — full payload example
{
  "id": "mwh:order-cancellation-decided:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:c7d1e4a9-2b35-4f68-9c01-5d7e8a9b0c12",
  "type": "order.cancellation_decided",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "request_id": "c7d1e4a9-2b35-4f68-9c01-5d7e8a9b0c12",
    "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
    "receipt_number": "RCP-20261020-1482",
    "status": "APPROVED",
    "requested_by": "CUSTOMER",
    "reason": "CHANGED_MIND",
    "decided_by": "1f2e3d4c-5b6a-4988-8a7b-6c5d4e3f2a1b",
    "decided_at": "2026-10-20T12:52:31.884220+00:00",
    "decision_reason": "الطلب لسه ما دخلش التحضير",
    "changed_at": "2026-10-20T12:52:31.884611+00:00",
    "order": {
      "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
      "receipt_number": "RCP-20261020-1482",
      "order_type": "DELIVERY",
      "order_source": "WEBSITE",
      "status": "PENDING",
      "customer_name": "أحمد محمود",
      "customer_phone": "+201001234567",
      "delivery_address": "15 شارع التحرير، الدقي، الجيزة",
      "table_number": null,
      "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
      "payment_method": "COD",
      "coupon_code": "WELCOME10",
      "total_amount": 624,
      "tax_amount": 62,
      "shipping_fee": 50,
      "discount_amount": 50,
      "created_at": "2026-10-20T12:34:56.789147+00:00",
      "items": [
        {
          "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
          "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
          "name": "تي شيرت قطن - أزرق / L",
          "price": 225,
          "quantity": 2
        },
        {
          "product_id": "9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
          "variant_id": null,
          "name": "كاب صيفي",
          "price": 112,
          "quantity": 1
        }
      ],
      "items_truncated": false
    }
  }
}
return.requested — full payload example
{
  "id": "mwh:return-requested:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:d2a6b8c1-4e75-49f0-8a23-6b9c0d1e2f34",
  "type": "return.requested",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "request_id": "d2a6b8c1-4e75-49f0-8a23-6b9c0d1e2f34",
    "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
    "receipt_number": "RCP-20261020-1482",
    "status": "PENDING",
    "reason": "WRONG_SIZE",
    "notes": "المقاس ضاق عن المتوقع",
    "items": [
      {
        "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
        "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
        "name": "تي شيرت قطن - أزرق / L",
        "price": 225,
        "quantity": 1
      }
    ],
    "items_truncated": false,
    "order": {
      "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
      "receipt_number": "RCP-20261020-1482",
      "order_type": "DELIVERY",
      "order_source": "WEBSITE",
      "status": "SHIPPED",
      "customer_name": "أحمد محمود",
      "customer_phone": "+201001234567",
      "delivery_address": "15 شارع التحرير، الدقي، الجيزة",
      "table_number": null,
      "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
      "payment_method": "COD",
      "coupon_code": "WELCOME10",
      "total_amount": 624,
      "tax_amount": 62,
      "shipping_fee": 50,
      "discount_amount": 50,
      "created_at": "2026-10-20T12:34:56.789147+00:00",
      "items": [
        {
          "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
          "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
          "name": "تي شيرت قطن - أزرق / L",
          "price": 225,
          "quantity": 2
        },
        {
          "product_id": "9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
          "variant_id": null,
          "name": "كاب صيفي",
          "price": 112,
          "quantity": 1
        }
      ],
      "items_truncated": false
    }
  }
}
return.request_decided — full payload example
{
  "id": "mwh:return-request-decided:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:d2a6b8c1-4e75-49f0-8a23-6b9c0d1e2f34",
  "type": "return.request_decided",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "request_id": "d2a6b8c1-4e75-49f0-8a23-6b9c0d1e2f34",
    "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
    "receipt_number": "RCP-20261020-1482",
    "status": "APPROVED",
    "return_id": "b4e1f7a2-9c30-4d58-8e6b-3f2a1d0c9b87",
    "reviewed_by": "1f2e3d4c-5b6a-4988-8a7b-6c5d4e3f2a1b",
    "reviewed_at": "2026-10-21T09:12:39.704118+00:00",
    "changed_at": "2026-10-21T09:12:39.704522+00:00",
    "order": {
      "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
      "receipt_number": "RCP-20261020-1482",
      "order_type": "DELIVERY",
      "order_source": "WEBSITE",
      "status": "SHIPPED",
      "customer_name": "أحمد محمود",
      "customer_phone": "+201001234567",
      "delivery_address": "15 شارع التحرير، الدقي، الجيزة",
      "table_number": null,
      "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
      "payment_method": "COD",
      "coupon_code": "WELCOME10",
      "total_amount": 624,
      "tax_amount": 62,
      "shipping_fee": 50,
      "discount_amount": 50,
      "created_at": "2026-10-20T12:34:56.789147+00:00",
      "items": [
        {
          "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
          "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
          "name": "تي شيرت قطن - أزرق / L",
          "price": 225,
          "quantity": 2
        },
        {
          "product_id": "9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
          "variant_id": null,
          "name": "كاب صيفي",
          "price": 112,
          "quantity": 1
        }
      ],
      "items_truncated": false
    }
  }
}
refund.executed — full payload example
{
  "id": "mwh:refund-executed:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:e5f6a7b8-9c01-4234-8567-8a9b0c1d2e3f",
  "type": "refund.executed",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "refund_id": "e5f6a7b8-9c01-4234-8567-8a9b0c1d2e3f",
    "return_id": "b4e1f7a2-9c30-4d58-8e6b-3f2a1d0c9b87",
    "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
    "receipt_number": "RCP-20261020-1482",
    "method": "VODAFONE_CASH",
    "approved_amount": 225,
    "amount": 225,
    "executed_by": "1f2e3d4c-5b6a-4988-8a7b-6c5d4e3f2a1b",
    "executed_at": "2026-10-21T10:02:15.330907+00:00",
    "external_reference": "VF-20261021-778812",
    "changed_at": "2026-10-21T10:02:15.331244+00:00",
    "order": {
      "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
      "receipt_number": "RCP-20261020-1482",
      "order_type": "DELIVERY",
      "order_source": "WEBSITE",
      "status": "SHIPPED",
      "customer_name": "أحمد محمود",
      "customer_phone": "+201001234567",
      "delivery_address": "15 شارع التحرير، الدقي، الجيزة",
      "table_number": null,
      "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
      "payment_method": "COD",
      "coupon_code": "WELCOME10",
      "total_amount": 624,
      "tax_amount": 62,
      "shipping_fee": 50,
      "discount_amount": 50,
      "created_at": "2026-10-20T12:34:56.789147+00:00",
      "items": [
        {
          "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
          "variant_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
          "name": "تي شيرت قطن - أزرق / L",
          "price": 225,
          "quantity": 2
        },
        {
          "product_id": "9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
          "variant_id": null,
          "name": "كاب صيفي",
          "price": 112,
          "quantity": 1
        }
      ],
      "items_truncated": false
    }
  }
}
product.created — full payload example
{
  "id": "mwh:product-created:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
  "type": "product.created",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
    "name": "تي شيرت قطن - أزرق",
    "description": "تي شيرت قطن 100% بمقاسات متعددة",
    "category_id": "8b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "price": 225,
    "compare_at_price": 300,
    "sku": "TSH-BLU",
    "barcode": "6221048312571",
    "image_url": "https://cdn.example.com/tshirt-blue.jpg",
    "has_variants": true,
    "is_available": true,
    "is_featured": false,
    "stock_quantity": 62,
    "low_stock_threshold": 5,
    "variants": [
      {
        "id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
        "sku": "TSH-BLU-L",
        "name": "أزرق / L",
        "color": "أزرق",
        "size": "L",
        "price_override": 225,
        "compare_at_price": 300,
        "stock_quantity": 30,
        "low_stock_threshold": 3
      },
      {
        "id": "9e1d2f3a-4b5c-4d6e-8f7a-0b1c2d3e4f5a",
        "sku": "TSH-BLU-M",
        "name": "أزرق / M",
        "color": "أزرق",
        "size": "M",
        "price_override": 225,
        "compare_at_price": 300,
        "stock_quantity": 32,
        "low_stock_threshold": 3
      }
    ],
    "variants_truncated": false,
    "created_at": "2026-10-20T11:02:44.517392+00:00"
  }
}
product.updated — full payload example
{
  "id": "mwh:product-updated:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d:1",
  "type": "product.updated",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "product_id": "7a2b9c4e-8f31-4d5a-b6c2-1e9f8a7b6c5d",
    "name": "تي شيرت قطن - أزرق",
    "description": "تي شيرت قطن 100% بمقاسات متعددة",
    "category_id": "8b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "price": 210,
    "compare_at_price": 300,
    "sku": "TSH-BLU",
    "barcode": "6221048312571",
    "image_url": "https://cdn.example.com/tshirt-blue.jpg",
    "has_variants": true,
    "is_available": true,
    "is_featured": false,
    "stock_quantity": 60,
    "low_stock_threshold": 5,
    "variants": [
      {
        "id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
        "sku": "TSH-BLU-L",
        "name": "أزرق / L",
        "color": "أزرق",
        "size": "L",
        "price_override": 210,
        "compare_at_price": 300,
        "stock_quantity": 29,
        "low_stock_threshold": 3
      },
      {
        "id": "9e1d2f3a-4b5c-4d6e-8f7a-0b1c2d3e4f5a",
        "sku": "TSH-BLU-M",
        "name": "أزرق / M",
        "color": "أزرق",
        "size": "M",
        "price_override": 210,
        "compare_at_price": 300,
        "stock_quantity": 31,
        "low_stock_threshold": 3
      }
    ],
    "variants_truncated": false,
    "changed_columns": ["compare_at_price", "price"],
    "changed_at": "2026-10-20T13:30:07.662018+00:00"
  }
}
product.deleted — full payload example
{
  "id": "mwh:product-deleted:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
  "type": "product.deleted",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "product_id": "9d8c7b6a-5f4e-4d3c-2b1a-0f9e8d7c6b5a",
    "name": "كاب صيفي",
    "description": "كاب قطن بمقاس واحد",
    "category_id": "8b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "price": 112,
    "compare_at_price": null,
    "sku": "CAP-SUM",
    "barcode": null,
    "image_url": "https://cdn.example.com/cap-summer.jpg",
    "has_variants": false,
    "is_available": true,
    "is_featured": false,
    "stock_quantity": 14,
    "low_stock_threshold": 3,
    "variants": [],
    "variants_truncated": false,
    "deleted_at": "2026-10-21T08:55:41.037652+00:00"
  }
}
inventory.received — full payload example
{
  "id": "mwh:inventory-received:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "type": "inventory.received",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "movement_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "item_type": "VARIANT",
    "item_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
    "item_name": "تي شيرت قطن - أزرق / L",
    "movement_type": "RECEIPT",
    "quantity": 50,
    "quantity_before": 12,
    "quantity_after": 62,
    "reason": "استلام من المورد",
    "reference_type": null,
    "reference_id": null,
    "created_at": "2026-10-20T14:20:09.960135+00:00"
  }
}
inventory.adjusted — full payload example
{
  "id": "mwh:inventory-adjusted:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:b8c9d0e1-2f3a-4b5c-8d6e-7f8a9b0c1d2e",
  "type": "inventory.adjusted",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "movement_id": "b8c9d0e1-2f3a-4b5c-8d6e-7f8a9b0c1d2e",
    "item_type": "VARIANT",
    "item_id": "2c4e6f8a-0b1d-4e2f-8a3b-5c6d7e8f9a0b",
    "item_name": "تي شيرت قطن - أزرق / L",
    "movement_type": "DAMAGE",
    "quantity": -2,
    "quantity_before": 62,
    "quantity_after": 60,
    "reason": "قطعتين تالفين من الشحنة الأخيرة",
    "reference_type": "MANUAL",
    "reference_id": null,
    "created_at": "2026-10-20T16:41:37.208461+00:00"
  }
}
coupon.redeemed — full payload example
{
  "id": "mwh:coupon-redeemed:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:b7c8d9e0-1f2a-4b3c-9d4e-5f6a7b8c9d0e",
  "type": "coupon.redeemed",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "redemption_id": "b7c8d9e0-1f2a-4b3c-9d4e-5f6a7b8c9d0e",
    "coupon_id": "0a9b8c7d-6e5f-4a3b-8c2d-1e0f9a8b7c6d",
    "coupon_code": "WELCOME10",
    "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
    "receipt_number": "RCP-20261020-1482",
    "customer_phone": "+201001234567",
    "customer_name": "أحمد محمود",
    "customer_id": "3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
    "discount_amount": 50,
    "redeemed_at": "2026-10-20T12:34:56.812450+00:00"
  }
}
kitchen.status_changed — full payload example
{
  "id": "mwh:kitchen-status-changed:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa:1",
  "type": "kitchen.status_changed",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "order_id": "3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa",
    "receipt_number": "RCP-20261020-1482",
    "order_type": "DELIVERY",
    "customer_name": "أحمد محمود",
    "customer_phone": "+201001234567",
    "previous_kitchen_status": "QUEUED",
    "kitchen_status": "IN_PREP",
    "table_number": null,
    "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
    "changed_at": "2026-10-20T12:41:58.104337+00:00"
  }
}
shift.closed — full payload example
{
  "id": "mwh:shift-closed:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
  "type": "shift.closed",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "shift_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
    "cashier_name": "منى عبد الله",
    "staff_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
    "branch_id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
    "opened_at": "2026-10-20T09:00:12.442018+00:00",
    "closed_at": "2026-10-20T17:05:40.990214+00:00",
    "starting_float": 500,
    "closing_cash_counted": 3145.25,
    "cash_sales": 2870.25,
    "card_sales": 1240,
    "other_sales": 90,
    "total_refunds": 225,
    "expected_cash": 3145.25,
    "variance": 0,
    "notes": "إغلاق طبيعي"
  }
}
ping — full payload example (the test event)
{
  "id": "mwh:ping:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:8a9b0c1d-2e3f-4a5b-8c6d-7e8f9a0b1c2d",
  "type": "ping",
  "api_version": "v2",
  "tenant_id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "data": {
    "endpoint_id": "8f3a1c2e-4b5d-4e6f-9a0b-1c2d3e4f5a6b",
    "endpoint_name": "n8n — الإشعارات",
    "sent_at": "2026-10-20T15:00:22.641078+00:00",
    "message": "This is a test webhook event from YallaBuy."
  }
}

7.Event identity, ordering and payload bounds

Every event carries a stable string id (event_id), delivered in the webhook-event-id header — it is how you recognize an event across all retries and replays. The general law: mwh:<type-dashed>:<tenant_id>:<resource_id> — the event type dashed, your store id, and the source id (the order, return, product, inventory movement, and peers).

  • mwh:order-created:<tenant_id>:<order_id> — order creation.
  • mwh:order-status:<tenant_id>:<order_id>:<seq> — status change: note the historical order-status prefix (NOT order-status-changed — the legacy prefix is preserved verbatim because live consumers build their dedupe on it). <seq> is the per-order sequence of status transitions (1, 2, 3, ...). The transition matrix legally allows returning to a previous status (e.g. CONFIRMED then PENDING then CONFIRMED again), so the sequence number — not the before/after pair — is what makes each transition unique.
  • mwh:return-created:<tenant_id>:<return_id> — return creation.
  • The other repeatable types follow the same <seq> rule: mwh:product-updated:<tenant_id>:<product_id>:<seq> for each product edit, and mwh:kitchen-status-changed:<tenant_id>:<order_id>:<seq> for each kitchen transition of the same order.
  • Every remaining single-occurrence type follows the general form: mwh:refund-executed:<tenant_id>:<refund_id>, mwh:product-created:<tenant_id>:<product_id>, mwh:shift-closed:<tenant_id>:<shift_id>, and their peers.
  • mwh:ping:<tenant_id>:<uuid> — the test event, with a fresh id on every press.

The payload bounds that let you trust any delivery's size:

  • The items array is truncated at the first 100 items with items_truncated = true — on order.created, return.created and return.requested.
  • The variants array on product events is truncated at the first 100 variants with variants_truncated = true.
  • Free-text fields like reason, notes and failure_reason ship capped at 500 characters in the payload (the product description at 1000).
  • The whole payload is bounded at 64 KB — these caps are what guarantee every event passes the bound without ever failing the merchant's own transaction.

When you need the rest of the items or details, fetch them by the source id from the dashboard or the Public API.

All amounts are plain numbers in the store's currency — the Egyptian pound is the platform's primary currency.

8.Delivery headers and identifiers

Every delivery is a single POST with a JSON body and these headers (header names are always lowercase):

HeaderValue
webhook-idThe endpoint id (UUID) — the same value on every delivery of that endpoint.
webhook-delivery-idThe delivery id (UUID) — THE dedupe key; every retry of one delivery reuses the same id, while a manual retry or replay mints a NEW id.
webhook-event-idThe event id — e.g. mwh:order-created:<tenant>:<order> — stable across retries and replays. One exception: events are retained 14 days while deliveries live 30, so a delivery manually retried after its event row was purged carries the raw event UUID in this header instead — layer-1 dedupe (delivery id) still holds.
webhook-event-typeThe event type — the same value as type in the payload: one of the 16 subscribable catalog names (order.created, refund.executed, shift.closed, and peers) or ping for the test event.
webhook-timestampUnix seconds (decimal string) — when THIS attempt was signed and sent.
webhook-signaturev1=<hex> where hex = HMAC-SHA256 over the string ${timestamp}.${endpoint_id}.${event_id}.${delivery_id}.${body} with the endpoint secret.
  • Content-Type: application/json — the body is the payload itself as JSON.
  • User-Agent: YallaBuy-Webhooks/1.0 — let it identify platform requests in your firewall.
  • The platform waits 10 seconds at most for your response — past that the attempt counts as TIMEOUT.
  • Your response body is ignored entirely: it is drained up to 64 KB and never parsed — a short 2xx is enough.

9.Signatures: generation and verification

The signature is computed by joining five values with a single dot: ${timestamp}.${endpoint_id}.${event_id}.${delivery_id}.${body} — where timestamp is the webhook-timestamp header value of that attempt, endpoint_id is the webhook-id value, event_id and delivery_id come from the same-named headers, and body is the raw request body byte for byte. The result is an HMAC-SHA256 lowercase hex string, sent as v1=<hex>.

Node.js — complete signature verification (runnable)
const crypto = require("node:crypto");

// The endpoint secret from the dashboard (shown once). Keep the previous
// secret available during the 24h rotation overlap.
const CURRENT_SECRET = process.env.YALLABUY_WEBHOOK_SECRET;       // whsec_...
const PREVIOUS_SECRET = process.env.YALLABUY_WEBHOOK_SECRET_PREV; // optional

function verifyYallaBuyWebhook(rawBody, headers) {
  // 1) Timestamp freshness: reject anything older/newer than 300 seconds.
  const timestamp = String(headers["webhook-timestamp"] ?? "");
  if (!/^\d+$/.test(timestamp)) return false;
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (skew > 300) return false;

  // 2) Rebuild the signed message from the RAW body (before JSON.parse):
  //    timestamp.endpoint_id.event_id.delivery_id.body
  const message = [
    timestamp,
    String(headers["webhook-id"] ?? ""),
    String(headers["webhook-event-id"] ?? ""),
    String(headers["webhook-delivery-id"] ?? ""),
    rawBody,
  ].join(".");

  // 3) Constant-time compare of v1=<hex>: current secret first, then the
  //    previous secret during the 24h rotation overlap.
  const received = String(headers["webhook-signature"] ?? "");
  const candidates = [CURRENT_SECRET, PREVIOUS_SECRET].filter(Boolean);
  return candidates.some((secret) => {
    const expected =
      "v1=" + crypto.createHmac("sha256", secret).update(message, "utf8").digest("hex");
    const a = Buffer.from(expected, "utf8");
    const b = Buffer.from(received, "utf8");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

// Express: read the RAW body BEFORE JSON.parse, verify, answer fast.
// const express = require("express");
// const app = express();
// app.post(
//   "/webhooks/yallabuy",
//   express.raw({ type: "application/json", limit: "128kb" }),
//   (req, res) => {
//     const raw = req.body.toString("utf8");
//     if (!verifyYallaBuyWebhook(raw, req.headers)) {
//       return res.status(401).end(); // bad signature: never process it
//     }
//     const event = JSON.parse(raw);
//     enqueue(event, req.headers); // heavy work off the request path
//     res.status(200).end();       // a fast 2xx marks the delivery DELIVERED
//   }
// );
  • Compare in constant time only (crypto.timingSafeEqual) — plain string comparison opens timing-attack doors.
  • Try the current secret first, then previous_secret during the 24h overlap after rotation — the example above already does exactly that.
  • Reject any request whose timestamp differs from your server clock by more than 300 seconds (5 minutes) — that window kills replay attacks with captured requests.

10.Handling duplicates — at-least-once delivery

You will see duplicates in two situations, each with its own key:

  • A retry of the same delivery: the network dropped after you processed the event and before the platform recorded your response, or the attempt genuinely failed — either way the same webhook-delivery-id comes back.
  • A manual replay (retry or replay from the dashboard): a brand-new delivery with a new id for the SAME event — the stable value there is webhook-event-id.
Node.js — duplicate-delivery store
// Dedupe store: durable (database/Redis) in production — an in-memory
// Set dies with the process. Expire the keys after 30 days (the delivery
// retention window) so the store does not grow forever.
async function isDuplicate(store, deliveryId, eventId) {
  // Layer 1: the same delivery retried -> same webhook-delivery-id.
  if (await store.has(`d:${deliveryId}`)) return true;
  // Layer 2: the same event replayed -> new delivery id, same event id.
  if (await store.has(`e:${eventId}`)) return true;
  await store.set(`d:${deliveryId}`, 1);
  await store.set(`e:${eventId}`, 1);
  return false;
}

// In your route, after signature verification passes:
// if (await isDuplicate(store, headers["webhook-delivery-id"], headers["webhook-event-id"])) {
//   return res.status(200).end(); // already processed: acknowledge, do nothing
// }

11.Retries, exponential backoff and timeouts

Every attempt has a 10-second timeout. Any 2xx response settles the delivery as DELIVERED and the response body is ignored. Anything else — a non-2xx status, a timeout, a transport error, a rejected URL or host, an oversized response — counts as a failed attempt and is retried automatically.

The wait between attempts is exponential: after attempt number n fails, the wait before the next one is 60 × 2^(n-1) seconds, capped at 7200 seconds (2 hours), with ±15% jitter that spreads attempt times apart. Practically: about 1 minute after the first failure, then about 2 minutes, then 4, then 8 — and so on up to the cap.

The maximum is 5 attempts (the storage model allows 1 to 10; deliveries are created with the default of 5). After the last failed attempt the delivery is marked DEAD — a terminal state that stays visible in the delivery history (retained for 30 days) and can be retried manually from the dashboard.

12.Secret rotation

Rotate from the webhooks screen: select the endpoint and request a secret rotation — a new whsec_ secret is minted and shown to you exactly once, just like at creation.

  • The old secret moves to previous_secret and stays valid for verification for 24 more hours — the overlap window.
  • After the 24 hours, periodic maintenance clears the old secret — from then on only the new one verifies.
  • During the overlap your handler tries the current secret first, then the old one — the verification example in the signature section already does this.
  1. Rotate from the dashboard and copy the new secret immediately into your secrets manager.
  2. Store it as CURRENT_SECRET and move the old one into a PREVIOUS_SECRET variable.
  3. Deploy the config change — deliveries keep flowing the whole time, because signatures made with either secret verify during the overlap.
  4. 24 hours after the rotation, remove the old variable from your configuration.

13.Replay semantics — rules and limits

Replay lets you deliver an old event again to a working endpoint — after an outage you have fixed, for example. Event replays and dead-delivery retries follow the same rules:

  • The window is 14 days from the event's creation — anything older is refused.
  • The target must be an ACTIVE endpoint (not disabled).
  • The replay budget: 1000 replays per hour per store (covering event replays and delivery retries together) — exceeding it answers 429 rate limit.
  • A replay never touches the original rows — it creates a NEW REPLAY delivery with a fresh id and an embedded copy of the payload, its source marked so you can tell it apart in the history.

14.Endpoint security requirements

These rules are enforced at creation, on every delivery attempt, and on every redirect hop — anything that fails them is refused by the platform itself (BLOCKED_URL or BLOCKED_HOST):

  • HTTPS only — any other scheme is refused at creation.
  • Only ports 443 or 8443 — any other port is refused.
  • Public addresses only: the hostname is resolved and EVERY address is checked — loopback, private ranges (RFC1918), link-local including the cloud metadata address 169.254.169.254, the CGNAT range, reserved IPv6 ranges, and wrapped addresses (IPv4-mapped and NAT64) — any failure answers BLOCKED_HOST.
  • No userinfo in the URL (user:pass@host) — such URLs are refused.
  • Redirects: at most 3 hops, each one re-validated from scratch — a redirect to a disallowed target answers REDIRECT_BLOCKED.
  • The connection itself is pinned to the validated address — changing DNS after the first resolution (DNS rebinding) opens no door to any internal address.

And on your side, as the endpoint owner:

  • Answer 2xx quickly — the platform waits 10 seconds at most; do the heavy work after responding (a job queue or background worker).
  • Make the handler idempotent — duplicates are inherent to the system (see the dedupe section).
  • Keep a valid, current TLS certificate — a failed handshake counts as TRANSPORT_ERROR and delays the delivery.

15.Error codes and troubleshooting

Endpoint management operations (create, edit, rotate, delete, replay) answer stable codes with bilingual messages:

CodeHTTPMeaning
Forbidden403You are not the owner of this store, or the store is currently inactive/suspended.
invalid_request400Invalid input: the name (1-100 characters), the URL shape (HTTPS, up to 2048 characters), an unknown event type, or an empty subscription.
endpoint_limit409The store already has its 10 endpoints — deactivate or delete one before creating another.
rate limit429The replay budget is exhausted (1000/hour/store) — wait, then continue.
—401No dashboard session, or the session expired — sign in again.

The delivery history shows the outcome of every attempt — here is what each means and how you fix it:

OutcomeMeaningThe fix
SUCCESSYour server answered 2xx — the delivery is settled.Nothing to do.
NON_2XXYour server answered with a non-2xx status (a 3xx after the redirect chain counts too).Make the receiving route answer 2xx once the event is accepted.
TIMEOUTNo complete response within 10 seconds.Respond fast; do the heavy work in the background.
TRANSPORT_ERRORThe connection itself failed: DNS, the TLS handshake, a refused or reset connection.Check the host is up, its certificate valid, and it is publicly reachable.
BLOCKED_URLThe endpoint URL breaks the rules: not HTTPS, a disallowed port, or user:password in it.Fix the URL in the endpoint settings.
BLOCKED_HOSTThe hostname resolved to an internal or reserved address (loopback, private, link-local, metadata).Use a genuinely public host.
REDIRECT_BLOCKEDMore than 3 redirect hops, or a hop failed validation.Point the endpoint directly at the final URL, with no redirects.
OVERSIZED_RESPONSEYour response body passed the 64 KB drain limit.Answer with a small body — the platform does not read it anyway.
ABANDONEDAn attempt abandoned by a worker that died before reporting its result — the delivery was retried or marked DEAD afterwards.Informational, in the attempt history — check the delivery's current status.

A delivery that looks stuck? PENDING deliveries are normally waiting: either for their next backoff slot, or while an attempt lease is still held (it expires within minutes). Fresh events are delivered within seconds — an instant kick fires the delivery worker the moment an event commits — and the per-minute worker remains the safety net: it claims anything missed and drives the retries, while periodic maintenance dead-letters any row that exhausted its attempts — nothing is ever silently dropped. For full honesty: deliveries and their attempts are retained 30 days, and the events themselves 14 days — anything not visible on the screen has passed its retention.

16.End-to-end integration guide

  1. Open "Settings" then "Webhooks" and press create endpoint: a clear name, a public HTTPS URL, and the event types you need from the catalog (1 to 25 types per endpoint).
  2. Copy the secret immediately into your secrets manager — it is never shown again.
  3. Build the receiving route: read the raw body, verify the signature with the code above, answer 2xx fast. While developing, use a public test endpoint (a request-inspection service or an HTTPS tunnel) — any public host passes the rules.
  4. Press "Send test event" on the endpoint — a signed ping event arrives within seconds (events are delivered instantly) and proves the route and the signature work.
  5. Place a test order on your store — the first business event arrives just as fast.
  6. Watch the webhooks screen: delivery status, HTTP status, attempt count, and a safe error summary when something fails.

And this is what a real delivery looks like as your server receives it — the same headers and payload documented above:

Example — a real delivery reaching your server
POST /webhooks/yallabuy HTTP/1.1
Host: integrations.example.com
Content-Type: application/json
User-Agent: YallaBuy-Webhooks/1.0
webhook-id: 8f3a1c2e-4b5d-4e6f-9a0b-1c2d3e4f5a6b
webhook-delivery-id: 5d4c3b2a-1f0e-4d3c-8b7a-6f5e4d3c2b1a
webhook-event-id: mwh:order-created:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa
webhook-event-type: order.created
webhook-timestamp: 1792502096
webhook-signature: v1=7f2a9c41e8b3d6051c4f8e2a9b7d6c5f4e3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c

{"order_id":"3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa","receipt_number":"RCP-20261020-1482","order_type":"DELIVERY","order_source":"WEBSITE","status":"PENDING","total_amount":624,"items_truncated":false,"created_at":"2026-10-20T12:34:56.789147+00:00","id":"mwh:order-created:9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b:3f0c8e6a-1d24-4b7f-9a3e-2c5b8d90f1aa","type":"order.created","api_version":"v2","tenant_id":"9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"}

Before you declare the integration live, walk this checklist:

  • HTTPS with a valid certificate on port 443 or 8443, on a publicly reachable host.
  • The raw body is read before parsing, and the signature is verified in constant time with the 300-second timestamp window.
  • A durable dedupe store (not in-memory) keyed on both delivery-id and event-id.
  • Heavy processing happens after the response — nothing slow sits between receiving and the 2xx.
  • The secret lives in a secrets manager, never in code, and the rotation plan is ready (verify both secrets through the 24-hour overlap).
  • Event types are allowlisted: handle the types you know, answer 2xx and ignore anything else.
  • Monitoring: watch DEAD deliveries and old PENDING ones from the webhooks screen, and replay the missed ones within the 14-day window.

17.Security best practices

  • Always compare the signature in constant time (crypto.timingSafeEqual or your language's equivalent) — plain string comparison is out.
  • Enforce the 300-second timestamp window — without it, the signature does not protect you from replaying a captured request.
  • Allowlist event types: switch on webhook-event-type, handle the ones you know, and answer 2xx while ignoring anything else — the catalog may grow in future versions without notice to your handler.
  • Never trust arrival order — rely on the event data itself, and on the sequence number in the event_id for the repeatable types when you need one source's transition order.
  • Keep TLS certificate validation ON in any HTTP client you use while testing — disabling it invites interception and tampering.
  • Store the secret in a secrets manager — never in code, Git or logs. Rotate periodically, and rotate immediately if you suspect a leak.
  • Never log raw payloads or secrets — the platform itself logs neither.
Was this article helpful?