These rules apply to every endpoint, so the rest of the reference doesn't repeat them.
Versioning
All endpoints live under /v1. Breaking changes ship as a new version. Additive changes, like new fields, new endpoints or new enum values, can land in v1 at any time, so ignore fields you don't recognize.
https://spysay.com/v1
snake_case
Every field name, query parameter and enum value is snake_case, in requests and responses.
{ "origin_host": "my-app.lovable.app", "on_demand_render": true, "status": "connected_via_api" }
Request bodies are strict. Sending a field that doesn't exist, including a camelCase spelling of a real one, fails with 400 parameter_unknown instead of being silently ignored:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_unknown",
"message": "Received unknown parameter: originHost",
"param": "originHost",
"doc_url": "/docs/api-reference/error-codes#parameter_unknown"
}
}
Objects and IDs
Every object has an object field naming its type and an id whose prefix matches that type, so an ID on its own tells you what it refers to.
| Prefix | Object |
|---|---|
dom_ |
domain |
pur_ |
cache_purge |
snp_ |
render snapshot |
aud_ |
audit |
pg_ |
audit_page |
prm_ |
prompt |
prr_ |
prompt_run |
rpt_ |
visibility_report |
biz_ |
business |
mkt_ |
market |
act_ |
action |
{
"id": "dom_8f3k2j9x",
"object": "domain",
"hostname": "your-app.com",
"created_at": 1761350400
}
Passing the wrong kind of ID, for example an aud_ ID where a prm_ ID belongs, fails with 400 parameter_invalid.
Domains are the exception to ID-only references. Anywhere a domain is expected, whether the :domain path segment or a domain field or filter, you can pass either its dom_ ID or its hostname.
Lists and pagination
Every list endpoint returns the same envelope:
{
"object": "list",
"data": [{ "id": "prm_4k2", "object": "prompt" }, { "id": "prm_9xa", "object": "prompt" }],
"has_more": true,
"next_cursor": "prm_9xa"
}
| Parameter | Type | Description |
|---|---|---|
limit |
number | Items per page, 1 to 100. Default 20. |
starting_after |
string | Pass the previous page's next_cursor to get the next page |
Cursors stay stable while new items are added, so paging through a list won't skip or repeat anything. Every list has a fixed order with ties broken by ID (or by host for sources), so the same request always returns the same pages.
A starting_after that isn't in the list returns 400 parameter_invalid with param: "starting_after", instead of quietly starting over at the first page. That happens when the item was deleted after you fetched its page: start the listing again.
Lists filtered by a date window (sources and the analytics endpoints) default to to today. When you page through one of those, pass the same from and to on every request, so the window can't move under you if a check finishes while you're paging.
curl "https://spysay.com/v1/prompts?business=biz_7hQm2&limit=100&starting_after=prm_9xa" \
-H "x-spysay-api-key: <API_KEY>"
import { createClient } from "@spysay/sdk";
const spysay = createClient({ baseUrl: "https://spysay.com", token: process.env.SPYSAY_API_KEY! });
let startingAfter: string | undefined;
do {
const page = await spysay.prompts.list({ business: "biz_7hQm2", limit: 100, startingAfter });
for (const prompt of page.data) handle(prompt);
startingAfter = page.hasMore ? (page.nextCursor ?? undefined) : undefined;
} while (startingAfter);
Timestamps and dates
Points in time, such as created_at, started_at and last_run_at, are integer Unix seconds: 1761350400. Calendar days, such as from, to, date and the daily buckets in analytics, are YYYY-MM-DD strings in UTC: "2026-10-25". The API doesn't use milliseconds or ISO timestamp strings anywhere.
Booleans, lists and nested data
Booleans are true and false rather than 0 and 1; in a query string that means ?active=true. Lists are JSON arrays ("tags": ["brand", "pricing"]), and nested data is sent as JSON objects rather than as JSON encoded inside a string.
HTTP methods
| Method | Meaning | Success |
|---|---|---|
GET |
Retrieve or list | 200 |
POST /v1/things |
Create | 201 with the object |
POST /v1/things/:id/verb |
Run an action | 200 |
PATCH |
Partial update. Omitted fields don't change; lists replace wholesale. | 200 with the full object |
DELETE |
Delete | 200 with { "id", "object", "deleted": true } |
Errors
Failures return a Stripe-style error object with type, code, message, param and doc_url. Branch on type, then on code.