---
title: "Webhooks and security"
space: "BWH Shipping"
url: "https://docs.bwh.tech/bwh-shipping/build/webhooks-and-security"
updated: "2026-09-21"
---

Carriers use webhooks to report tracking scans as they happen. BWH Shipping has **one webhook endpoint** for every carrier. It finds your carrier, hands the delivery to your `handle_webhook`, and applies the result to the Shipping Request. Your job is to verify the delivery and translate it.

## Your webhook URL

```
https://your.site/api/v2/method/bwh_shipping.bwh_shipping.webhook.handle?provider=<Shipping Provider Profile name>
```

- Use the profile's exact name, URL-encoded: `?provider=Acme%20Courier`.
- The endpoint accepts `POST` only, and is rate limited to 120 requests per minute per IP address.
- A disabled profile rejects every delivery.

## What happens to a delivery

```mermaid
sequenceDiagram
    participant C as Carrier
    participant E as Webhook endpoint
    participant H as Your handle_webhook
    participant S as Shipping Request

    C->>E: POST ?provider=Acme Courier
    E->>H: handle_webhook(raw body, headers)
    alt you throw
        E-->>C: 400
    else you return an empty dict
        E-->>C: 200 (ignored)
    else you return awb, status, event_id, events
        E->>S: find the shipment by awb
        alt no shipment has that awb
            E-->>C: 200 (logged as Failed)
        else shipment belongs to another carrier
            E-->>C: 400
        else found
            S->>S: skip a repeated event_id, add new scans, move up the ladder
            E-->>C: 200
        end
    end
```

## Verify every delivery

The webhook URL is public, so anyone can post to it. Verify the delivery before you read it:

1. **Load the secret, and throw if it's missing.** A missing secret must never mean "skip the check".
2. **Read the header by its title-case name**, for example `X-Acme-Signature`. Headers arrive as a plain dict with title-case names, so `x-acme-signature` finds nothing.
3. **Compute the signature over `payload` exactly as received.** Don't parse and re-serialise it first.
4. **Compare in constant time** with `hmac.compare_digest`, after checking the header is ASCII.
5. **Only then parse the body.**

```python
secret = self.get_password("webhook_secret", raise_exception=False)
if not secret:
	frappe.throw(_("Set the webhook secret in Acme Courier Shipping Settings"))

signature = headers.get("X-Acme-Signature") or ""
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
if not signature.isascii() or not hmac.compare_digest(expected, signature):
	frappe.throw(_("Acme Courier webhook signature does not match"))
```

:::note
Every verification failure gets the same response: `400 Webhook could not be verified`. That includes a missing or unknown provider, a disabled profile and a bad signature, so callers can't use the endpoint to discover which carriers a site has set up. Signature failures are recorded in the Error Log.
:::

## What to return

| Return | When | Response |
|---|---|---|
| *(throw)* | The delivery isn't genuine | 400 |
| `{}` | Genuine, but nothing to act on | 200 |
| `{"awb", "status", "event_id", "events", "provider_status"}` | The shipment moved | 200 |

- `awb` must match the `awb` your `create_shipment` returned, exactly.
- `status` must be a ladder status. If the event doesn't map to one, return `{}`: a result without a status is dropped, scans included.
- `events` is a list of `{timestamp, status, location, message}` scans. It can be empty.

## The ladder, replays and ordering

Carriers retry, and they deliver scans out of order. BWH Shipping handles both:

- **The status only moves up.** A scan that ranks lower than the current status doesn't change it, so a late "In Transit" can't undo "Out For Delivery". **Delivered**, **Cancelled** and **Lost** are final. A later disagreement is written to the Error Log as a status conflict.
- **A repeat of the last applied `event_id` is skipped**, and still answered with 200 so the carrier stops retrying. Older replays fall to the ladder check, which ignores them.
- **Scans are added even when the status doesn't move.** Repeats are skipped by `event_id`, or by timestamp, status and message.

Choose an `event_id` that stays the same across retries of one event and differs between events. If the carrier sends no event id, build one from the tracking number and the latest scan time.

## Carriers that don't sign webhooks

Some carriers only echo back a token you chose. Compare it with `hmac.compare_digest`, treat it like a password, and never skip the check when it's missing. Shiprocket works this way. See [Shiprocket](/bwh-shipping/carriers/shiprocket).

## Test a webhook locally

```python
import hashlib, hmac, json

payload = json.dumps(
	{"event_id": "evt_1", "tracking_number": "AWB123", "status": "delivered"}
).encode()
print(hmac.new(b"test_secret", payload, hashlib.sha256).hexdigest())
```

```bash
curl -X POST "http://your.site:8000/api/v2/method/bwh_shipping.bwh_shipping.webhook.handle?provider=Acme%20Courier" \
  -H "Content-Type: application/json" \
  -H "X-Acme-Signature: <signature>" \
  --data-raw '{"event_id": "evt_1", "tracking_number": "AWB123", "status": "delivered"}'
```

The body you post must be byte-for-byte the one you signed, and `AWB123` must be the `awb` of a Shipping Request on that site.

## Debug a delivery

Deliveries for an enabled carrier are logged as an **Integration Request** with the service `<provider> Shipping Webhook`. Verification failures also go to the **Error Log** as `<provider> shipping webhook verification failed`.

| Symptom | Likely cause | Fix |
|---|---|---|
| Every delivery gets 400, and the Error Log says "verification failed" | Wrong or empty secret, or the header name doesn't match | Paste the secret from the carrier's dashboard. Read the header by its title-case name. |
| 400 with no Error Log entry | `?provider=` is missing or wrong, or the profile is disabled | Match the profile name exactly, and URL-encode spaces |
| 200, but the Integration Request says Failed | No Shipping Request has that AWB | Return the same AWB from the webhook that `create_shipment` returned |
| 200, but the status doesn't change | A repeated `event_id`, a lower-ranked status, a final shipment, or an unmapped carrier status | Check the request's status and your status mapping |
| 417 or 500 | Your `on_update` hook, or saving the request, raised an error | Reproduce it with **Sync Status**. The carrier retries the delivery. |
| "provider mismatch" in the Error Log | The URL names a different carrier from the one that booked the shipment | Give each carrier its own webhook URL |

## Next steps

- [Build a carrier](/bwh-shipping/build/build-a-carrier): the full step-by-step guide
- [Carrier reference](/bwh-shipping/build/carrier-reference): `handle_webhook` and `get_tracking` in detail
