A business is what Spysay tracks. It doesn't need a website: a shop known only by its Maps listing or Facebook page is tracked from those profiles. Each business has one or more markets, the places its prompts are asked from, as wide as a country or as narrow as a district.
The business object
{
"id": "biz_7hQm2",
"object": "business",
"name": "Lumen Dental",
"aliases": ["Lumen", "流明牙科"],
"domain_id": "dom_8f3k2j9x",
"profiles": ["https://maps.google.com/?cid=1234567890", "https://www.instagram.com/lumendental"],
"verified_at": 1761350400,
"first_check_at": 1761350460,
"created_at": 1761350400
}
aliases are the other names customers and AI use, including names in other scripts. An answer naming any of them counts as a mention. An answer citing one of the profiles counts as citing the business. domain_id is null for a business without a website. verified_at is null until the business is verified. first_check_at is when its first check started; scheduled checks begin after it.
Create a business
POST /v1/businesses
| Parameter | Type | Description |
|---|---|---|
name (required) |
string | Max 200 chars |
aliases |
string[] | Up to 20. Default []. |
domain |
string | dom_ ID or hostname of a connected domain. Leave it out for a business without a website. |
profiles |
string[] | Up to 20 URLs: Maps listings and social pages. Default []. |
Returns 201 with the business.
curl -X POST "https://spysay.com/v1/businesses" \
-H "Content-Type: application/json" \
-H "x-spysay-api-key: <API_KEY>" \
-d '{"name":"Lumen Dental","aliases":["Lumen"],"profiles":["https://www.instagram.com/lumendental"]}'
import { createClient } from "@spysay/sdk";
const spysay = createClient({ baseUrl: "https://spysay.com", token: process.env.SPYSAY_API_KEY! });
const business = await spysay.businesses.create({
name: "Lumen Dental",
aliases: ["Lumen"],
profiles: ["https://www.instagram.com/lumendental"],
});
List, retrieve, update and delete
| Endpoint | Description |
|---|---|
GET /v1/businesses |
Lists businesses. Takes limit and starting_after. |
GET /v1/businesses/:id |
Retrieves a business |
PATCH /v1/businesses/:id |
Changes any create field. List fields replace wholesale. domain: null detaches the website. |
DELETE /v1/businesses/:id |
Deletes the business with its markets, prompts and actions. Returns { "id": "biz_…", "object": "business", "deleted": true }. |
Verify a business
On Growth, a business's prompts are checked only once it's verified as yours. Agency accounts skip this.
POST /v1/businesses/:id/verify
Verifies the business when the signed-in person's email is at the business's own website domain: its registrable domain, so an@lumen.dental verifies lumen.dental and shop.lumen.dental. An address on a subdomain (an@mail.lumen.dental), free mail such as Gmail, and throwaway mail never verify. It needs a signed-in session, not an API key, and returns the business.
A domain verifies one business, in one account, at a time. Adding a website outside the verified domain undoes the verification.
With a code in the body, it verifies with the six-digit code that POST /v1/businesses/:id/verification_emails mailed to an address at the website's domain instead, so an API key works too. A code works for an hour and for five wrong tries; sending a new one replaces it.
{ "code": "482913" }
| Error | When |
|---|---|
verification_failed (422) |
The email isn't at the website's domain, there's no signed-in person, or the code is wrong or expired |
business_already_claimed (409) |
Another account has verified this website |
Markets
The market object
{
"id": "mkt_3xUs",
"object": "market",
"business_id": "biz_7hQm2",
"country": "US",
"region": "Texas",
"city": "Austin",
"district": null,
"languages": ["en"],
"geo": { "lat": 30.2672, "lng": -97.7431, "timezone": "America/Chicago" },
"competitors": ["Austin Smile Studio", "Bright Dental TX"],
"created_at": 1761350400
}
region, city and district narrow the market step by step; leave the rest null to stay at that level. A prompt is written in one of the market's languages.
geo is the device location sent to providers that read it. With it, Google AI Mode and AI Overviews answer "near me" questions for that exact spot, whichever IP the request leaves from. ChatGPT and most other providers ignore it and go by IP alone, so every run records where it was really checked from in checked_from.
competitors are counted in answers alongside the business.
POST /v1/markets
| Parameter | Type | Description |
|---|---|---|
business (required) |
string | biz_ ID |
country (required) |
string | ISO 3166-1 alpha-2, e.g. US, VN |
region, city, district |
string | Each optional, max 120 chars |
languages (required) |
string[] | 1 to 5 BCP 47 tags, e.g. ["en", "zh-Hans"] |
geo |
object | { lat, lng, timezone }, with an IANA time zone |
competitors |
string[] | Up to 20 names. Default []. |
Returns 201 with the market.
curl -X POST "https://spysay.com/v1/markets" \
-H "Content-Type: application/json" \
-H "x-spysay-api-key: <API_KEY>" \
-d '{"business":"biz_7hQm2","country":"US","region":"Texas","city":"Austin","languages":["en"],"geo":{"lat":30.2672,"lng":-97.7431,"timezone":"America/Chicago"}}'
import { createClient } from "@spysay/sdk";
const spysay = createClient({ baseUrl: "https://spysay.com", token: process.env.SPYSAY_API_KEY! });
const market = await spysay.markets.create({
business: "biz_7hQm2",
country: "US",
region: "Texas",
city: "Austin",
languages: ["en"],
geo: { lat: 30.2672, lng: -97.7431, timezone: "America/Chicago" },
});
| Endpoint | Description |
|---|---|
GET /v1/markets |
Lists a business's markets. Takes business (required), limit and starting_after. |
GET /v1/markets/:id |
Retrieves a market |
PATCH /v1/markets/:id |
Changes any field except business and country; a new country is a new market. null clears region, city, district or geo. |
DELETE /v1/markets/:id |
Deletes the market and its prompts. Returns { "id": "mkt_…", "object": "market", "deleted": true }. |
| Status | Codes |
|---|---|
400 |
parameter_invalid, parameter_unknown, parameter_missing |
404 |
resource_missing |