CountWatchDocs

Docs / Connect your data

Stripe

Get an alert when payments spike, drop or stop. Stripe sends each payment to CountWatch; nothing runs on your side.

In short: make a Webhook URL under Senders (Connect) and add it in Stripe for successful payments; each payment counts one. Import the last 89 days to start alerts today.

The steps below work for you or your coding agent. Your agent does every step that needs no secret. The key comes from npx countwatch init (you approve it); a Webhook URL you make under Senders (Connect), or approve for your agent.

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.

// 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:

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.

For agents: this page as Markdown.