# CountWatch recipe: Base44 backend functions

Counts each call of your app's backend functions as one hit to a Webhook URL, with the function's name and the outcome (`ok` or `error`) as tags. Each function × outcome is one watch (a push watch: it updates at once; mind the plan's watch limit and group related functions).

**Base44 traps first:** editing a function file (in the sandbox, or with Base44's `edit_file`, `write_file` or `run_command`) does not deploy it. Deploy each function ("Save & Redeploy" in the editor, or `base44 functions deploy`), then **publish the app again**: the published app keeps the old functions until then. Secrets reach functions as `Deno.env`, not the sandbox shell.

## 1. The Webhook URL

The person makes a Webhook URL for a metric named for what it counts (for example `function_calls`, not the app's name) in the dashboard (Senders (Connect), https://countwatch.com/dashboard/connections?tab=senders) and adds it as the Base44 secret `COUNTWATCH_WEBHOOK_URL`. It is a secret: keep it out of chat (link previews are ignored, but anyone who has the link can send counts).

## 2. The wrapper

Save as `functions/shared/countwatch.ts` (or next to each function):

```ts
// countwatch.ts — CountWatch for Base44 backend functions (Deno). Wrap a function's handler and each call
// sends one hit to COUNTWATCH_WEBHOOK_URL with ?function=<name>&outcome=ok|error: ok on 2xx, error on 5xx or a thrown
// error; a 4xx (the caller's mistake) is not counted. It never changes the response, waits at most 1.5 s, and does
// nothing when the secret is not set.
type Handler = (req: Request) => Response | Promise<Response>;

async function hit(fn: string, outcome: "ok" | "error"): Promise<void> {
  const link = Deno.env.get("COUNTWATCH_WEBHOOK_URL");
  if (!link) return;
  const url = new URL(link);
  url.searchParams.set("function", fn);
  url.searchParams.set("outcome", outcome);
  try {
    await fetch(url, { method: "POST", signal: AbortSignal.timeout(1500) });
  } catch {
    // counting never breaks the function
  }
}

export function withCountWatch(fn: string, handler: Handler): Handler {
  return async (req) => {
    let res: Response;
    try {
      res = await handler(req);
    } catch (err) {
      await hit(fn, "error");
      throw err;
    }
    if (res.status >= 200 && res.status < 300) await hit(fn, "ok");
    else if (res.status >= 500) await hit(fn, "error");
    return res;
  };
}
```

Wrap each function's handler:

```ts
import { withCountWatch } from "../shared/countwatch.ts";

Deno.serve(withCountWatch("finalize", async (req) => {
  // … the function as before
  return Response.json({ ok: true });
}));
```

Do not wrap the function that receives CountWatch alerts: its calls would feed back into the counts.

## 3. Deploy and test

Redeploy every wrapped function, then publish the app. A function that needs a signed-in user should answer 401 when there is none (`base44.auth.me()` throws; catch it): a 4xx is not counted, so signed-out visits and bots never add errors. Call a wrapped function that answers 200 without sign-in (or sign in and call one): its `outcome:ok` watch appears in `check_setup`.

## 4. Alerts to a function

A Base44 webhook trigger (`https://cwebhooks.base44.com/api/hooks/…`) queues the request and answers before your code runs, so it cannot answer the challenge. Use the receiving function's own URL, `https://<app>.base44.app/functions/<name>`, and answer the challenge in it:

```ts
// Standard Webhooks: HMAC-SHA256 of "id.timestamp.body" with the destination's signing secret, within 5 minutes;
// crypto.subtle.verify compares in constant time.
async function signed(req: Request, raw: string, secret: string | undefined): Promise<boolean> {
  const id = req.headers.get("webhook-id"), ts = req.headers.get("webhook-timestamp") ?? "";
  if (!secret || !id || !/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const bytes = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
  const key = await crypto.subtle.importKey("raw", bytes(secret.replace(/^whsec_/, "")), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  const data = new TextEncoder().encode(`${id}.${ts}.${raw}`);
  for (const sig of (req.headers.get("webhook-signature") ?? "").split(" ")) {
    if (sig.startsWith("v1,") && (await crypto.subtle.verify("HMAC", key, bytes(sig.slice(3)), data).catch(() => false))) return true;
  }
  return false;
}

Deno.serve(async (req) => {
  const raw = await req.text(); // the signature covers these exact bytes
  const body = JSON.parse(raw);
  if (body.type === "destination.verify") return Response.json({ challenge: body.data.challenge });
  if (!(await signed(req, raw, Deno.env.get("COUNTWATCH_WEBHOOK_SECRET")))) return new Response("bad signature", { status: 401 });
  // … act on body (alert.opened, alert.resolved)
  return new Response(null, { status: 204 });
});
```

Then `manage {"action": "create_destination", "body": {"url": "https://<app>.base44.app/functions/<name>"}}` and `verify_destination`. A signing secret is shown once: the person makes one in Destinations (Connect) → Edit → **Get a new signing secret** and puts it in the Base44 secret `COUNTWATCH_WEBHOOK_SECRET`; without it every alert is refused (401).

## Check it works

1. **Data arrives.** `check_setup` (or `curl -s https://countwatch.com/v1/status -H "Authorization: Bearer $COUNTWATCH_API_KEY"`): the `function_calls` 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 `function_calls`, 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.
