# Club Pulse — a SipsPOS public API reference application A read-only wine-club retention dashboard in three files of standard-library Python. Run it and it writes one self-contained HTML page: who is about to leave your club, who already has, how each month's joiners have held up, and what each tier is worth per month. It exists to be copied. If you are integrating with the SipsPOS API, the file you want is `sipspos.py` — bearer auth, cursor pagination, the error envelope, `X-Sipspos-Environment` and 429 backoff, with nothing about wine clubs in it. The other two show what using it looks like. | File | What it is | |---|---| | `sipspos.py` | The client. **Copy this one.** Knows nothing about clubs. | | `pulse.py` | This application: `fetch` (four GETs), `analyse` (pure), `main` (the CLI). | | `report.py` | `render_html(findings, …)` — the findings dict as one HTML page. | No dependencies. `import requests` never appears; SipsPOS itself depends on `requests`, and a file that can only be copied along with a dependency list is not really copyable. Python 3 and `urllib` are the whole toolchain. ## Get a test key first 1. Sign in to your winery and go to **Admin → API keys**. 2. Press **Build my sandbox**. That creates a *sandbox twin* — a second winery, seeded with demo data, that runs the same code as the real one. 3. Press **Create test key** and tick **`customers:read`** and **`sales:read`**. Those are the two this example needs. Tick **`orders:read`** as well if the winery has the online store — see below. Every request this example makes is a GET. 4. Copy the `sk_test_…` key when it is shown. It is not shown twice. **`orders:read` is optional, and that is the interesting part.** It maps to the `online_store` capability, which is on Estate and not on Growth — and a sandbox twin deliberately inherits its parent's plan, so a Growth winery *cannot* issue a key carrying it, however much it would like to. An integration that works across plans has to cope with that rather than treat it as a crash, which is the most common reason a partner's client works at one winery and not the next. So `fetch()` attempts the orders read, accepts a `permission_denied` or `feature_not_enabled` refusal as a fact about the plan, and the report says on its own masthead that the shop was not read. **Any other error is re-raised** — a bad token or an API outage must not be swallowed into a quietly wrong report. **Why sales and not just orders.** The *quiet member* check asks whether a member has bought anything recently, and for a wine club the answer usually happens at the cellar door — which is a **sale**, not an order. Reading only `/v1/orders` marks a member who bought a case in the tasting room last week as quiet, and tells the winery to chase the customers it should be thanking. So `/v1/sales` is the primary signal and the shop is additional. `sales:read` is core to the register on every plan and needs no capability, which is why the example still works in full on Growth apart from the shop half of that one check. `paid_since` filters a nullable column: an open tab has a NULL `paid_at` and is excluded. That is right here — an unpaid sale is not evidence that somebody bought something. A `sk_test_` key reaches the twin and cannot reach live data — the two are different tenants, so isolation does not depend on anything this script does. Every response says which one answered, in `X-Sipspos-Environment`; the client exposes it as `Client.environment`, and when it is `"test"` the report opens with a banner saying the numbers are invented. If the key is missing a **required** scope, the API answers `403` and the client raises `ApiError` with `type == "permission_denied"`. `pulse.py` prints that message and a line naming the scopes it needs, then exits `1`. ## Run it ```sh export SIPSPOS_BASE_URL=https://your-winery.sipspos.com # your winery's own host export SIPSPOS_API_KEY=sk_test_… python pulse.py ``` `SIPSPOS_BASE_URL` is the winery's own origin — SipsPOS is addressed per winery, not through one shared `api.` domain. If either variable is unset, the script names the missing one and exits `2` without making a request. ``` --out FILE where to write the report (default: club-pulse.html) --quiet-days N days without an order before a member counts as quiet, and how far back orders are read (default: 120) ``` On success it writes the file, prints a one-line summary with the number of API requests it took, and exits `0`. On an API failure it prints the `ApiError` — type, message, HTTP status and `request_id` — to stderr and exits non-zero. It never prints a traceback: a traceback is a bug report about this script, and what you need is a sentence about your problem with the id support can grep for. The rendered page never contains the key. `render_html` is handed the findings, the host name, the environment and a timestamp — there is no parameter the token could arrive in. ## What the four requests are ``` GET /api/v1/club/members everyone in the club funnel GET /api/v1/customers?club_status=none everyone who is not in it GET /api/v1/club/tiers tier names and prices GET /api/v1/orders?created_since=YYYY-MM-DD the order window ``` The first, second and fourth are cursor-paginated, and `Client.paginate` follows `next_cursor` until `has_more` is false, re-sending your filters on every page. That last part is not optional: a SipsPOS cursor is signed against the filters it was minted under, and a page-two request that drops `club_status=none` is refused with a `400`. ## What each section of the report means **Needs attention.** Every member appears **exactly once**, under the first reason that fits, in this fixed order: | Reason | Derived from | |---|---| | `past_due` | `club_status == "past_due"` | | `no_card` | `club_status == "active"` and `club_card_last4` is empty | | `paused` | `club_paused` (with `club_resume_on` when it is set) | | `skipping` | `club_skip_next` | | `quiet` | an active member with no order in the window, joined on `customer_id` | A member whose card is failing *and* missing appears once, as `past_due` — you cannot fix the second problem without fixing the first. `no_card` is the one worth knowing about: a member migrated from another system arrives active, with a real join date and a real tier and no card, and looks perfectly healthy in every other report until the next charge fails. **Cohorts.** Joiners grouped by `club_since[:7]`, with how many of that month's joiners still have `is_club_member` set. Both the current members and the cancelled ones are counted, or every cohort would look like it retained everybody. Customers with no `club_since` are in no cohort. **Tier mix.** Members per `club_tier_id`, joined to `/v1/club/tiers`. A member whose tier is not in that list — deactivated, or otherwise absent — is counted under **Unknown tier**, never dropped, so the tier table always adds up to the club. Money at this API is `{"amount_cents", "currency"}` or `null`, never a bare number; a tier with a `null` price contributes zero rather than a guess. `tier["price"]` is **one charge, not one month**, so a `quarterly` tier is divided by three and an `annual` one by twelve before it is called a monthly value. **Lost.** Customers with `club_status == "none"` who still have a `club_since`. This is the only way to see churn through this API: `/v1/club/members` is `Customer` narrowed to `club_status != "none"`, so a member is gone from it the moment they cancel. Cancelling clears `club_status`, `club_tier_id` and `is_club_member` but leaves `club_since` alone, and that surviving date is what separates somebody who left from somebody who never joined. ## `analyse` is pure, and that is the part to copy the thinking from ```python findings = pulse.analyse(members, lapsed, tiers, orders, today=datetime.date(2026, 8, 25), quiet_days=120) ``` No network, no clock, no client — `today` is an argument. The clock is read in exactly one place in `pulse.py`, in `main`. That is what lets the analysis be tested with dictionaries typed by hand (`scripts/club_pulse_test.py` does exactly that, with no HTTP at all), and what lets you ask what the report would have said on any other date. ## The gap this example found, and what came of it **There was no way to write back *"we contacted this member"*.** Club Pulse produces a call list, and the natural next step is marking the rows somebody has worked through. When this example was written it could not be done: `CUSTOMER_FIELDS` in `services/api_writes.py` — the whole writable surface of a customer — had no way to record either a label or a contact. So "who have we called" lived outside the report, and re-running it tomorrow produced the same list minus whoever happened to fix their card. **It can be done now, and finding it this way is the argument for having a reference application at all.** Two writes closed it: ```sh # Mark them, using the winery's own CRM vocabulary. PATCH /v1/customers/{id} {"tags": ["VIP", "Contacted"]} # Or record what you actually did, onto the winery's customer timeline. POST /v1/customers/{id}/notes {"note": "Called about the failed charge; card updated.", "channel": "call"} ``` Both need `customers:write`. Two things about them are worth knowing before you build on them, because both are deliberate and neither is guessable: **Setting `tags` replaces the whole set.** `{"tags": ["Contacted"]}` on somebody who was `["VIP"]` leaves them with `Contacted` alone. Read the customer first and send the list they should end up with. The failure here is silent, which is why it is said twice. **A tag this winery has not created is refused**, and the error names the ones that exist. Tags are not free-text labels: a winery segments an allocated wine release by tag, so a typo would become a permanent segment matching nobody. The vocabulary belongs to the winery; this API attaches and detaches. **The notes endpoint records a `note` or a `call`, and nothing else.** It cannot write an `email` or `sms` entry, because such a row asserts that a message was *sent* — and that timeline is what a winery consults when arguing about whether anybody was contacted. If you sent your own email, log a note saying what you sent. `POST …/notes` needs an `Idempotency-Key`; it creates a row, so a retry after a timeout would otherwise claim a second call. `PATCH` needs none — assigning the same tags twice is already the same outcome. **Still not there:** reading the winery's timeline back (`GET /v1/customers/{id}/communications`). An integration cannot yet see that the winery emailed somebody yesterday, which is the real way a retention workflow double-contacts a member. Recorded here the same way the last gap was.