# CountWatch recipe: Stripe payments

Counts successful Stripe charges per 5 minutes and alerts when payments spike, drop or stop. No code runs on your side: Stripe calls a webhook URL. Needs a Stripe secret key (`STRIPE_SECRET_KEY`, test or live) and a CountWatch account (the person signs up at https://staging.countwatch.com/login) and a webhook URL the person makes in the dashboard (Sources, https://staging.countwatch.com/dashboard/sources): it is a secret, so the agent never handles it.

## 1. The webhook URL and the watch

The person makes the webhook URL for `stripe_payments` in the dashboard (Sources, https://staging.countwatch.com/dashboard/sources) and keeps it: it is a secret for this one metric, so the agent never sees it. The agent creates the watch as a push watch first (a 5-minute bucket without hits is zero; a watch's source is fixed when it is created): over MCP, `send_counts {"source": "push", "points": [{"metric": "stripe_payments", "add": 0}]}`; with an API key:

```
curl -sX POST https://staging.countwatch.com/v1/ingest -H "Authorization: Bearer $COUNTWATCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"source":"push","points":[{"metric":"stripe_payments","add":0}]}'
```

Query parameters on the link become dimensions: the watch here must have the same ones as the link's hits (each dimension combination is a watch: mind the plan's watch limit).

An import first would make it an explicit watch, which ignores the link's hits.

## 2. Point a Stripe webhook at it

The person adds it in Stripe: **Developers → Webhooks → Add endpoint**, the webhook URL from the dashboard as the URL, the event `charge.succeeded`. Or the person runs this with their Stripe key:

```
# For a scheduler or a person at a terminal, never an agent's shell: it holds a source secret and calls CountWatch.
curl -s https://api.stripe.com/v1/webhook_endpoints -u "$STRIPE_SECRET_KEY:" \
  -d url="<webhook url>" -d "enabled_events[]=charge.succeeded" -d description="CountWatch"
```

Each `charge.succeeded` event adds 1. Stripe retries a failed delivery for up to 3 days; the webhook URL counts each event once (it reads the event id), so retries do not inflate the count. Use `payment_intent.succeeded` or `checkout.session.completed` instead if that is what "a sale" means here; pick one event type per metric. The watch reads a 5-minute bucket without events as zero (source `push`), so a stop in payments is a drop.

## 3. Import 89 days of charges

Two steps, so the Stripe key and the CountWatch key never meet. First the person counts past charges per 5 minutes with their key (Node 18+, no packages): every 5 minutes, zeros included, in UTC (Stripe's `created` is Unix time). The script writes the counts to `stripe-counts.json` and sends nothing. An agent saves it as `stripe-import.mjs` and lists the person's step in `countwatch/NEXT.md`: in that folder, `read -s STRIPE_SECRET_KEY && export STRIPE_SECRET_KEY && node stripe-import.mjs` (the key is pasted at a hidden prompt, so it stays out of the shell history). Without it the watch learns from live charges (about 3 weeks).

```js
// Run by the person: it reads STRIPE_SECRET_KEY, writes stripe-counts.json (counts only) and calls nothing but Stripe.
import { writeFileSync } from "node:fs";
const since = Math.floor(Date.now() / 1000) - 89 * 86400;
const slot = (t) => Math.floor(t / 300); // 5-minute buckets, from Unix seconds
const counts = new Map();
let after;
for (;;) {
  const q = new URLSearchParams({ limit: "100", "created[gte]": String(since) });
  if (after) q.set("starting_after", after);
  const r = await fetch("https://api.stripe.com/v1/charges?" + q, { headers: { authorization: "Bearer " + process.env.STRIPE_SECRET_KEY } });
  const page = await r.json();
  if (!r.ok) throw new Error("Stripe " + r.status + ": " + (page.error?.message ?? "error")); // e.g. a key without charges:read
  for (const c of page.data) if (c.status === "succeeded") counts.set(slot(c.created), (counts.get(slot(c.created)) ?? 0) + 1);
  if (!page.has_more) break;
  after = page.data.at(-1).id;
}
// Up to the last complete 5 minutes: the API stops the import at the webhook URL's first count, so it never overwrites
// live counts.
const first = slot(since) + 1, last = slot(Date.now() / 1000) - 1;
const body = { watches: [{ metric: "stripe_payments", resolution: "5m", start: first * 300000,
  counts: Array.from({ length: last - first + 1 }, (_, i) => counts.get(first + i) ?? 0) }] };
writeFileSync("stripe-counts.json", JSON.stringify(body));
console.log("stripe-counts.json: " + body.watches[0].counts.length + " buckets");
```

Then the agent sends its counts with `import_history` over MCP; without MCP, the person sends the file from the same folder with a write key:

```sh
curl -sS --fail-with-body -X POST https://staging.countwatch.com/v1/import -H "Authorization: Bearer $COUNTWATCH_API_KEY" -H "Content-Type: application/json" --data-binary @stripe-counts.json -w '\n%{http_code}\n'
```

It answers `202` and an `import_id` (the last line is the status).

The watch has no dimensions, like the webhook URL's hits, so both fill the same watch.

## Check it works

1. **Data arrives.** `check_setup` (or `curl -s https://staging.countwatch.com/v1/status -H "Authorization: Bearer $COUNTWATCH_API_KEY"`): the `stripe_payments` watch shows `last_request_at`, and `missing_buckets_24h` stays `0` (explicit watches). Do what `next_actions` says.
2. **History is in.** `GET /v1/imports/{import_id}` shows `status: done` and the rules chosen for `stripe_payments`, with the alerts they would have raised. (Skipped the import? The watch learns for about 3 weeks; starter rules apply meanwhile.)
3. **An alert reaches you.** The person turns on e-mail alerts in the dashboard (https://staging.countwatch.com/dashboard), or you add a webhook (`manage add_destination`, then `verify_destination`; the endpoint answers the challenge, see https://staging.countwatch.com/agent). Then `manage {"action": "send_test_alert"}`: every active destination receives a test alert (`test: true`) within a few minutes.
