Skip to content
CountJar
Docs Pricing The counter API for developers A hit counter for any website A view counter badge for your GitHub README An embeddable like button A yes/no poll you can embed anywhere Five-star ratings for any page Start free

Milestone alerts

Get told when a counter reaches a number: a webhook to your server or a text to your phone. Celebrate the 1,000th sign-up, or know when the last seats are going.

How milestones work

  • A milestone has a threshold and a channel: a webhook URL or your phone.
  • It fires when the counter goes from below the threshold to the threshold or above. That works for any change that raises the count, including up, set, likes, votes and ratings.
  • It fires once. Even if many requests arrive at the same moment, only one alert is sent.
  • A milestone that the counter has already passed when you add it won't fire until you re-arm it.
  • Each counter can have up to 10 milestones.

Add a milestone

Open the counter in the dashboard and go to the Milestones tab. Enter the number under When the count reaches, choose Webhook or Text message, and save. Each milestone shows whether it's waiting, has fired, or failed.

To use a milestone again, choose Re-arm. It then fires the next time the counter climbs past the threshold. If the counter is already above it, that means it has to drop below first.

Webhook requests

When a webhook milestone fires, CountJar sends a POST request to your URL with this JSON body:

{
  "event": "milestone.reached",
  "workspace": "acme",
  "counter": "signups",
  "threshold": 1000,
  "count": 1000,
  "fired_at": "2026-10-14T09:12:44Z"
}

count is the value right after the change that crossed the threshold, so it can be higher than threshold if the counter jumped past it. The request has these headers:

HeaderValue
Content-Typeapplication/json
User-AgentCountJar-Webhook/1.0
X-CountJar-Signaturesha256= followed by the HMAC-SHA256 of the raw body, as hex, using the milestone's signing secret.

Webhook URLs must:

  • start with https:// and use the standard port (443);
  • point to a public internet address, not a private network or localhost;
  • respond within 3 seconds with a status below 400.

Redirects aren't followed, so use the final URL.

Verify the signature

Anyone who finds your webhook URL could send it a request. Check the signature so you only act on requests from CountJar. Find the signing secret, which starts with whsec_, under Signing secret on the milestone, and store it as COUNTJAR_WEBHOOK_SECRET on your server.

Compute the HMAC over the exact bytes you received, before parsing the JSON, and compare it in constant time:

import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.COUNTJAR_WEBHOOK_SECRET;

// express.raw keeps the body as bytes, exactly as CountJar signed it.
app.post("/hooks/countjar", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(req.body).digest("hex");
  const given = req.get("X-CountJar-Signature") || "";
  const valid = given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  console.log(`${event.workspace}/${event.counter} reached ${event.threshold}`);
  res.sendStatus(204);
});

app.listen(3000);
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["COUNTJAR_WEBHOOK_SECRET"].encode()


@app.post("/hooks/countjar")
def countjar_hook():
    body = request.get_data()  # the raw bytes, before any JSON parsing
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    given = request.headers.get("X-CountJar-Signature", "")
    if not hmac.compare_digest(expected.encode(), given.encode()):
        abort(401)

    event = request.get_json()
    print(f"{event['workspace']}/{event['counter']} reached {event['threshold']}")
    return "", 204
<?php
$body = file_get_contents("php://input");
$expected = "sha256=" . hash_hmac("sha256", $body, getenv("COUNTJAR_WEBHOOK_SECRET"));
$given = $_SERVER["HTTP_X_COUNTJAR_SIGNATURE"] ?? "";

if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
error_log("{$event['workspace']}/{$event['counter']} reached {$event['threshold']}");
http_response_code(204);
To also turn away old requests that someone recorded and sent again, ignore deliveries whose fired_at is more than a few minutes old. It's covered by the signature, so it can't be changed without breaking it.

Text alerts

Text alerts go to the phone number you signed in with. You can't send them to anyone else, including teammates; they can add their own.

🫙 CountJar: acme/signups just reached 1,000 (now 1,000).

Each phone number gets at most 5 milestone texts a day across all workspaces. Alerts over that limit are skipped and marked as failed, so you can re-arm them. If you leave a workspace, or are removed from it, your text alerts there are deleted.

When delivery fails

CountJar tries each alert once and doesn't retry. If your server is down, too slow, or returns an error, or a text can't be sent, the milestone is marked Failed and the reason is shown when you hover over it. Choose Re-arm once you've fixed the problem.

A failed alert never affects counting. The request that crossed the threshold still succeeds.

Since there are no retries, don't rely on a webhook as the only record of something important. Use the counter itself, or its events, as the source of truth.

Test a milestone

Add a milestone one above the counter's current value, then count up once from a terminal:

curl -X POST https://countjar.vercel.app/api/v2/acme/signups/up \
  -H "Authorization: Bearer $COUNTJAR_TOKEN"

The webhook or text should arrive within a few seconds, and the milestone will show as fired. To test a webhook before your server is ready, point it at a request inspector such as a temporary URL from a webhook testing service.