# Parcel Post — a SipsPOS webhook receiver A delivery log for the pack bench. It listens for the fulfillment and order events and keeps a record of what happened to each parcel, so the bench sees a change when it happens rather than on the next poll. It is also the reference **receiver**: the half of the integration that arrives uninvited. If you only take one file, take `sipspos_webhook.py`. ``` sipspos_webhook.py verify a delivery. THIS is the file to copy. parcels.py what this application does with a verified event. ``` Standard library only — `http.server`, `hmac`, `sqlite3`. No framework, because yours will be different. ## Run it 1. The winery adds your URL in **Admin → Setup → Webhooks** and picks the events. They are shown a **signing secret** — that is what you need. It is not an API key: it proves a body came from SipsPOS and grants nothing. 2. Then: ```sh export SIPSPOS_WEBHOOK_SECRET=whsec_… python parcels.py # listens on 127.0.0.1:8400/webhooks ``` To receive real deliveries while developing, put a tunnel in front of it (`cloudflared tunnel --url http://localhost:8400`, `ngrok http 8400`) and give the winery the tunnel's URL. Point it at their **sandbox** first — a test winery emits the same events with invented parcels. ## The five things receivers get wrong Each one passes a casual test and fails in production. This is the whole reason the example exists. **1. Comparing with `==`.** String comparison returns on the first differing byte, so how long it takes leaks how much of a guess was right. Use `hmac.compare_digest`. **2. Verifying the parsed body.** The signature covers *the bytes that arrived*. If you `json.loads` and re-serialise to check, you are checking a different string — key order, spacing and unicode escaping are all free to change. Keep the raw bytes; verify those; parse afterwards. **3. Ignoring the timestamp.** `t` is inside the signed string precisely so a captured delivery stops being usable. Verify the HMAC and skip the clock, and every delivery you have ever received is replayable against you forever. **4. Assuming exactly-once.** It is at-least-once. `X-SipsPOS-Delivery` is the idempotency key. Here it is the *primary key* of the log, so a repeat is refused by the database rather than by a branch somebody might forget. A receiver that treats delivery as exactly-once ships the parcel twice. **5. Doing the work before answering.** The sender applies a timeout and retries anything that is not a 2xx, escalating to **disabling your endpoint** after 1m / 5m / 30m / 2h / 12h of failures. Record the fact, answer, then work. ## The bit people get wrong about the replay window **A 300-second tolerance does not drop retries.** Reading the 1m/5m/30m/2h/12h ladder, the obvious conclusion is that a twelve-hour retry would fall outside any sane window, so the window has to be a day — at which point the timestamp stops protecting anything. It does not, because **every attempt is signed afresh with the current time**. The twelve-hour retry carries a `t` from twelve hours later, not from when the event happened. If you find yourself needing a large tolerance, the problem is a clock rather than the ladder. ## What it answers | Situation | Status | |---|---| | Verified, recorded | `200` | | Verified, already seen | `200` — and nothing is written twice | | Verified, an event it does not handle | `200` — a refusal would be retried forever | | Signature header malformed | `400` | | Signature wrong, or `t` outside the window | `401` | Anything that is not a 2xx will be retried, so answer 2xx to everything you have safely absorbed — including duplicates and events you ignore. ## What this example does not do **It does not re-GET the changed resource.** `customer.updated` means "a watched field changed", and the honest response is to re-read the customer rather than treat the payload as a diff. That needs an API key as well as a signing secret, which is the *other* half of the integration — see the Club Pulse example for the client. Parcel Post consumes the payload it is given. **The work is a thread, not a queue.** "Answer first, work after" is shown with the smallest thing that has the right shape. A real deployment puts the work on a queue; a broker in an example would swamp the thing being demonstrated. **No TLS and no hosting story.** It listens on localhost. Put a tunnel or a reverse proxy in front of it. ## Tested against the real sender `scripts/parcel_post_test.py` signs with `services/webhooks.py`'s own `signature()` — the function production uses — and drives a real `WebhookEndpoint` through `deliver_due()` into this handler. An example that verified its own fake signatures would only prove it agrees with itself. That is not theoretical. SipsPOS shipped a bug where deliveries were signed with a timestamp hours in the past; a receiver applying the tolerance documented above would have rejected every one of them. This example, run against the old code, answers `401` and the delivery goes unacknowledged — which is exactly how it was caught.