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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | CountJar-Webhook/1.0 |
X-CountJar-Signature | sha256= 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);
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.
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.