Developer docs
The Saymeter API and webhooks
Pull your readings into your own dashboards and reports, or get told the moment something changes. The API is read-only and every figure comes with its count and likely range, exactly as it appears in the app. To use it from Claude, see the MCP server.
Keys and limits
Create a key under Integrations in your workspace (owners and admins, on any paid plan or the free trial). Send it as a bearer token. A key reads the whole workspace; it can't change anything. We store only a fingerprint, so a lost key is revoked and replaced, never recovered.
curl https://saymeter.com/api/v1/locations \ -H "Authorization: Bearer smk_your_key"
Each key may make 60 requests a minute. Responses carry X-RateLimit-Remaining; over the limit you get 429 with Retry-After in seconds.
The machine-readable description is at /api/v1/openapi.json (OpenAPI 3.1).
Endpoints
All are GET, under /api/v1, and return JSON.
| Path | What it returns | Query parameters |
|---|---|---|
| /locations | List tracked locations with their latest reading | limit, cursor, client_id |
| /locations/{id} | One location: competitors, latest and previous like-with-like readings, and the change | none |
| /locations/{id}/readings | Scored readings of a location, newest first | limit, cursor, from, to |
| /locations/{id}/fixes | Fix cards for a location, newest first | limit, cursor, status |
| /locations/{id}/answers | The answers of one reading (the latest by default) | limit, cursor, run, engine, question |
| /clients | Clients (a business workspace has one automatic client) | limit, cursor |
| /reports | Monthly, Full Audit and pitch reports, newest first | limit, cursor, location_id |
{
"data": [
{
"id": "6f1c…",
"name": "Smith & Sons Plumbing",
"city": "Leeds",
"client_id": "0b9e…",
"latest_reading": {
"id": "a41d…",
"kind": "weekly",
"finished_at": "2026-10-12T06:14:09.000Z",
"share": { "named": 14, "total": 40, "share": 0.35, "low": 0.23, "high": 0.49 },
"sentence": "Named in 14 of 40 answers (35%, likely 23–49%)."
}
}
],
"next_cursor": null
}Reading the numbers
Every share is an object: named answers out of total answers we actually got, the share, and the 90% likely range low–high. Treat two figures as different only when their ranges don't overlap; that is the rule the app uses for “up” and “down”. An engine that failed is left out of total, never counted as “not named”.
Compare a reading with one of the same kind (weekly with weekly): a free scan or a pitch audit asks fewer questions. /locations/{id} already gives the previous like-with-like reading and the change sentence. See the methodology for how answers are collected and read.
Pages and errors
Lists return { data, next_cursor }. Pass next_cursor back as cursor until it is null. limit is 50 by default and at most 100.
Errors are { error: { code, message } } with status 400 (invalid_request), 401 (unauthorized), 402 (plan_required: the plan lapsed; the key itself is fine), 404 (not_found), 429 (rate_limited) or 500 (server_error, with a request id to quote).
Webhooks and Slack
Add a destination under Integrations and pick its events:
reading.scored: a new reading is scored.competitor.overtook: a competitor clearly overtakes a location.fix.verified: a fix is verified live.fact.mismatch: an ai answer gets a business fact wrong.
A Slack destination takes an incoming-webhook URL (https://hooks.slack.com/services/…) and gets a short message with the figures and a link. Your own endpoint gets a JSON POST:
{
"id": "d7c2…",
"type": "competitor.overtook",
"created_at": "2026-10-12T06:15:02.118Z",
"organization_id": "51aa…",
"data": {
"location": { "id": "6f1c…", "name": "Smith & Sons Plumbing" },
"reading_id": "a41d…",
"competitors": ["Apex Plumbing"],
"sentence": "Apex Plumbing overtook you: its likely range is now entirely above yours, and it wasn't ahead last time.",
"url": "https://saymeter.com/app/51aa…/locations/6f1c…"
}
}Each POST carries Saymeter-Event, Saymeter-Delivery (the event id, for de-duplicating) and Saymeter-Signature: t=…,v1=…: an HMAC-SHA256 of t.body with your signing secret. Verify it before trusting the body:
import { createHmac, timingSafeEqual } from "node:crypto";
// header: the Saymeter-Signature header; body: the raw request body as a string.
export function verifySaymeter(secret, header, body) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 minutes: refuse
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}Answer with any 2xx within 10 seconds. Anything else is retried 3 times, after 30 seconds, 2 minutes, 10 minutes. A destination whose last 20 events all failed is switched off and the workspace owners are emailed; turn it back on from Integrations. Destinations must be public https addresses: we check where the address points when you add it and again before every send.