# 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://countwatch.com/login) and a Webhook URL the person makes in the dashboard (Senders (Connect), https://countwatch.com/dashboard/connections?tab=senders): it is a secret for one metric, so the agent gets it only through MCP `webhook_url`, after the person approves, to paste into the tool itself.

## 1. The Webhook URL and the watch

The agent creates the watch as a push watch first (a 5-minute window without hits is zero; a watch's source is fixed by its first count; until then it waits for that count): over MCP, `manage {"action": "create_watch", "body": {"metric": "payments", "source": "push", "recipe": "stripe"}}`; with an API key:

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

The Webhook URL for `payments` is a secret for this one metric. The person makes it in the dashboard (Senders (Connect), https://countwatch.com/dashboard/connections?tab=senders) and pastes it into the tool. When the agent sets the webhook up in the tool itself (through the tool's own MCP server or API), it may get the URL instead with MCP `webhook_url {"action": "make_webhook_url", "metric": "payments"}`, which the person approves first, and paste it only into that tool: never into code, a repository, chat or logs. API keys and signing secrets stay the person's.

Query parameters on the link become tags (`?repo=api` is the tag `repo:api`): the watch here must have the same ones as the link's hits (each combination of tag values 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 (the dashboard's New watch shows the same steps):

1. In Stripe, go to **Developers → Webhooks → Add endpoint**.
2. Paste the Webhook URL as the endpoint URL.
3. Choose the event `charge.succeeded`, then press **Add endpoint**.

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 window 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.

```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 = { recipe: "stripe", watches: [{ metric: "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://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 tags, like the Webhook URL's hits, so both fill the same watch.

## Check it works

1. **Data arrives.** `check_setup` (or `curl -s https://countwatch.com/v1/status -H "Authorization: Bearer $COUNTWATCH_API_KEY"`): the `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 `payments`, with the alerts they would have raised. (Skipped the import? The watch learns from live data; starter rules apply meanwhile.)
3. **An alert reaches you.** The person turns on e-mail alerts in the dashboard (Destinations (Connect), https://countwatch.com/dashboard/connections?tab=destinations), or you add a webhook (`manage create_destination`, then `verify_destination`; the endpoint answers the challenge, see https://countwatch.com/agent). A write key cannot read alerts: read them with MCP `alerts`, or with `GET /v1/alerts` and an admin key. Then `manage {"action": "send_test_alert"}`: every active destination receives a test alert (`test: true`) within a few minutes. Real alerts go only where the watch's group chose (Alerts go to, or `manage update_group` with `destination_ids`); until then they stay in the app.
