BWH Shipping

BWH Shipping

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Webhooks and security

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

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

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.

Test a webhook locally

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())
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

Last updated 3 weeks ago
Was this helpful?
Thanks!