earful api
Everything the Earful app does: find a business on review sites, add its review pages, read reviews as they arrive, draft replies with AI, post them, and get webhooks for new reviews.
Authenticate with Authorization: Bearer ef_…. Every plan includes the API; plan limits apply as in the app. Errors are RFC 9457 application/problem+json. Every keyed answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset for its key (120 requests a minute). Ids are opaque strings like loc_3 (bare numbers still work in paths). Lists page with cursor: pass nextCursor back until it’s null (offset still works but is deprecated). GET /reviews/changes keeps a copy in sync. Every POST takes an Idempotency-Key. Webhooks are signed, retried with backoff, logged and replayable. An ef_test_ key reaches a sandbox with made-up data that makes no outside calls except its own webhooks, sent with livemode: false. TypeScript and Python clients generated from this spec are in the repo’s sdk/ folder.
The MCP server at /mcp also takes OAuth 2.1 access tokens (see mcpOAuth), which is how claude.ai connectors sign in. Those tokens only work at /mcp; the REST API takes keys.
place search
Find a business on review sites by name.
POST /api/v1/place-searches
Search for a business createPlaceSearch
Starts a search for a business or app on each site that has a search source: Google, Tripadvisor and Trustpilot (through DataForSEO, when the server has it), the App Store and Google Play. Answers 202 at once; poll GET /place-searches/{id}. Each site retries with backoff until it’s done or has failed 5 times. Other sites are added by link. 30 searches an hour per workspace.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
query | string | The business or app name. |
near | string optional | A town or area, for Google and Tripadvisor. |
sites | array of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional | Defaults to every searchable site. |
{
"query": "Harbour & Vine",
"near": "Falmouth"
}
Responses
- 202 Started. Location points at the search.
Field Type About idinteger Search id. querystring What was searched for. nearstring or null statusstring: running | done | failed done once every site has finished (some may have failed); failed when all did. createdAtstring When it started. sitesarray of object candidatesarray of Candidate { "id": 55, "query": "Harbour & Vine", "near": "Falmouth", "status": "running", "createdAt": "2026-10-28T09:30:00.000Z", "sites": [ { "site": "appstore", "status": "pending", "attempts": 0, "error": null, "finishedAt": null }, { "site": "google", "status": "pending", "attempts": 0, "error": null, "finishedAt": null }, { "site": "tripadvisor", "status": "pending", "attempts": 1, "error": null, "finishedAt": null } ], "candidates": [] } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
GET /api/v1/place-searches/{id}
Search status and results getPlaceSearch
Where each site’s search is, and the businesses found so far. Add one with POST /locations/{id}/profiles {candidateId}.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The search.
Field Type About idinteger Search id. querystring What was searched for. nearstring or null statusstring: running | done | failed done once every site has finished (some may have failed); failed when all did. createdAtstring When it started. sitesarray of object candidatesarray of Candidate { "id": 55, "query": "Harbour & Vine", "near": "Falmouth", "status": "done", "createdAt": "2026-10-28T09:30:00.000Z", "sites": [ { "site": "appstore", "status": "done", "attempts": 0, "error": null, "finishedAt": "2026-10-28T09:30:00.000Z" }, { "site": "google", "status": "done", "attempts": 0, "error": null, "finishedAt": "2026-10-28T09:30:00.000Z" }, { "site": "tripadvisor", "status": "done", "attempts": 1, "error": null, "finishedAt": "2026-10-28T09:30:00.000Z" } ], "candidates": [ { "candidateId": 301, "site": "google", "name": "Harbour & Vine", "address": "12 Arwenack St, Falmouth TR11 3JA", "url": "https://www.google.com/maps/place/Harbour+%26+Vine/@50.153,-5.067,17z?cid=1234567890123456789", "rating": 4.7, "reviewCount": 532 }, { "candidateId": 302, "site": "tripadvisor", "name": "Harbour & Vine", "address": null, "url": "https://www.tripadvisor.co.uk/Restaurant_Review-g186233-d1234567-Reviews-Harbour_Vine-Falmouth.html", "rating": 4.5, "reviewCount": 214 } ] } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
locations
Businesses (or apps), each across its review sites.
GET /api/v1/locations
List locations listLocations
Every location in the workspace, oldest first. Locations past the plan’s limit stay listed but aren’t collected.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
externalRef optional | query | string | Only the location with your reference. |
Responses
- 200 The locations.
Field Type About dataarray of Location { "data": [ { "id": "loc_3", "name": "Harbour & Vine, Falmouth", "profileCount": 2, "createdAt": "2026-10-28T09:30:00.000Z", "externalRef": null } ] } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
POST /api/v1/locations
Add a location createLocation
Adds a business or branch. Refused with 402 once the plan’s locations are used up.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
name | string | |
externalRef | string or null optional | Your own reference for it, like your store id. Filter with GET /locations?externalRef=. |
{
"name": "Harbour & Vine, Falmouth",
"externalRef": "store-0042"
}
Responses
- 201 The new location.
Field Type About idstring Location id. namestring The business or branch name. profileCountinteger How many review pages this location has. createdAtstring When it was added. externalRefstring or null Your own reference for it, like your store id. Filter with GET /locations?externalRef=. { "id": "loc_3", "name": "Harbour & Vine, Falmouth", "profileCount": 0, "createdAt": "2026-10-28T09:30:00.000Z", "externalRef": null } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
GET /api/v1/locations/{id}
Get a location getLocation
One location.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The location.
Field Type About idstring Location id. namestring The business or branch name. profileCountinteger How many review pages this location has. createdAtstring When it was added. externalRefstring or null Your own reference for it, like your store id. Filter with GET /locations?externalRef=. { "id": "loc_3", "name": "Harbour & Vine, Falmouth", "profileCount": 2, "createdAt": "2026-10-28T09:30:00.000Z", "externalRef": null } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
PATCH /api/v1/locations/{id}
Rename a location updateLocation
Renames a location.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Body
| Field | Type | About |
|---|---|---|
name | string optional | |
externalRef | string or null optional | Your own reference for it, like your store id. Filter with GET /locations?externalRef=. |
{
"name": "Harbour & Vine, Falmouth harbour",
"externalRef": "store-0042"
}
Responses
- 200 The renamed location.
Field Type About idstring Location id. namestring The business or branch name. profileCountinteger How many review pages this location has. createdAtstring When it was added. externalRefstring or null Your own reference for it, like your store id. Filter with GET /locations?externalRef=. { "id": "loc_3", "name": "Harbour & Vine, Falmouth", "profileCount": 2, "createdAt": "2026-10-28T09:30:00.000Z", "externalRef": null } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
DELETE /api/v1/locations/{id}
Delete a location deleteLocation
Deletes a location with its review pages and their reviews. A workspace keeps at least one location (409).
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 429 Too Many Requests
profiles
Review pages: one site of one location.
POST /api/v1/locations/{id}/profiles
Add a review page to a location addProfile
Adds a review page by its link (url) or by a place search result (candidateId), and queues its first collection. One page per site per location (409).
Needs a write key.
One profile per listing: a link to one that’s already here (another domain, tracking parameters, http) answers 409 with existingId.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
url | string optional | The review page link. |
candidateId | integer optional | A candidate from GET /place-searches/{id}. |
name | string optional | Defaults to the site and business name. |
{
"candidateId": 302
}
Responses
- 201 The new profile.
Field Type About idstring Profile id. locationIdstring The location it belongs to. locationNamestring or null That location’s name. sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra Which review site. namestring A name for the review page. urlstring The review page. reviewCountinteger Reviews held (not counting removed ones). averageRatingnumber or null Average star rating of those reviews. lastCheckedAtstring or null When reviews were last collected. nextCheckAtstring When the next collection is due. lastErrorstring or null Why the last collection failed, if it did. createdAtstring When it was added. externalIdstring or null The listing’s own id on its site: a Google cid or place id, a Tripadvisor location id (d7106969), a Trustpilot domain, an App Store id, a Play package, a Booking.com hotel slug. Two links to one listing share it, so it’s what to match on, not url. null where the link doesn’t carry one. importobject Its history import. The first reviews come within a minute or two; then older ones load in the background, up to the plan’s cap (Free 40, Plus 5,000). A profile.imported webhook says when it’s over. statusstring: queued | importing | done | failed queued: first import not in yet. importing: loading older reviews. done or failed: over. reviewsinteger Reviews held so far. limitinteger or null The history cap it ran to, once over. finishedAtstring or null When it was over. errorstring or null Why it failed, if it did. { "id": "prf_12", "locationId": "loc_3", "locationName": "Harbour & Vine, Falmouth", "site": "tripadvisor", "name": "Tripadvisor: Harbour & Vine", "url": "https://www.tripadvisor.co.uk/Restaurant_Review-g186233-d1234567-Reviews-Harbour_Vine-Falmouth.html", "reviewCount": 0, "averageRating": null, "lastCheckedAt": null, "nextCheckAt": "2026-10-28T10:30:00.000Z", "lastError": null, "createdAt": "2026-10-01T12:00:00.000Z", "externalId": null, "import": { "status": "done", "reviews": 0, "limit": 5000, "finishedAt": null, "error": null } } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
GET /api/v1/profiles
List profiles listProfiles
Every review page, or those of one location. Find a listing with site and externalId rather than its URL: links change, ids don’t.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
location optional | query | string | Only this location’s. |
site optional | query | string | With externalId: find a listing. |
externalId optional | query | string | The listing’s id on its site (see Profile.externalId), like d7106969 on Tripadvisor. |
Responses
- 200 The profiles.
Field Type About dataarray of Profile { "data": [ { "id": "prf_12", "locationId": "loc_3", "locationName": "Harbour & Vine, Falmouth", "site": "tripadvisor", "name": "Tripadvisor: Harbour & Vine", "url": "https://www.tripadvisor.co.uk/Restaurant_Review-g186233-d1234567-Reviews-Harbour_Vine-Falmouth.html", "reviewCount": 214, "averageRating": 4.6, "lastCheckedAt": "2026-10-28T09:30:00.000Z", "nextCheckAt": "2026-10-28T10:30:00.000Z", "lastError": null, "createdAt": "2026-10-01T12:00:00.000Z", "externalId": null, "import": { "status": "done", "reviews": 214, "limit": 5000, "finishedAt": "2026-10-28T09:30:00.000Z", "error": null } } ] } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
GET /api/v1/profiles/{id}
Get a profile getProfile
One review page.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The profile.
Field Type About idstring Profile id. locationIdstring The location it belongs to. locationNamestring or null That location’s name. sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra Which review site. namestring A name for the review page. urlstring The review page. reviewCountinteger Reviews held (not counting removed ones). averageRatingnumber or null Average star rating of those reviews. lastCheckedAtstring or null When reviews were last collected. nextCheckAtstring When the next collection is due. lastErrorstring or null Why the last collection failed, if it did. createdAtstring When it was added. externalIdstring or null The listing’s own id on its site: a Google cid or place id, a Tripadvisor location id (d7106969), a Trustpilot domain, an App Store id, a Play package, a Booking.com hotel slug. Two links to one listing share it, so it’s what to match on, not url. null where the link doesn’t carry one. importobject Its history import. The first reviews come within a minute or two; then older ones load in the background, up to the plan’s cap (Free 40, Plus 5,000). A profile.imported webhook says when it’s over. statusstring: queued | importing | done | failed queued: first import not in yet. importing: loading older reviews. done or failed: over. reviewsinteger Reviews held so far. limitinteger or null The history cap it ran to, once over. finishedAtstring or null When it was over. errorstring or null Why it failed, if it did. { "id": "prf_12", "locationId": "loc_3", "locationName": "Harbour & Vine, Falmouth", "site": "tripadvisor", "name": "Tripadvisor: Harbour & Vine", "url": "https://www.tripadvisor.co.uk/Restaurant_Review-g186233-d1234567-Reviews-Harbour_Vine-Falmouth.html", "reviewCount": 214, "averageRating": 4.6, "lastCheckedAt": "2026-10-28T09:30:00.000Z", "nextCheckAt": "2026-10-28T10:30:00.000Z", "lastError": null, "createdAt": "2026-10-01T12:00:00.000Z", "externalId": null, "import": { "status": "done", "reviews": 214, "limit": 5000, "finishedAt": "2026-10-28T09:30:00.000Z", "error": null } } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
DELETE /api/v1/profiles/{id}
Delete a profile deleteProfile
Deletes a review page and its reviews.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/profiles/{id}/check
Collect now checkProfile
Queues a collection straight away instead of at the plan’s next check. At most 10 an hour per workspace (429). New reviews appear in a minute or two.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 202 Queued; nextCheckAt is now.
Field Type About idstring Profile id. locationIdstring The location it belongs to. locationNamestring or null That location’s name. sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra Which review site. namestring A name for the review page. urlstring The review page. reviewCountinteger Reviews held (not counting removed ones). averageRatingnumber or null Average star rating of those reviews. lastCheckedAtstring or null When reviews were last collected. nextCheckAtstring When the next collection is due. lastErrorstring or null Why the last collection failed, if it did. createdAtstring When it was added. externalIdstring or null The listing’s own id on its site: a Google cid or place id, a Tripadvisor location id (d7106969), a Trustpilot domain, an App Store id, a Play package, a Booking.com hotel slug. Two links to one listing share it, so it’s what to match on, not url. null where the link doesn’t carry one. importobject Its history import. The first reviews come within a minute or two; then older ones load in the background, up to the plan’s cap (Free 40, Plus 5,000). A profile.imported webhook says when it’s over. statusstring: queued | importing | done | failed queued: first import not in yet. importing: loading older reviews. done or failed: over. reviewsinteger Reviews held so far. limitinteger or null The history cap it ran to, once over. finishedAtstring or null When it was over. errorstring or null Why it failed, if it did. { "id": "prf_12", "locationId": "loc_3", "locationName": "Harbour & Vine, Falmouth", "site": "tripadvisor", "name": "Tripadvisor: Harbour & Vine", "url": "https://www.tripadvisor.co.uk/Restaurant_Review-g186233-d1234567-Reviews-Harbour_Vine-Falmouth.html", "reviewCount": 214, "averageRating": 4.6, "lastCheckedAt": "2026-10-28T09:30:00.000Z", "nextCheckAt": "2026-10-28T09:30:00.000Z", "lastError": null, "createdAt": "2026-10-01T12:00:00.000Z", "externalId": null, "import": { "status": "done", "reviews": 214, "limit": 5000, "finishedAt": "2026-10-28T09:30:00.000Z", "error": null } } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
reviews
Collected reviews.
GET /api/v1/reviews
List reviews listReviews
Reviews newest first, filtered like the inbox. Removed reviews are left out unless status is deleted or any.
Any key (read or write).
Page with cursor: pass each answer’s nextCursor back until it’s null. limit and offset still work but offset is deprecated. To keep a copy in sync, use GET /reviews/changes instead.
Parameters
| Name | In | Type | About |
|---|---|---|---|
site optional | query | string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra | |
location optional | query | string | |
profile optional | query | string | |
rating optional | query | integer | |
status optional | query | string: unreplied | replied | open | handled | edited | deleted | any | |
from optional | query | string | Published on or after. |
to optional | query | string | Published on or before. |
since optional | query | string | First seen by Earful at or after; for polling. |
q optional | query | string | Text, title or reviewer contains. |
tag optional | query | string | |
Limit optional | query | integer | Page size. |
Offset optional | query | integer | Deprecated: page with cursor. Rows to skip. |
Cursor optional | query | string | nextCursor from the previous page. Keep the same filters. |
sentiment optional | query | string: positive | neutral | negative | mixed | Only reviews the AI read this way. |
version optional | query | string | App stores: reviews left on this app version. |
country optional | query | string | App stores: reviews from this store country. |
Responses
- 200 A page of reviews.
Field Type About dataarray of Review totalinteger Matching reviews. limitinteger offsetinteger nextCursorstring or null The next page’s cursor; null after the last review. { "data": [ { "id": "rev_981", "site": "tripadvisor", "profileId": "prf_12", "profileName": "Tripadvisor: Harbour & Vine", "reviewId": "1011625097", "author": "Ann P", "rating": 2, "title": "Slow on a Saturday", "text": "Lovely crab linguine but we waited 50 minutes for mains.", "language": "en", "publishedAt": "2026-10-27T19:04:00.000Z", "updatedAt": null, "ownerReply": null, "reviewUrl": "https://www.tripadvisor.co.uk/ShowUserReviews-g186233-d1234567-r1011625097-Harbour_Vine-Falmouth.html", "editedAt": null, "deletedAt": null, "handledAt": null, "firstSeenAt": "2026-10-28T09:30:00.000Z", "tags": [ "service" ], "draft": null, "sentiment": "negative", "translation": null, "appVersion": null, "country": null, "history": false } ], "total": 1, "limit": 50, "offset": 0, "nextCursor": null } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
GET /api/v1/reviews/{id}
Get a review getReview
One review, with how a reply to it would reach the site.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The review.
Field Type About idstring optional Earful’s id for the review. sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional profileIdstring optional The profile it came from. profileNamestring or null optional reviewIdstring optional The site’s own id for the review. authorstring or null optional ratinginteger or null optional Whole stars; out-of-10 sites are halved. titlestring or null optional textstring or null optional languagestring or null optional publishedAtstring or null optional When the reviewer posted it. updatedAtstring or null optional When the site says it was last changed. ownerReplynull or object optional The business’s reply, from the site or recorded in Earful. reviewUrlstring or null optional editedAtstring or null optional When Earful noticed the reviewer edit it. deletedAtstring or null optional When Earful noticed it removed from the site. handledAtstring or null optional When it was marked handled. firstSeenAtstring optional When Earful first collected it. tagsarray of string optional draftstring or null optional The reply draft, if there is one. sentimentstring or null: positive | neutral | negative | mixed optional Read by AI when the review arrived; null until then or with AI off. translationstring or null optional An English translation by AI, for reviews in another language. appVersionstring or null optional App stores: the app version the review was left on. countrystring or null optional App stores: the store country, ISO 3166 (alpha-2 from public feeds, alpha-3 from App Store Connect). historyboolean optional Came in with the profile’s first import or history backfill, so no alert went out for it. replyRouteobject optional modestring: api | link | none api: POST /reply posts to the site. link: post it on the site at url. none: no way to reply. viastring or null Which owner API posts it (appstore, googleplay, gbp) when mode is api. urlstring or null The site’s reply page when mode is link. { "id": "rev_981", "site": "tripadvisor", "profileId": "prf_12", "profileName": "Tripadvisor: Harbour & Vine", "reviewId": "1011625097", "author": "Ann P", "rating": 2, "title": "Slow on a Saturday", "text": "Lovely crab linguine but we waited 50 minutes for mains.", "language": "en", "publishedAt": "2026-10-27T19:04:00.000Z", "updatedAt": null, "ownerReply": null, "reviewUrl": "https://www.tripadvisor.co.uk/ShowUserReviews-g186233-d1234567-r1011625097-Harbour_Vine-Falmouth.html", "editedAt": null, "deletedAt": null, "handledAt": null, "firstSeenAt": "2026-10-28T09:30:00.000Z", "tags": [ "service" ], "draft": null, "replyRoute": { "mode": "link", "via": null, "url": "https://www.tripadvisor.co.uk/ManagementCenter" }, "sentiment": "negative", "translation": null, "appVersion": null, "country": null, "history": false } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
PATCH /api/v1/reviews/{id}
Mark handled, set tags updateReview
Marks a review handled or not, and replaces its tags.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Body
| Field | Type | About |
|---|---|---|
handled | boolean optional | |
tags | array of string optional |
{
"handled": true,
"tags": [
"service",
"weekend"
]
}
Responses
- 200 The review.
Field Type About idstring optional Earful’s id for the review. sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional profileIdstring optional The profile it came from. profileNamestring or null optional reviewIdstring optional The site’s own id for the review. authorstring or null optional ratinginteger or null optional Whole stars; out-of-10 sites are halved. titlestring or null optional textstring or null optional languagestring or null optional publishedAtstring or null optional When the reviewer posted it. updatedAtstring or null optional When the site says it was last changed. ownerReplynull or object optional The business’s reply, from the site or recorded in Earful. reviewUrlstring or null optional editedAtstring or null optional When Earful noticed the reviewer edit it. deletedAtstring or null optional When Earful noticed it removed from the site. handledAtstring or null optional When it was marked handled. firstSeenAtstring optional When Earful first collected it. tagsarray of string optional draftstring or null optional The reply draft, if there is one. sentimentstring or null: positive | neutral | negative | mixed optional Read by AI when the review arrived; null until then or with AI off. translationstring or null optional An English translation by AI, for reviews in another language. appVersionstring or null optional App stores: the app version the review was left on. countrystring or null optional App stores: the store country, ISO 3166 (alpha-2 from public feeds, alpha-3 from App Store Connect). historyboolean optional Came in with the profile’s first import or history backfill, so no alert went out for it. replyRouteobject optional modestring: api | link | none api: POST /reply posts to the site. link: post it on the site at url. none: no way to reply. viastring or null Which owner API posts it (appstore, googleplay, gbp) when mode is api. urlstring or null The site’s reply page when mode is link. { "id": "rev_981", "site": "tripadvisor", "profileId": "prf_12", "profileName": "Tripadvisor: Harbour & Vine", "reviewId": "1011625097", "author": "Ann P", "rating": 2, "title": "Slow on a Saturday", "text": "Lovely crab linguine but we waited 50 minutes for mains.", "language": "en", "publishedAt": "2026-10-27T19:04:00.000Z", "updatedAt": null, "ownerReply": null, "reviewUrl": "https://www.tripadvisor.co.uk/ShowUserReviews-g186233-d1234567-r1011625097-Harbour_Vine-Falmouth.html", "editedAt": null, "deletedAt": null, "handledAt": "2026-10-28T09:30:00.000Z", "firstSeenAt": "2026-10-28T09:30:00.000Z", "tags": [ "service", "weekend" ], "draft": null, "replyRoute": { "mode": "link", "via": null, "url": "https://www.tripadvisor.co.uk/ManagementCenter" }, "sentiment": "negative", "translation": null, "appVersion": null, "country": null, "history": false } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/reviews/{id}/analyse
Read a review with AI analyseReview
Reads the review again: sentiment, an English translation if it’s in another language, and the workspace’s AI tags that fit. New reviews get this automatically; use it for older ones.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 200 The review, read.
Field Type About idstring optional Earful’s id for the review. sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional profileIdstring optional The profile it came from. profileNamestring or null optional reviewIdstring optional The site’s own id for the review. authorstring or null optional ratinginteger or null optional Whole stars; out-of-10 sites are halved. titlestring or null optional textstring or null optional languagestring or null optional publishedAtstring or null optional When the reviewer posted it. updatedAtstring or null optional When the site says it was last changed. ownerReplynull or object optional The business’s reply, from the site or recorded in Earful. reviewUrlstring or null optional editedAtstring or null optional When Earful noticed the reviewer edit it. deletedAtstring or null optional When Earful noticed it removed from the site. handledAtstring or null optional When it was marked handled. firstSeenAtstring optional When Earful first collected it. tagsarray of string optional draftstring or null optional The reply draft, if there is one. sentimentstring or null: positive | neutral | negative | mixed optional Read by AI when the review arrived; null until then or with AI off. translationstring or null optional An English translation by AI, for reviews in another language. appVersionstring or null optional App stores: the app version the review was left on. countrystring or null optional App stores: the store country, ISO 3166 (alpha-2 from public feeds, alpha-3 from App Store Connect). historyboolean optional Came in with the profile’s first import or history backfill, so no alert went out for it. replyRouteobject optional modestring: api | link | none api: POST /reply posts to the site. link: post it on the site at url. none: no way to reply. viastring or null Which owner API posts it (appstore, googleplay, gbp) when mode is api. urlstring or null The site’s reply page when mode is link. { "id": "rev_981", "site": "tripadvisor", "profileId": "prf_12", "profileName": "Tripadvisor: Harbour & Vine", "reviewId": "1011625097", "author": "Ann P", "rating": 2, "title": "Slow on a Saturday", "text": "Lovely crab linguine but we waited 50 minutes for mains.", "language": "en", "publishedAt": "2026-10-27T19:04:00.000Z", "updatedAt": null, "ownerReply": null, "reviewUrl": "https://www.tripadvisor.co.uk/ShowUserReviews-g186233-d1234567-r1011625097-Harbour_Vine-Falmouth.html", "editedAt": null, "deletedAt": null, "handledAt": null, "firstSeenAt": "2026-10-28T09:30:00.000Z", "tags": [ "service" ], "draft": null, "replyRoute": { "mode": "link", "via": null, "url": "https://www.tripadvisor.co.uk/ManagementCenter" }, "sentiment": "negative", "translation": null, "appVersion": null, "country": null, "history": false } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
- 503 Service Unavailable
GET /api/v1/tag-rules
List AI tags listTagRules
Tags the AI applies to new reviews, each with when it applies.
Any key (read or write).
Responses
- 200 The AI tags.
Field Type About dataarray of TagRule { "data": [ { "id": 3, "tag": "service", "description": "Mentions waiting, slow service or staff attitude", "createdAt": "2026-10-28T09:30:00.000Z" } ] } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
POST /api/v1/tag-rules
Add an AI tag createTagRule
Adds a tag the AI applies to new reviews when its description fits; saving an existing tag changes its description. Up to 50.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
tag | string | |
description | string |
{
"tag": "service",
"description": "Mentions waiting, slow service or staff attitude"
}
Responses
- 201 The AI tag.
Field Type About idinteger tagstring Lower case letters, digits, - and _. descriptionstring When the AI should apply it. createdAtstring { "id": 3, "tag": "service", "description": "Mentions waiting, slow service or staff attitude", "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
DELETE /api/v1/tag-rules/{id}
Remove an AI tag deleteTagRule
Stops the AI applying it. Reviews keep tags they already have.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
GET /api/v1/reviews/changes
Sync reviews listReviewChanges
Every review that changed after the cursor (new, edited, replied, tagged, handled, removed from the site), oldest change first, and the ids of reviews that are gone for good (their profile was deleted). Start without a cursor to walk the whole workspace, then keep nextCursor and call again later: you get only what changed. Apply removed, then data.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Cursor optional | query | string | nextCursor from the previous page. Keep the same filters. |
limit optional | query | integer | Changes per page. |
Responses
- 200 Changes after the cursor.
Field Type About dataarray of Review Reviews as they are now. removedarray of string Ids of reviews deleted from Earful. nextCursorstring Where to carry on from; always set. hasMoreboolean More changes are waiting: call again now. { "data": [ { "id": "rev_981", "site": "tripadvisor", "profileId": "prf_12", "profileName": "Tripadvisor: Harbour & Vine", "reviewId": "1011625097", "author": "Ann P", "rating": 2, "title": "Slow on a Saturday", "text": "Lovely crab linguine but we waited 50 minutes for mains.", "language": "en", "publishedAt": "2026-10-27T19:04:00.000Z", "updatedAt": null, "ownerReply": null, "reviewUrl": "https://www.tripadvisor.co.uk/ShowUserReviews-g186233-d1234567-r1011625097-Harbour_Vine-Falmouth.html", "editedAt": null, "deletedAt": null, "handledAt": null, "firstSeenAt": "2026-10-28T09:30:00.000Z", "tags": [ "service" ], "draft": null, "sentiment": "negative", "translation": null, "appVersion": null, "country": null, "history": false } ], "removed": [ "rev_77" ], "nextCursor": "eyJzZXEiOjQwMjF9", "hasMore": false } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
replies
AI drafts and replies.
POST /api/v1/reviews/{id}/draft
Draft a reply with AI generateDraft
Writes a reply in the workspace’s voice and keeps it as the review’s draft. Free includes 10 a month (402 after).
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 201 The draft.
Field Type About textstring The draft reply. aiDraftsLeftinteger or null AI drafts left this month; null when unlimited or not an AI draft. { "text": "Hi Ann, thank you, and sorry about the wait…", "aiDraftsLeft": 7 } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
- 503 Service Unavailable
PUT /api/v1/reviews/{id}/draft
Edit the draft saveDraft
Replaces the review’s draft reply.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Body
| Field | Type | About |
|---|---|---|
text | string |
{
"text": "Hi Ann, thank you, and sorry about the wait on Saturday. Sam"
}
Responses
- 200 The saved draft.
Field Type About textstring The draft reply. aiDraftsLeftinteger or null AI drafts left this month; null when unlimited or not an AI draft. { "text": "Hi Ann, thank you, and sorry about the wait on Saturday. Sam", "aiDraftsLeft": null } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/reviews/{id}/reply
Reply replyToReview
Posts the reply through the site’s owner API where the profile is connected to one (App Store Connect, Google Play, Google Business Profile) and the plan allows direct replies. Otherwise Earful records it as replied by hand: postedToSite is false and replyUrl is where to post it.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
text | string |
{
"text": "Hi Ann, thank you, and sorry about the wait on Saturday. Sam"
}
Responses
- 201 Posted or recorded.
Field Type About postedToSiteboolean true when Earful posted it through the site’s owner API. viastring: appstore | googleplay | gbp | facebook | manual manual means recorded only: post it on the site yourself. replyUrlstring or null Where to post it when via is manual. reviewReviewDetail { "postedToSite": false, "via": "manual", "replyUrl": "https://www.tripadvisor.co.uk/ManagementCenter", "review": { "id": "rev_981", "site": "tripadvisor", "profileId": "prf_12", "profileName": "Tripadvisor: Harbour & Vine", "reviewId": "1011625097", "author": "Ann P", "rating": 2, "title": "Slow on a Saturday", "text": "Lovely crab linguine but we waited 50 minutes for mains.", "language": "en", "publishedAt": "2026-10-27T19:04:00.000Z", "updatedAt": null, "ownerReply": null, "reviewUrl": "https://www.tripadvisor.co.uk/ShowUserReviews-g186233-d1234567-r1011625097-Harbour_Vine-Falmouth.html", "editedAt": null, "deletedAt": null, "handledAt": null, "firstSeenAt": "2026-10-28T09:30:00.000Z", "tags": [ "service" ], "draft": null, "replyRoute": { "mode": "link", "via": null, "url": "https://www.tripadvisor.co.uk/ManagementCenter" }, "sentiment": "negative", "translation": null, "appVersion": null, "country": null, "history": false } } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
auto-replies
Rules, the approval queue and the log.
GET /api/v1/reply-rules
List reply rules listReplyRules
Auto-reply rules, in the order they’re tried: a location’s own first, then the ones for every location. The first that fits a new review wins.
Any key (read or write).
Responses
- 200 The rules.
Field Type About dataarray of ReplyRule { "data": [ { "id": 2, "locationId": null, "minRating": 4, "maxRating": 5, "sites": [ "appstore", "googleplay" ], "languages": [], "keywords": [], "action": "post", "createdAt": "2026-10-28T09:30:00.000Z" } ] } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
POST /api/v1/reply-rules
Add a reply rule createReplyRule
New reviews that fit get an AI reply that is posted (action post, on sites with a reply API, up to the daily cap) or held in the approval queue. Defaults: 4 and 5 stars, held for approval. Auto-replies also need turning on: PATCH /api/v1/workspace with autoReply.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
action | string: post | approve optional | |
locationId | string or integer optional | Only this location. Its id (loc_3), or the bare number. |
minRating | integer optional | 1 to 3 stars only when you set it lower. |
maxRating | integer optional | |
sites | array of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional | |
languages | array of string optional | |
keywords | array of string optional |
{
"action": "post",
"minRating": 4,
"sites": [
"appstore",
"googleplay"
]
}
Responses
- 201 The rule.
Field Type About idinteger locationIdstring or null The location it covers; null for every location. minRatinginteger maxRatinginteger sitesarray of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra Empty for every site. languagesarray of string Two-letter codes; empty for any. keywordsarray of string The review must mention one; empty for any. actionstring: post | approve post: post the AI reply where the site has a reply API, otherwise hold it. approve: always hold it. createdAtstring { "id": 2, "locationId": null, "minRating": 4, "maxRating": 5, "sites": [ "appstore", "googleplay" ], "languages": [], "keywords": [], "action": "post", "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
GET /api/v1/reply-rules/{id}
Get a reply rule getReplyRule
One auto-reply rule.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The rule.
Field Type About idinteger locationIdstring or null The location it covers; null for every location. minRatinginteger maxRatinginteger sitesarray of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra Empty for every site. languagesarray of string Two-letter codes; empty for any. keywordsarray of string The review must mention one; empty for any. actionstring: post | approve post: post the AI reply where the site has a reply API, otherwise hold it. approve: always hold it. createdAtstring { "id": 2, "locationId": null, "minRating": 4, "maxRating": 5, "sites": [ "appstore", "googleplay" ], "languages": [], "keywords": [], "action": "post", "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
DELETE /api/v1/reply-rules/{id}
Remove a reply rule deleteReplyRule
Removes the rule. Replies it already held stay in the queue.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
GET /api/v1/auto-replies
List auto-replies listAutoReplies
What auto-replies did, newest first: the approval queue (status pending) and the audit log of what was posted, approved, rejected or skipped.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
status optional | query | string: pending | posted | approved | rejected | skipped | |
Limit optional | query | integer | Page size. |
Offset optional | query | integer | Deprecated: page with cursor. Rows to skip. |
Cursor optional | query | string | nextCursor from the previous page. Keep the same filters. |
Responses
- 200 Auto-replies.
Field Type About dataarray of AutoReply limitinteger offsetinteger nextCursorstring or null The next page’s cursor; null after the last item. { "data": [ { "id": "arp_14", "reviewId": "rev_981", "ruleId": 2, "status": "pending", "text": "Thanks so much, Ann! We’re glad the crab linguine hit the spot.", "reason": "The rule holds replies for approval.", "decidedBy": null, "createdAt": "2026-10-28T09:30:00.000Z", "decidedAt": null, "review": { "site": "appstore", "rating": 5, "author": "Ann P", "text": "Lovely app, booking took seconds.", "profileName": "App Store: Harbour & Vine" } } ], "limit": 50, "offset": 0, "nextCursor": null } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
GET /api/v1/auto-replies/{id}
Get an auto-reply getAutoReply
One auto-reply.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The auto-reply.
Field Type About idstring Auto-reply id. reviewIdstring The review. ruleIdinteger or null statusstring: pending | posted | approved | rejected | skipped pending is the approval queue; posted means posted automatically. textstring or null The AI reply (as approved, if edited). reasonstring or null Why it’s held or skipped. decidedBystring or null auto, an email, an API key or a Slack user. createdAtstring decidedAtstring or null reviewobject sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra ratinginteger or null authorstring or null textstring or null profileNamestring { "id": "arp_14", "reviewId": "rev_981", "ruleId": 2, "status": "pending", "text": "Thanks so much, Ann! We’re glad the crab linguine hit the spot.", "reason": "The rule holds replies for approval.", "decidedBy": null, "createdAt": "2026-10-28T09:30:00.000Z", "decidedAt": null, "review": { "site": "appstore", "rating": 5, "author": "Ann P", "text": "Lovely app, booking took seconds.", "profileName": "App Store: Harbour & Vine" } } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/auto-replies/{id}/approve
Approve and post approveAutoReply
Posts a held reply to the site, as drafted or with your text. 409 if it was already decided, the review has a reply, or the site has no reply API (copy those across).
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
text | string optional | Leave out to post the AI draft. |
{}
Responses
- 200 The auto-reply, approved.
Field Type About idstring Auto-reply id. reviewIdstring The review. ruleIdinteger or null statusstring: pending | posted | approved | rejected | skipped pending is the approval queue; posted means posted automatically. textstring or null The AI reply (as approved, if edited). reasonstring or null Why it’s held or skipped. decidedBystring or null auto, an email, an API key or a Slack user. createdAtstring decidedAtstring or null reviewobject sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra ratinginteger or null authorstring or null textstring or null profileNamestring { "id": "arp_14", "reviewId": "rev_981", "ruleId": 2, "status": "approved", "text": "Thanks so much, Ann! We’re glad the crab linguine hit the spot.", "reason": null, "decidedBy": "api:Zapier", "createdAt": "2026-10-28T09:30:00.000Z", "decidedAt": "2026-10-28T09:30:00.000Z", "review": { "site": "appstore", "rating": 5, "author": "Ann P", "text": "Lovely app, booking took seconds.", "profileName": "App Store: Harbour & Vine" } } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
POST /api/v1/auto-replies/{id}/reject
Reject rejectAutoReply
Takes a held reply out of the queue without posting it. The draft stays on the review.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 200 The auto-reply, rejected.
Field Type About idstring Auto-reply id. reviewIdstring The review. ruleIdinteger or null statusstring: pending | posted | approved | rejected | skipped pending is the approval queue; posted means posted automatically. textstring or null The AI reply (as approved, if edited). reasonstring or null Why it’s held or skipped. decidedBystring or null auto, an email, an API key or a Slack user. createdAtstring decidedAtstring or null reviewobject sitestring: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra ratinginteger or null authorstring or null textstring or null profileNamestring { "id": "arp_14", "reviewId": "rev_981", "ruleId": 2, "status": "rejected", "text": "Thanks so much, Ann! We’re glad the crab linguine hit the spot.", "reason": "The rule holds replies for approval.", "decidedBy": "api:Zapier", "createdAt": "2026-10-28T09:30:00.000Z", "decidedAt": "2026-10-28T09:30:00.000Z", "review": { "site": "appstore", "rating": 5, "author": "Ann P", "text": "Lovely app, booking took seconds.", "profileName": "App Store: Harbour & Vine" } } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
alerts
Email, Slack and webhooks for new reviews.
GET /api/v1/alerts
List alerts listAlerts
Every alert: email digests, instant email, Slack and signed webhooks.
Any key (read or write).
Responses
- 200 The alerts.
Field Type About dataarray of Alert { "data": [ { "id": "alr_7", "channel": "webhook", "frequency": null, "target": "hooks.acme.example", "maxRating": 3, "sites": [], "lastSentAt": "2026-10-28T09:30:00.000Z", "lastError": null, "createdAt": "2026-10-28T09:30:00.000Z", "locationId": null, "sentiments": [], "tags": [], "keywords": [] } ] } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
POST /api/v1/alerts
Add an alert or webhook createAlert
Adds an alert. A webhook gets a POST of {event: "reviews.new", workspace, reviews: [Review]} for each batch of new reviews, signed with an Earful-Signature: t=…,v1=… header: an HMAC-SHA256 of "t.body" with the secret in this answer. Filters (rating, sites, location, sentiment, tags, keywords) all have to match. Email and Slack alerts carry a signed link to approve or edit the reply without signing in. Slack and webhooks are on Plus (402).
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
channel | string: digest | email | slack | webhook | |
target | string | An email address, a Slack incoming webhook URL or your https webhook URL. |
frequency | string: daily | weekly optional | Digests only. |
maxRating | integer: 1 | 2 | 3 | 4 optional | |
sites | array of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional | |
locationId | string or integer optional | Only this location. Its id (loc_3), or the bare number. |
sentiments | array of string: positive | neutral | negative | mixed optional | |
tags | array of string optional | |
keywords | array of string optional | Match any one, case-insensitive. |
{
"channel": "webhook",
"target": "https://hooks.acme.example/earful",
"maxRating": 3
}
Responses
- 201 The alert, and a webhook’s secret.
Field Type About idstring optional Alert id. channelstring: digest | email | slack | webhook optional frequencystring or null: daily | weekly optional For digests. targetstring optional Where it goes: the email address, “Slack channel”, or the webhook’s host. Full URLs stay encrypted. maxRatinginteger or null optional Only reviews at or below this rating; null for all. sitesarray of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional Only these sites; empty for all. lastSentAtstring or null optional When it last sent. lastErrorstring or null optional createdAtstring optional When it was added. locationIdstring or null optional Only this location; null for all. sentimentsarray of string: positive | neutral | negative | mixed optional Only reviews the AI read as one of these; empty for any. tagsarray of string optional Only reviews with one of these tags; empty for any. keywordsarray of string optional Only reviews that mention one of these (in the text or its translation); empty for any. secretstring or null optional A webhook’s signing secret. Shown only in this answer. { "id": "alr_7", "channel": "webhook", "frequency": null, "target": "hooks.acme.example", "maxRating": 3, "sites": [], "lastSentAt": "2026-10-28T09:30:00.000Z", "lastError": null, "createdAt": "2026-10-28T09:30:00.000Z", "secret": "whsec_9fQ2…", "locationId": null, "sentiments": [], "tags": [], "keywords": [] } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
GET /api/v1/alerts/{id}
Get an alert getAlert
One alert.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The alert.
Field Type About idstring Alert id. channelstring: digest | email | slack | webhook frequencystring or null: daily | weekly For digests. targetstring Where it goes: the email address, “Slack channel”, or the webhook’s host. Full URLs stay encrypted. maxRatinginteger or null Only reviews at or below this rating; null for all. sitesarray of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra Only these sites; empty for all. lastSentAtstring or null When it last sent. lastErrorstring or null createdAtstring When it was added. locationIdstring or null Only this location; null for all. sentimentsarray of string: positive | neutral | negative | mixed Only reviews the AI read as one of these; empty for any. tagsarray of string Only reviews with one of these tags; empty for any. keywordsarray of string Only reviews that mention one of these (in the text or its translation); empty for any. { "id": "alr_7", "channel": "webhook", "frequency": null, "target": "hooks.acme.example", "maxRating": 3, "sites": [], "lastSentAt": "2026-10-28T09:30:00.000Z", "lastError": null, "createdAt": "2026-10-28T09:30:00.000Z", "locationId": null, "sentiments": [], "tags": [], "keywords": [] } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
DELETE /api/v1/alerts/{id}
Delete an alert deleteAlert
Stops an alert.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/alerts/{id}/test
Send a test testAlert
Sends the newest review (or a made-up one) through the alert now. 400 says why delivery failed.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 200 Sent.
Field Type About idstring Alert id. channelstring: digest | email | slack | webhook frequencystring or null: daily | weekly For digests. targetstring Where it goes: the email address, “Slack channel”, or the webhook’s host. Full URLs stay encrypted. maxRatinginteger or null Only reviews at or below this rating; null for all. sitesarray of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra Only these sites; empty for all. lastSentAtstring or null When it last sent. lastErrorstring or null createdAtstring When it was added. locationIdstring or null Only this location; null for all. sentimentsarray of string: positive | neutral | negative | mixed Only reviews the AI read as one of these; empty for any. tagsarray of string Only reviews with one of these tags; empty for any. keywordsarray of string Only reviews that mention one of these (in the text or its translation); empty for any. { "id": "alr_7", "channel": "webhook", "frequency": null, "target": "hooks.acme.example", "maxRating": 3, "sites": [], "lastSentAt": "2026-10-28T09:30:00.000Z", "lastError": null, "createdAt": "2026-10-28T09:30:00.000Z", "locationId": null, "sentiments": [], "tags": [], "keywords": [] } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
GET /api/v1/webhook-deliveries
List webhook deliveries listWebhookDeliveries
Every webhook send, newest first: its status, tries and your endpoint’s answer. A failed send is retried after 1, 5, 30, 120, 360 and 720 minutes, then given up. Kept 30 days.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
alert optional | query | string | Only this webhook alert. |
status optional | query | string: pending | delivered | failed | |
Cursor optional | query | string | nextCursor from the previous page. Keep the same filters. |
limit optional | query | integer | Page size. |
Responses
- 200 A page of deliveries.
Field Type About dataarray of WebhookDelivery nextCursorstring or null { "data": [ { "id": "whd_88", "alertId": "alr_7", "event": "reviews.new", "status": "pending", "test": false, "attempts": 2, "responseStatus": 503, "error": "Webhook answered 503", "replayOf": null, "createdAt": "2026-10-28T09:30:00.000Z", "lastAttemptAt": "2026-10-28T09:31:00.000Z", "nextAttemptAt": "2026-10-28T10:01:00.000Z", "deliveredAt": null } ], "nextCursor": null } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
GET /api/v1/webhook-deliveries/{id}
Get a webhook delivery getWebhookDelivery
One delivery, with the exact body sent as payload.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The delivery.
Field Type About idstring optional Delivery id. alertIdstring optional The webhook alert it went to. eventstring: reviews.new | profile.imported optional The event. statusstring: pending | delivered | failed optional pending: will retry at nextAttemptAt. testboolean optional A test send (POST /alerts/{id}/test); never retried. attemptsinteger optional Tries so far. responseStatusinteger or null optional Your endpoint’s last HTTP status. errorstring or null optional Why the last try failed. replayOfstring or null optional The delivery this replays. createdAtstring optional lastAttemptAtstring or null optional nextAttemptAtstring or null optional The next retry, while pending. deliveredAtstring or null optional payloadobject optional { "id": "whd_88", "alertId": "alr_7", "event": "reviews.new", "status": "pending", "test": false, "attempts": 2, "responseStatus": 503, "error": "Webhook answered 503", "replayOf": null, "createdAt": "2026-10-28T09:30:00.000Z", "lastAttemptAt": "2026-10-28T09:31:00.000Z", "nextAttemptAt": "2026-10-28T10:01:00.000Z", "deliveredAt": null, "payload": { "id": "evt_9QwLx2", "event": "profile.imported", "livemode": true, "createdAt": "2026-10-28T09:30:00.000Z", "workspace": { "id": 1, "name": "Harbour & Vine" }, "profile": { "id": 12, "locationId": 3, "locationName": "Harbour & Vine, Falmouth", "site": "tripadvisor", "name": "Tripadvisor: Harbour & Vine", "url": "https://www.tripadvisor.co.uk/Restaurant_Review-g186233-d1234567-Reviews-Harbour_Vine-Falmouth.html", "reviewCount": 214, "averageRating": 4.6, "lastCheckedAt": "2026-10-28T09:30:00.000Z", "nextCheckAt": "2026-10-28T10:30:00.000Z", "lastError": null, "createdAt": "2026-10-01T12:00:00.000Z" } } } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/webhook-deliveries/{id}/replay
Replay a webhook delivery replayWebhookDelivery
Sends the same body (same event id) to the same alert again, as a new delivery, signed afresh. It retries like any other.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 201 The new delivery.
Field Type About idstring Delivery id. alertIdstring The webhook alert it went to. eventstring: reviews.new | profile.imported The event. statusstring: pending | delivered | failed pending: will retry at nextAttemptAt. testboolean A test send (POST /alerts/{id}/test); never retried. attemptsinteger Tries so far. responseStatusinteger or null Your endpoint’s last HTTP status. errorstring or null Why the last try failed. replayOfstring or null The delivery this replays. createdAtstring lastAttemptAtstring or null nextAttemptAtstring or null The next retry, while pending. deliveredAtstring or null { "id": "whd_91", "alertId": "alr_7", "event": "reviews.new", "status": "delivered", "test": false, "attempts": 1, "responseStatus": 200, "error": null, "replayOf": "whd_88", "createdAt": "2026-10-28T09:30:00.000Z", "lastAttemptAt": "2026-10-28T09:31:00.000Z", "nextAttemptAt": null, "deliveredAt": "2026-10-28T10:05:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
review links
Short links and QR codes to get reviews.
GET /api/v1/locations/{id}/review-link
Get a review link getReviewLink
The location’s short review link, its QR code and click count.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The review link.
Field Type About locationIdstring The location. codestring urlstring The short link to print or share; it redirects and counts the click. sitestring or null The site it sends people to, if it’s one Earful knows. targetUrlstring Where it sends people: the site’s write-a-review page. clicksinteger lastClickAtstring or null qrSvgstring A QR code for url, as an SVG document. createdAtstring { "locationId": "loc_4", "code": "q7Rk2mWa", "url": "https://earful.onibaku.co.uk/r/q7Rk2mWa", "site": "google", "targetUrl": "https://search.google.com/local/writereview?placeid=ChIJN1t_tDeuEmsRUsoyG83frY4", "clicks": 31, "lastClickAt": "2026-10-28T09:30:00.000Z", "qrSvg": "<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 264 264\">…</svg>", "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
PUT /api/v1/locations/{id}/review-link
Set a review link setReviewLink
Makes the location’s review link, or changes where it goes (the code, QR code and click count stay). Send site and, for Google, placeId; for other sites the location needs a review page there. Or send any https url.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Body
| Field | Type | About |
|---|---|---|
site | string: google | tripadvisor | trustpilot | appstore | googleplay | booking | feefo | reviewsio optional | |
placeId | string optional | Google’s place ID, for site google. |
url | string optional | Any https page instead of a site. |
{
"site": "google",
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4"
}
Responses
- 200 The review link.
Field Type About locationIdstring The location. codestring urlstring The short link to print or share; it redirects and counts the click. sitestring or null The site it sends people to, if it’s one Earful knows. targetUrlstring Where it sends people: the site’s write-a-review page. clicksinteger lastClickAtstring or null qrSvgstring A QR code for url, as an SVG document. createdAtstring { "locationId": "loc_4", "code": "q7Rk2mWa", "url": "https://earful.onibaku.co.uk/r/q7Rk2mWa", "site": "google", "targetUrl": "https://search.google.com/local/writereview?placeid=ChIJN1t_tDeuEmsRUsoyG83frY4", "clicks": 31, "lastClickAt": "2026-10-28T09:30:00.000Z", "qrSvg": "<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 264 264\">…</svg>", "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
DELETE /api/v1/locations/{id}/review-link
Remove a review link deleteReviewLink
Removes the link; printed QR codes stop working.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
review requests
Ask customers for reviews by email or text, with a reminder and opt-outs.
POST /api/v1/locations/{id}/review-requests
Ask a customer for a review createReviewRequest
Queues an email (or text) to one customer with the location’s review link, sent within a minute, and one reminder later to people who haven’t clicked (see the settings). Needs the location’s review link and the sending rules agreed (409 otherwise). A customer asked at this location in the last 30 days, or who opted out, is refused with 409. Over the month’s allowance is 402, and so is any text: texts aren’t on a plan yet. Only ask real, recent customers who were told they’d be asked: transactedAt older than 180 days is refused.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
name | string optional | |
email | string optional | |
phone | string optional | A mobile, +44 7… or 07…. |
channel | string: email | sms optional | Defaults to email when there’s one. |
orderRef | string optional | |
transactedAt | string optional | When they bought or visited. |
sendAt | string optional | Send later, up to 30 days ahead. |
{
"name": "Ann Price",
"email": "ann@example.com",
"orderRef": "A1043",
"transactedAt": "2026-10-27"
}
Responses
- 201 The request, queued.
Field Type About idinteger locationIdstring The location. locationNamestring or null channelstring: email | sms contactstring Email address, or mobile number in E.164. namestring or null orderRefstring or null sourcestring: api | csv | webhook | app statusstring: queued | sent | failed | suppressed suppressed: the customer opted out before it went. sendAtstring sentAtstring or null remindedAtstring or null When the one reminder went, to people who hadn’t clicked. clickedAtstring or null First click on the review link. reviewIdstring or null The review Earful matched to this request by the reviewer’s name. unsubscribedAtstring or null errorstring or null transactedAtstring or null createdAtstring { "id": 301, "locationId": "loc_4", "locationName": "Harbour & Vine, Falmouth", "channel": "email", "contact": "ann@example.com", "name": "Ann Price", "orderRef": "A1043", "source": "api", "status": "queued", "sendAt": "2026-10-28T09:30:00.000Z", "sentAt": null, "remindedAt": null, "clickedAt": null, "reviewId": null, "unsubscribedAt": null, "error": null, "transactedAt": "2026-10-27T19:00:00.000Z", "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
- 503 Service Unavailable
POST /api/v1/locations/{id}/review-requests/import
Ask customers from a CSV importReviewRequests
A CSV file’s text: a header row (name, email, phone, order_ref, transacted_at; common alternatives like first_name, last_name and mobile work too), then one customer a row, up to 2,000. Good rows are queued; the rest are listed with the reason.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
csv | string |
{
"csv": "name,email,order_ref\nAnn Price,ann@example.com,A1043\nSam Ellis,sam@example.com,A1044\n"
}
Responses
- 200 What was queued and skipped.
Field Type About createdinteger idsarray of integer skippedarray of object { "created": 2, "ids": [ 302, 303 ], "skipped": [ { "row": 4, "reason": "That email address doesn’t look right." } ] } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
- 503 Service Unavailable
GET /api/v1/review-requests
List review requests listReviewRequests
Newest first.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
location optional | query | string | |
status optional | query | string: queued | sent | failed | suppressed | |
Limit optional | query | integer | Page size. |
Offset optional | query | integer | Deprecated: page with cursor. Rows to skip. |
Cursor optional | query | string | nextCursor from the previous page. Keep the same filters. |
Responses
- 200 A page of requests.
Field Type About dataarray of ReviewRequest limitinteger offsetinteger nextCursorstring or null The next page’s cursor; null after the last request. { "data": [ { "id": 301, "locationId": "loc_4", "locationName": "Harbour & Vine, Falmouth", "channel": "email", "contact": "ann@example.com", "name": "Ann Price", "orderRef": "A1043", "source": "api", "status": "sent", "sendAt": "2026-10-28T09:30:00.000Z", "sentAt": "2026-10-28T09:30:00.000Z", "remindedAt": null, "clickedAt": "2026-10-28T09:30:00.000Z", "reviewId": "rev_5512", "unsubscribedAt": null, "error": null, "transactedAt": "2026-10-27T19:00:00.000Z", "createdAt": "2026-10-28T09:30:00.000Z" } ], "limit": 50, "offset": 0, "nextCursor": null } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
GET /api/v1/review-requests/{id}
Get a review request getReviewRequest
One request and how far it got.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The request.
Field Type About idinteger locationIdstring The location. locationNamestring or null channelstring: email | sms contactstring Email address, or mobile number in E.164. namestring or null orderRefstring or null sourcestring: api | csv | webhook | app statusstring: queued | sent | failed | suppressed suppressed: the customer opted out before it went. sendAtstring sentAtstring or null remindedAtstring or null When the one reminder went, to people who hadn’t clicked. clickedAtstring or null First click on the review link. reviewIdstring or null The review Earful matched to this request by the reviewer’s name. unsubscribedAtstring or null errorstring or null transactedAtstring or null createdAtstring { "id": 301, "locationId": "loc_4", "locationName": "Harbour & Vine, Falmouth", "channel": "email", "contact": "ann@example.com", "name": "Ann Price", "orderRef": "A1043", "source": "api", "status": "queued", "sendAt": "2026-10-28T09:30:00.000Z", "sentAt": null, "remindedAt": null, "clickedAt": null, "reviewId": null, "unsubscribedAt": null, "error": null, "transactedAt": "2026-10-27T19:00:00.000Z", "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
GET /api/v1/review-requests/stats
Review request funnel getReviewRequestStats
For requests made in the last days (7, 30, 90 or 365; 30 by default): how many were sent, clicked, matched to a review (by the reviewer’s name, at the same location, within 30 days), reminded and opted out. Plus this month’s sends against the plan.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
location optional | query | integer | |
days optional | query | integer: 7 | 30 | 90 | 365 |
Responses
- 200 The funnel.
Field Type About daysinteger requestedinteger queuedinteger sentinteger failedinteger suppressedinteger remindedinteger clickedinteger reviewedinteger unsubscribedinteger clickRatenumber or null clicked / sent. reviewRatenumber or null reviewed / sent. usageobject emailsinteger emailLimitinteger textsinteger textLimitinteger { "days": 30, "requested": 240, "queued": 4, "sent": 230, "failed": 2, "suppressed": 4, "reminded": 120, "clicked": 88, "reviewed": 41, "unsubscribed": 3, "clickRate": 0.383, "reviewRate": 0.178, "usage": { "emails": 212, "emailLimit": 1100, "texts": 31, "textLimit": 250 } } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
GET /api/v1/review-requests/settings
Get message settings getReviewRequestSettings
The email, reminder and text templates, the reminder delay, where replies go, whether the sending rules are agreed, and suggested consent wording.
Any key (read or write).
Responses
- 200 The settings.
Field Type About emailSubjectstring emailBodystring Must contain {link}. {name} and {business} are filled in. reminderSubjectstring reminderBodystring smsBodystring reminderDaysinteger 0 for no reminder. replyTostring or null acknowledgedboolean Whether the owner agreed the sending rules; requests need it. acknowledgedAtstring or null consentWordingstring Suggested wording for receipts and booking forms. { "emailSubject": "How was your visit to {business}?", "emailBody": "Hi {name},\n\nWould you take a minute to leave us a review?\n\n{link}\n\nThank you,\n{business}", "reminderSubject": "A quick reminder from {business}", "reminderBody": "Hi {name}, here’s the link again: {link}", "smsBody": "Hi {name}, thanks for choosing {business}. Would you leave us a quick review? {link}", "reminderDays": 3, "replyTo": "hello@harbourandvine.co.uk", "acknowledged": true, "acknowledgedAt": "2026-10-28T09:30:00.000Z", "consentWording": "We’ll send you one email (or text) after your visit asking for a review, and one reminder. You can opt out now, or with the link in the message." } - 401 Unauthorized
- 429 Too Many Requests
PUT /api/v1/review-requests/settings
Change message settings setReviewRequestSettings
Fields left out keep their value. Templates must contain {link}; Earful adds the opt-out line. Send acknowledged: true once to agree the sending rules: only real customers who bought or booked recently, who were told they’d be asked and could say no.
Needs a write key.
Body
| Field | Type | About |
|---|---|---|
emailSubject | string optional | |
emailBody | string optional | |
reminderSubject | string optional | |
reminderBody | string optional | |
smsBody | string optional | |
reminderDays | integer: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 14 optional | |
replyTo | string or null optional | |
acknowledged | boolean optional |
{
"acknowledged": true
}
Responses
- 200 The settings.
Field Type About emailSubjectstring emailBodystring Must contain {link}. {name} and {business} are filled in. reminderSubjectstring reminderBodystring smsBodystring reminderDaysinteger 0 for no reminder. replyTostring or null acknowledgedboolean Whether the owner agreed the sending rules; requests need it. acknowledgedAtstring or null consentWordingstring Suggested wording for receipts and booking forms. { "emailSubject": "How was your visit to {business}?", "emailBody": "Hi {name},\n\nWould you take a minute to leave us a review?\n\n{link}\n\nThank you,\n{business}", "reminderSubject": "A quick reminder from {business}", "reminderBody": "Hi {name}, here’s the link again: {link}", "smsBody": "Hi {name}, thanks for choosing {business}. Would you leave us a quick review? {link}", "reminderDays": 3, "replyTo": "hello@harbourandvine.co.uk", "acknowledged": true, "acknowledgedAt": "2026-10-28T09:30:00.000Z", "consentWording": "We’ll send you one email (or text) after your visit asking for a review, and one reminder. You can opt out now, or with the link in the message." } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 429 Too Many Requests
GET /api/v1/suppressions
List opt-outs listSuppressions
Customers who opted out of review requests, or were added by hand. They’re never asked again.
Any key (read or write).
Responses
- 200 Opt-outs, newest first.
Field Type About dataarray of Suppression { "data": [ { "id": 7, "contact": "sam@example.com", "reason": "unsubscribed", "createdAt": "2026-10-28T09:30:00.000Z" } ] } - 401 Unauthorized
- 429 Too Many Requests
POST /api/v1/suppressions
Add an opt-out addSuppression
An email address or mobile number never to ask.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
contact | string |
{
"contact": "sam@example.com"
}
Responses
- 201 The opt-out.
Field Type About idinteger contactstring reasonstring: unsubscribed | manual createdAtstring { "id": 8, "contact": "+447700900123", "reason": "manual", "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
DELETE /api/v1/suppressions/{id}
Remove an opt-out deleteSuppression
Only when the customer asks to hear from you again.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
GET /api/v1/locations/{id}/request-hook
Get a location’s webhook URL getRequestHook
The secret URL a till, booking system or Zapier posts customers to. 404 until one is made.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The URL.
Field Type About locationIdstring The location. urlstring { "locationId": "loc_4", "url": "https://earful.onibaku.co.uk/api/v1/hooks/review-requests/k3P9xQ2mRt7wZb4nVh8c" } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/locations/{id}/request-hook
Make a new webhook URL rotateRequestHook
Makes the location’s webhook URL, or replaces it (the old one stops working at once).
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 201 The URL.
Field Type About locationIdstring The location. urlstring { "locationId": "loc_4", "url": "https://earful.onibaku.co.uk/api/v1/hooks/review-requests/k3P9xQ2mRt7wZb4nVh8c" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
DELETE /api/v1/locations/{id}/request-hook
Remove the webhook URL deleteRequestHook
The URL stops working.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/hooks/review-requests/{code}
Webhook: ask a customer for a review postRequestHook
No key: the secret URL from the location’s webhook is the credential. Send one customer as JSON or a form, with field names as your system has them (name or first_name and last_name; email; phone or mobile; order_ref, order_id or booking_id; transacted_at). In Zapier, use Webhooks by Zapier, POST. A repeat or opted-out customer answers 202 with status skipped, so a till can post every sale.
Parameters
| Name | In | Type | About |
|---|---|---|---|
code | path | string | From the webhook URL. |
Body
object
{
"first_name": "Ann",
"last_name": "Price",
"email": "ann@example.com",
"order_id": "A1043"
}
Responses
- 202 Queued, or skipped with the reason.
Field Type About idinteger or null statusstring: queued | skipped reasonstring or null { "id": 304, "status": "queued", "reason": null } - 400 Bad Request
- 402 Payment Required
- 404 Not Found
- 409 Conflict
- 429 Too Many Requests
- 503 Service Unavailable
apps
App Store and Google Play rating trends and AI topics.
GET /api/v1/apps
List apps listApps
App Store and Google Play profiles, with their rating and the newest version a review mentions. Their reviews are GET /api/v1/reviews?profile={id}, filterable by version and country.
Any key (read or write).
Responses
- 200 Apps.
Field Type About dataarray of App { "data": [ { "id": "prf_12", "locationId": "loc_6", "site": "appstore", "name": "App Store: Harbour & Vine", "url": "https://apps.apple.com/gb/app/harbour-vine/id1234567890", "reviewCount": 412, "averageRating": 4.3, "latestVersion": "5.2.0" } ] } - 401 Unauthorized
- 429 Too Many Requests
GET /api/v1/apps/{id}/trends
App rating trends getAppTrends
Average rating by week, by app version (with when each version’s first review arrived, which marks the release on a chart) and by store country, over the last 30, 90 (default) or 365 days.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
days optional | query | integer: 30 | 90 | 365 |
Responses
- 200 Trends.
Field Type About appApp daysinteger weeksarray of object versionsarray of object countriesarray of object { "app": { "id": "prf_12", "locationId": "loc_6", "site": "appstore", "name": "App Store: Harbour & Vine", "url": "https://apps.apple.com/gb/app/harbour-vine/id1234567890", "reviewCount": 412, "averageRating": 4.3, "latestVersion": "5.2.0" }, "days": 90, "weeks": [ { "week": "2026-10-19", "reviews": 31, "averageRating": 3.9 } ], "versions": [ { "version": "5.2.0", "firstSeenAt": "2026-10-20T08:12:00.000Z", "reviews": 28, "averageRating": 3.7 } ], "countries": [ { "country": "GB", "reviews": 25, "averageRating": 4.1 } ] } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
GET /api/v1/apps/{id}/topics
Get app topics getAppTopics
The latest AI grouping of the app’s reviews into topics. 404 until it’s been run.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 Topics.
Field Type About appIdstring The app’s profile id. reviewCountinteger Reviews the topics were drawn from (the newest written ones). topicsarray of object createdAtstring { "appId": "prf_12", "reviewCount": 200, "topics": [ { "title": "Crashes when uploading a photo", "kind": "bug", "summary": "Since 5.2.0 the app closes when attaching a photo.", "reviewIds": [ 5512, 5520 ] }, { "title": "Wants a dark mode", "kind": "feature_request", "summary": "People reading at night ask for a dark theme.", "reviewIds": [ 5490 ] } ], "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/apps/{id}/topics
Find app topics findAppTopics
Groups the app’s 200 newest written reviews into up to 12 topics (bugs, feature requests, complaints, praise, questions) with AI. Plus plan; 10 runs an hour per workspace; 503 when AI is off on the server.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 201 Topics.
Field Type About appIdstring The app’s profile id. reviewCountinteger Reviews the topics were drawn from (the newest written ones). topicsarray of object createdAtstring { "appId": "prf_12", "reviewCount": 200, "topics": [ { "title": "Crashes when uploading a photo", "kind": "bug", "summary": "Since 5.2.0 the app closes when attaching a photo.", "reviewIds": [ 5512, 5520 ] }, { "title": "Wants a dark mode", "kind": "feature_request", "summary": "People reading at night ask for a dark theme.", "reviewIds": [ 5490 ] } ], "createdAt": "2026-10-28T09:30:00.000Z" } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
- 503 Service Unavailable
map rankings
Google Maps rank on a grid of points around a location.
GET /api/v1/rank-trackers
List rank trackers listRankTrackers
Each tracker with its latest scan.
Any key (read or write).
Responses
- 200 Trackers.
Field Type About dataarray of RankTracker { "data": [ { "id": 9, "locationId": "loc_4", "keyword": "seafood restaurant", "gridSize": 7, "radiusKm": 2, "center": { "lat": 50.153, "lng": -5.067 }, "business": { "cid": "1234567890123456789", "name": "Harbour & Vine" }, "every": "week", "nextScanAt": "2026-11-04T09:30:00.000Z", "createdAt": "2026-10-28T09:30:00.000Z", "scans": [ { "id": 88, "trackerId": 9, "status": "done", "points": 49, "averageRank": 3.4, "top3Share": 0.592, "foundShare": 0.918, "costUsd": 0.0294, "createdAt": "2026-10-28T09:30:00.000Z", "finishedAt": "2026-10-28T09:30:00.000Z" } ] } ] } - 401 Unauthorized
- 429 Too Many Requests
POST /api/v1/locations/{id}/rank-trackers
Track a Google Maps search createRankTracker
Where the location ranks on Google Maps for keyword, at each point of a gridSize × gridSize grid (3 to 13, odd; 7 by default) radiusKm (0.5 to 25; 2 by default) from the centre to each edge. Scans straight away; every rescans on a schedule. The centre and the business (its cid, else its name) come from the location’s Google Maps page, or send lat, lng and name. Each point uses one of the plan’s monthly grid points (402 when they run out); 503 when DataForSEO isn’t set up on the server.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
keyword | string | |
gridSize | integer: 3 | 5 | 7 | 9 | 11 | 13 optional | |
radiusKm | number optional | |
every | string: week | fortnight | month optional | |
lat | number optional | |
lng | number optional | |
name | string optional | The business name exactly as Google Maps shows it. |
cid | string optional | Google’s customer id for the business. |
{
"keyword": "seafood restaurant",
"gridSize": 7,
"radiusKm": 2,
"every": "week"
}
Responses
- 201 The tracker, with its first scan running.
Field Type About idinteger locationIdstring The location. keywordstring gridSizeinteger: 3 | 5 | 7 | 9 | 11 | 13 radiusKmnumber centerobject latnumber lngnumber businessobject cidstring or null namestring everystring or null: week | fortnight | month nextScanAtstring or null createdAtstring scansarray of RankScan Newest first. { "id": 9, "locationId": "loc_4", "keyword": "seafood restaurant", "gridSize": 7, "radiusKm": 2, "center": { "lat": 50.153, "lng": -5.067 }, "business": { "cid": "1234567890123456789", "name": "Harbour & Vine" }, "every": "week", "nextScanAt": "2026-11-04T09:30:00.000Z", "createdAt": "2026-10-28T09:30:00.000Z", "scans": [ { "id": 88, "trackerId": 9, "status": "running", "points": 49, "averageRank": null, "top3Share": null, "foundShare": null, "costUsd": null, "createdAt": "2026-10-28T09:30:00.000Z", "finishedAt": null } ] } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
- 503 Service Unavailable
GET /api/v1/rank-trackers/{id}
Get a rank tracker getRankTracker
The tracker and its scan history, newest first (up to a year of weekly scans).
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The tracker.
Field Type About idinteger locationIdstring The location. keywordstring gridSizeinteger: 3 | 5 | 7 | 9 | 11 | 13 radiusKmnumber centerobject latnumber lngnumber businessobject cidstring or null namestring everystring or null: week | fortnight | month nextScanAtstring or null createdAtstring scansarray of RankScan Newest first. { "id": 9, "locationId": "loc_4", "keyword": "seafood restaurant", "gridSize": 7, "radiusKm": 2, "center": { "lat": 50.153, "lng": -5.067 }, "business": { "cid": "1234567890123456789", "name": "Harbour & Vine" }, "every": "week", "nextScanAt": "2026-11-04T09:30:00.000Z", "createdAt": "2026-10-28T09:30:00.000Z", "scans": [ { "id": 88, "trackerId": 9, "status": "done", "points": 49, "averageRank": 3.4, "top3Share": 0.592, "foundShare": 0.918, "costUsd": 0.0294, "createdAt": "2026-10-28T09:30:00.000Z", "finishedAt": "2026-10-28T09:30:00.000Z" } ] } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
DELETE /api/v1/rank-trackers/{id}
Delete a rank tracker deleteRankTracker
Deletes it with its scans.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/rank-trackers/{id}/scans
Scan again scanRankTracker
Starts a scan now (409 while one is running). Points finish within minutes; poll GET /api/v1/rank-scans/{id}.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Responses
- 202 The scan, running.
Field Type About idinteger trackerIdinteger statusstring: running | done | failed pointsinteger Grid points, which count against the plan’s monthly allowance. averageRanknumber or null Average Maps rank over the points where the business shows. top3Sharenumber or null Share of points (0–1) where it’s in the top 3. foundSharenumber or null Share of points (0–1) where it’s in the top 20. costUsdnumber or null createdAtstring finishedAtstring or null { "id": 88, "trackerId": 9, "status": "running", "points": 49, "averageRank": null, "top3Share": null, "foundShare": null, "costUsd": null, "createdAt": "2026-10-28T09:30:00.000Z", "finishedAt": null } - 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
- 503 Service Unavailable
GET /api/v1/rank-scans/{id}
Get a rank scan getRankScan
The rank at every grid point (row by row from the north-west), the top 3 there, and the businesses that most often beat this one.
Any key (read or write).
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 200 The scan.
Field Type About idinteger optional trackerIdinteger optional statusstring: running | done | failed optional pointsinteger optional Grid points, which count against the plan’s monthly allowance. averageRanknumber or null optional Average Maps rank over the points where the business shows. top3Sharenumber or null optional Share of points (0–1) where it’s in the top 3. foundSharenumber or null optional Share of points (0–1) where it’s in the top 20. costUsdnumber or null optional createdAtstring optional finishedAtstring or null optional competitorsarray of object optional gridarray of object optional Row by row from the north-west corner. { "id": 88, "trackerId": 9, "status": "done", "points": 49, "averageRank": 3.4, "top3Share": 0.592, "foundShare": 0.918, "costUsd": 0.0294, "createdAt": "2026-10-28T09:30:00.000Z", "finishedAt": "2026-10-28T09:30:00.000Z", "competitors": [ { "name": "The Shack", "cid": "987654321", "appearances": 31, "averageRank": 1.6 } ], "grid": [ { "index": 0, "lat": 50.17099, "lng": -5.09509, "status": "done", "rank": 4, "top": [ { "rank": 1, "title": "The Shack", "cid": "987654321" } ] } ] } - 400 Bad Request
- 401 Unauthorized
- 404 Not Found
- 429 Too Many Requests
workspace
Reply voice, plan and usage.
GET /api/v1/workspace
Workspace, plan and usage getWorkspace
The key’s workspace: its reply voice, plan limits and what’s used of them this month.
Any key (read or write).
Responses
- 200 The workspace.
Field Type About idinteger Workspace id. namestring Workspace name. businessNamestring or null The name AI drafts sign as. tonestring: friendly | professional | playful | concise voiceNotesstring or null signoffstring or null planobject namestring: free | pro locationsinteger Locations the plan covers. checkEveryMinutesinteger How often reviews are collected. aiDraftsPerMonthinteger or null null for unlimited. alertChannelsarray of string directRepliesboolean Whether replies post straight to sites with an owner API. addonsobject How many of the plan’s locations the Local add-on (map rank points) covers. requestEmailsPerMonthinteger requestTextsPerMonthinteger rankPointsPerMonthinteger Map rank grid points a month. appTopicsboolean Whether AI topics for apps are included. usageobject locationsinteger Locations in use. profilesinteger Review pages in use. aiDraftsThisMonthinteger AI drafts made this calendar month. aiDraftsLeftinteger or null null for unlimited. requestEmailsThisMonthinteger Sent and queued this calendar month. requestTextsThisMonthinteger rankPointsThisMonthinteger autoReplyobject Auto-replies: see reply rules. enabledboolean Off until you turn it on. dailyCapinteger Most replies posted automatically in 24 hours. { "id": 1, "name": "Harbour & Vine reviews", "businessName": "Harbour & Vine", "tone": "friendly", "voiceNotes": "Family-run. Use first names.", "signoff": "Sam and the team", "plan": { "name": "pro", "locations": 3, "checkEveryMinutes": 60, "aiDraftsPerMonth": null, "alertChannels": [ "digest", "email", "slack", "webhook" ], "directReplies": true, "addons": { "local": 0 }, "requestEmailsPerMonth": 3000, "requestTextsPerMonth": 0, "rankPointsPerMonth": 450, "appTopics": true }, "usage": { "locations": 2, "profiles": 5, "aiDraftsThisMonth": 41, "aiDraftsLeft": null, "requestEmailsThisMonth": 212, "requestTextsThisMonth": 0, "rankPointsThisMonth": 98 }, "autoReply": { "enabled": false, "dailyCap": 20 } } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
PATCH /api/v1/workspace
Update the reply voice updateWorkspace
Changes the workspace name and what AI drafts are written from. Fields left out keep their value; send null to clear one.
Needs a write key.
Body
| Field | Type | About |
|---|---|---|
name | string optional | |
businessName | string or null optional | |
tone | string: friendly | professional | playful | concise optional | |
voiceNotes | string or null optional | |
signoff | string or null optional | |
autoReply | object optional | |
enabled | boolean optional | Off until you turn it on. |
dailyCap | integer optional | Most replies posted automatically in 24 hours. |
{
"tone": "playful",
"signoff": "Sam and the team"
}
Responses
- 200 The updated workspace.
Field Type About idinteger Workspace id. namestring Workspace name. businessNamestring or null The name AI drafts sign as. tonestring: friendly | professional | playful | concise voiceNotesstring or null signoffstring or null planobject namestring: free | pro locationsinteger Locations the plan covers. checkEveryMinutesinteger How often reviews are collected. aiDraftsPerMonthinteger or null null for unlimited. alertChannelsarray of string directRepliesboolean Whether replies post straight to sites with an owner API. addonsobject How many of the plan’s locations the Local add-on (map rank points) covers. requestEmailsPerMonthinteger requestTextsPerMonthinteger rankPointsPerMonthinteger Map rank grid points a month. appTopicsboolean Whether AI topics for apps are included. usageobject locationsinteger Locations in use. profilesinteger Review pages in use. aiDraftsThisMonthinteger AI drafts made this calendar month. aiDraftsLeftinteger or null null for unlimited. requestEmailsThisMonthinteger Sent and queued this calendar month. requestTextsThisMonthinteger rankPointsThisMonthinteger autoReplyobject Auto-replies: see reply rules. enabledboolean Off until you turn it on. dailyCapinteger Most replies posted automatically in 24 hours. { "id": 1, "name": "Harbour & Vine reviews", "businessName": "Harbour & Vine", "tone": "friendly", "voiceNotes": "Family-run. Use first names.", "signoff": "Sam and the team", "plan": { "name": "pro", "locations": 3, "checkEveryMinutes": 60, "aiDraftsPerMonth": null, "alertChannels": [ "digest", "email", "slack", "webhook" ], "directReplies": true, "addons": { "local": 0 }, "requestEmailsPerMonth": 3000, "requestTextsPerMonth": 0, "rankPointsPerMonth": 450, "appTopics": true }, "usage": { "locations": 2, "profiles": 5, "aiDraftsThisMonth": 41, "aiDraftsLeft": null, "requestEmailsThisMonth": 212, "requestTextsThisMonth": 0, "rankPointsThisMonth": 98 }, "autoReply": { "enabled": false, "dailyCap": 20 } } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 429 Too Many Requests
api keys
Create, rotate and revoke keys.
GET /api/v1/keys
List API keys listKeys
Every REST API key in the workspace, with when, where from and how often each was used. Keys themselves are never shown again.
Any key (read or write).
Responses
- 200 The keys.
Field Type About dataarray of ApiKey { "data": [ { "id": 4, "name": "BI export", "scope": "read", "prefix": "ef_3k9Qw1x", "createdAt": "2026-10-28T09:30:00.000Z", "expiresAt": null, "lastUsedAt": "2026-10-28T09:30:00.000Z", "lastUsedIp": "203.0.113.7", "requestCount": 1288, "mode": "live" } ] } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
POST /api/v1/keys
Create a key createKey
Makes a key. The answer holds the key itself, once; Earful keeps only its hash.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
name | string optional | |
scope | string: read | write optional | |
expiresInDays | integer optional | Leave out for a key that doesn’t expire. |
mode | string: live | test optional | test makes an ef_test_ key for the workspace’s sandbox: made-up data, no outside calls. |
{
"name": "BI export",
"scope": "read",
"expiresInDays": 90
}
Responses
- 201 The key, shown once.
Field Type About idinteger optional Key id. namestring optional What it’s for. scopestring: read | write optional read: GET only. write: everything. prefixstring optional The start of the key, to tell keys apart. createdAtstring optional When it was made. expiresAtstring or null optional When it stops working; null for never. A rotated key’s grace period ends here. lastUsedAtstring or null optional When it was last used. lastUsedIpstring or null optional The address it was last used from. requestCountinteger optional Requests made with it. modestring: live | test optional test: an ef_test_ key, which reaches the workspace’s sandbox (made-up data, no outside calls). keystring optional The key itself. Shown only in this answer; Earful keeps a hash. { "id": 4, "name": "BI export", "scope": "read", "prefix": "ef_3k9Qw1x", "createdAt": "2026-10-28T09:30:00.000Z", "expiresAt": null, "lastUsedAt": null, "lastUsedIp": null, "requestCount": 0, "key": "ef_3k9Qw1x…", "mode": "live" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
PATCH /api/v1/keys/{id}
Rename a key renameKey
Changes a key’s name.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Body
| Field | Type | About |
|---|---|---|
name | string |
{
"name": "Looker"
}
Responses
- 200 The key.
Field Type About idinteger Key id. namestring What it’s for. scopestring: read | write read: GET only. write: everything. prefixstring The start of the key, to tell keys apart. createdAtstring When it was made. expiresAtstring or null When it stops working; null for never. A rotated key’s grace period ends here. lastUsedAtstring or null When it was last used. lastUsedIpstring or null The address it was last used from. requestCountinteger Requests made with it. modestring: live | test test: an ef_test_ key, which reaches the workspace’s sandbox (made-up data, no outside calls). { "id": 4, "name": "Looker", "scope": "read", "prefix": "ef_3k9Qw1x", "createdAt": "2026-10-28T09:30:00.000Z", "expiresAt": null, "lastUsedAt": "2026-10-28T09:30:00.000Z", "lastUsedIp": "203.0.113.7", "requestCount": 1288, "mode": "live" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
DELETE /api/v1/keys/{id}
Revoke a key revokeKey
Stops a key working, straight away.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
POST /api/v1/keys/{id}/rotate
Rotate a key rotateKey
Makes a new key with the same name, scope and expiry, and ends the old one after a grace period (24 hours unless you say; 0 ends it now) so callers can switch without downtime.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
IdempotencyKey optional | header | string | Any unique string, like a UUID. Retrying with the same key within 24 hours gets the first answer back (with Idempotent-Replayed: true) instead of doing it twice. The same key on a different request is a 422. |
Body
| Field | Type | About |
|---|---|---|
graceHours | integer optional |
{
"graceHours": 168
}
Responses
- 201 The new key, shown once, and the old one’s new expiry.
Field Type About previousApiKey optional { "id": 5, "name": "BI export", "scope": "read", "prefix": "ef_3k9Qw1x", "createdAt": "2026-10-28T09:30:00.000Z", "expiresAt": null, "lastUsedAt": "2026-10-28T09:30:00.000Z", "lastUsedIp": "203.0.113.7", "requestCount": 0, "key": "ef_Zp0…", "previous": { "id": 4, "name": "BI export", "scope": "read", "prefix": "ef_3k9Qw1x", "createdAt": "2026-10-28T09:30:00.000Z", "expiresAt": "2026-10-29T09:30:00.000Z", "lastUsedAt": "2026-10-28T09:30:00.000Z", "lastUsedIp": "203.0.113.7", "requestCount": 1288, "mode": "live" }, "mode": "live" } - 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Content
- 429 Too Many Requests
connected apps
MCP clients signed in with OAuth, and disconnecting them.
GET /api/v1/connected-apps
List connected apps listConnectedApps
Apps connected to the workspace’s MCP server with OAuth (claude.ai connectors and the like), with who allowed them and when they were last used.
Any key (read or write).
Responses
- 200 The connected apps.
Field Type About dataarray of ConnectedApp { "data": [ { "id": 2, "name": "Claude", "clientId": "efc_Zk3PqA9wX1bT0cV7nL2mRg", "redirectHosts": [ "claude.ai" ], "scope": "write", "connectedBy": "owner@harbour.example", "createdAt": "2026-10-28T09:30:00.000Z", "lastUsedAt": "2026-10-28T10:02:00.000Z", "lastUsedIp": "203.0.113.7", "requestCount": 42 } ] } - 400 Bad Request
- 401 Unauthorized
- 429 Too Many Requests
DELETE /api/v1/connected-apps/{id}
Disconnect an app revokeConnectedApp
Revokes the app’s access and refresh tokens straight away. It has to be connected again to come back.
Needs a write key.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Id | path | string | The resource’s id, like loc_3. The bare number still works. |
Responses
- 204 Done; no body.
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Too Many Requests
free tools
The no-sign-up tools on the Earful site. No key, a small daily allowance per IP address.
GET /api/v1/tools/google-places
Free tool: find a Google place ID and review link findGooglePlace
No key. Looks a business up on Google by name and answers each match with its place ID and write-a-review link. 10 new lookups a day per IP address; repeat lookups come from a cache and don’t count. With a key, use place searches instead.
Parameters
| Name | In | Type | About |
|---|---|---|---|
q | query | string | The business name. |
near optional | query | string | A town or postcode to narrow it down. |
Responses
- 200 Matches, best first. Empty when nothing matched.
Field Type About dataarray of object { "data": [ { "name": "Salami Social Club", "address": "12 High Street, Leeds LS1 4AB", "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4", "reviewUrl": "https://search.google.com/local/writereview?placeid=ChIJN1t_tDeuEmsRUsoyG83frY4", "rating": 4.6, "reviewCount": 412 } ] } - 400 Bad Request
- 429 Too Many Requests
- 503 Service Unavailable
POST /api/v1/tools/reply-drafts
Free tool: draft a reply to a pasted review draftFreeReply
No key. Drafts a reply to one review in the tone you pick. Nothing is stored. 5 drafts a day per IP address, reviews up to 2,000 characters. With a key, draft replies to collected reviews instead, in your workspace’s voice.
Body
| Field | Type | About |
|---|---|---|
review | string | |
rating | integer or null optional | |
tone | string: friendly | professional | playful | concise optional | |
businessName | string or null optional |
{
"review": "Waited 40 minutes for our mains and nobody said why.",
"rating": 2,
"tone": "professional",
"businessName": "Salami Social Club"
}
Responses
- 200 The draft.
Field Type About replystring { "reply": "Thank you for telling us, and we’re sorry about the wait. That isn’t the evening we want anyone to have. Please get in touch so we can look into what happened." } - 400 Bad Request
- 422 Unprocessable Content
- 429 Too Many Requests
- 502 Service Unavailable
- 503 Service Unavailable
meta
This spec.
GET /api/v1/openapi.json
This specification getOpenApi
The OpenAPI 3.1 document for this API, with this server as its server URL. No key needed.
Responses
- 200 The spec.
object
{ "openapi": "3.1.0" }
webhooks
What Earful POSTs to a webhook alert’s URL.
POST reviews.new
New reviews reviewsNew
New reviews that match a webhook alert’s filters.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Earful-Signature | header | string | t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with the alert’s secret>. Refuse it if t is over 5 minutes off. |
Earful-Event optional | header | string | |
Earful-Delivery optional | header | string | The delivery’s number. |
Body
| Field | Type | About |
|---|---|---|
id | string | The event’s id (evt_…); a replay sends the same one, so dedupe on it. |
event | any | |
livemode | boolean | false when it comes from the test-mode sandbox (an ef_test_ key’s workspace). |
createdAt | string | |
workspace | object | |
id | integer | |
name | string | |
reviews | array of Review |
Responses
- 200 Any 2xx counts as delivered; anything else is retried.
POST profile.imported
Profile imported profileImported
A profile’s history import is over. Sent to webhook alerts whose site and location filters fit the profile.
Parameters
| Name | In | Type | About |
|---|---|---|---|
Earful-Signature | header | string | t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with the alert’s secret>. Refuse it if t is over 5 minutes off. |
Earful-Event optional | header | string | |
Earful-Delivery optional | header | string | The delivery’s number. |
Body
| Field | Type | About |
|---|---|---|
id | string | The event’s id (evt_…); a replay sends the same one, so dedupe on it. |
event | any | |
livemode | boolean | false when it comes from the test-mode sandbox (an ef_test_ key’s workspace). |
createdAt | string | |
workspace | object | |
id | integer | |
name | string | |
profile | Profile |
Responses
- 200 Any 2xx counts as delivered; anything else is retried.
schemas
Problem
An RFC 9457 problem details object, sent as application/problem+json.
| Field | Type | About |
|---|---|---|
type | string | A URI for the problem type; always about:blank, so title is the HTTP status text. |
title | string | The HTTP status text. |
status | integer | The HTTP status code. |
detail | string | What went wrong, in plain English, and usually how to fix it. |
existingId | string optional | On a 409 for a profile that’s already there: its id. |
Location
One business (or app) across all its review sites. Plans are priced per location.
| Field | Type | About |
|---|---|---|
id | string | Location id. |
name | string | The business or branch name. |
profileCount | integer | How many review pages this location has. |
createdAt | string | When it was added. |
externalRef | string or null | Your own reference for it, like your store id. Filter with GET /locations?externalRef=. |
Profile
One review page of one location on one site.
| Field | Type | About |
|---|---|---|
id | string | Profile id. |
locationId | string | The location it belongs to. |
locationName | string or null | That location’s name. |
site | string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra | Which review site. |
name | string | A name for the review page. |
url | string | The review page. |
reviewCount | integer | Reviews held (not counting removed ones). |
averageRating | number or null | Average star rating of those reviews. |
lastCheckedAt | string or null | When reviews were last collected. |
nextCheckAt | string | When the next collection is due. |
lastError | string or null | Why the last collection failed, if it did. |
createdAt | string | When it was added. |
externalId | string or null | The listing’s own id on its site: a Google cid or place id, a Tripadvisor location id (d7106969), a Trustpilot domain, an App Store id, a Play package, a Booking.com hotel slug. Two links to one listing share it, so it’s what to match on, not url. null where the link doesn’t carry one. |
import | object | Its history import. The first reviews come within a minute or two; then older ones load in the background, up to the plan’s cap (Free 40, Plus 5,000). A profile.imported webhook says when it’s over. |
status | string: queued | importing | done | failed | queued: first import not in yet. importing: loading older reviews. done or failed: over. |
reviews | integer | Reviews held so far. |
limit | integer or null | The history cap it ran to, once over. |
finishedAt | string or null | When it was over. |
error | string or null | Why it failed, if it did. |
Review
| Field | Type | About |
|---|---|---|
id | string | Earful’s id for the review. |
site | string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra | |
profileId | string | The profile it came from. |
profileName | string or null | |
reviewId | string | The site’s own id for the review. |
author | string or null | |
rating | integer or null | Whole stars; out-of-10 sites are halved. |
title | string or null | |
text | string or null | |
language | string or null | |
publishedAt | string or null | When the reviewer posted it. |
updatedAt | string or null | When the site says it was last changed. |
ownerReply | null or object | The business’s reply, from the site or recorded in Earful. |
reviewUrl | string or null | |
editedAt | string or null | When Earful noticed the reviewer edit it. |
deletedAt | string or null | When Earful noticed it removed from the site. |
handledAt | string or null | When it was marked handled. |
firstSeenAt | string | When Earful first collected it. |
tags | array of string | |
draft | string or null | The reply draft, if there is one. |
sentiment | string or null: positive | neutral | negative | mixed | Read by AI when the review arrived; null until then or with AI off. |
translation | string or null | An English translation by AI, for reviews in another language. |
appVersion | string or null | App stores: the app version the review was left on. |
country | string or null | App stores: the store country, ISO 3166 (alpha-2 from public feeds, alpha-3 from App Store Connect). |
history | boolean | Came in with the profile’s first import or history backfill, so no alert went out for it. |
ReviewDetail
| Field | Type | About |
|---|---|---|
id | string optional | Earful’s id for the review. |
site | string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional | |
profileId | string optional | The profile it came from. |
profileName | string or null optional | |
reviewId | string optional | The site’s own id for the review. |
author | string or null optional | |
rating | integer or null optional | Whole stars; out-of-10 sites are halved. |
title | string or null optional | |
text | string or null optional | |
language | string or null optional | |
publishedAt | string or null optional | When the reviewer posted it. |
updatedAt | string or null optional | When the site says it was last changed. |
ownerReply | null or object optional | The business’s reply, from the site or recorded in Earful. |
reviewUrl | string or null optional | |
editedAt | string or null optional | When Earful noticed the reviewer edit it. |
deletedAt | string or null optional | When Earful noticed it removed from the site. |
handledAt | string or null optional | When it was marked handled. |
firstSeenAt | string optional | When Earful first collected it. |
tags | array of string optional | |
draft | string or null optional | The reply draft, if there is one. |
sentiment | string or null: positive | neutral | negative | mixed optional | Read by AI when the review arrived; null until then or with AI off. |
translation | string or null optional | An English translation by AI, for reviews in another language. |
appVersion | string or null optional | App stores: the app version the review was left on. |
country | string or null optional | App stores: the store country, ISO 3166 (alpha-2 from public feeds, alpha-3 from App Store Connect). |
history | boolean optional | Came in with the profile’s first import or history backfill, so no alert went out for it. |
replyRoute | object optional | |
mode | string: api | link | none | api: POST /reply posts to the site. link: post it on the site at url. none: no way to reply. |
via | string or null | Which owner API posts it (appstore, googleplay, gbp) when mode is api. |
url | string or null | The site’s reply page when mode is link. |
Draft
| Field | Type | About |
|---|---|---|
text | string | The draft reply. |
aiDraftsLeft | integer or null | AI drafts left this month; null when unlimited or not an AI draft. |
ReplyResult
| Field | Type | About |
|---|---|---|
postedToSite | boolean | true when Earful posted it through the site’s owner API. |
via | string: appstore | googleplay | gbp | facebook | manual | manual means recorded only: post it on the site yourself. |
replyUrl | string or null | Where to post it when via is manual. |
review | ReviewDetail |
Alert
| Field | Type | About |
|---|---|---|
id | string | Alert id. |
channel | string: digest | email | slack | webhook | |
frequency | string or null: daily | weekly | For digests. |
target | string | Where it goes: the email address, “Slack channel”, or the webhook’s host. Full URLs stay encrypted. |
maxRating | integer or null | Only reviews at or below this rating; null for all. |
sites | array of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra | Only these sites; empty for all. |
lastSentAt | string or null | When it last sent. |
lastError | string or null | |
createdAt | string | When it was added. |
locationId | string or null | Only this location; null for all. |
sentiments | array of string: positive | neutral | negative | mixed | Only reviews the AI read as one of these; empty for any. |
tags | array of string | Only reviews with one of these tags; empty for any. |
keywords | array of string | Only reviews that mention one of these (in the text or its translation); empty for any. |
NewAlert
| Field | Type | About |
|---|---|---|
id | string optional | Alert id. |
channel | string: digest | email | slack | webhook optional | |
frequency | string or null: daily | weekly optional | For digests. |
target | string optional | Where it goes: the email address, “Slack channel”, or the webhook’s host. Full URLs stay encrypted. |
maxRating | integer or null optional | Only reviews at or below this rating; null for all. |
sites | array of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra optional | Only these sites; empty for all. |
lastSentAt | string or null optional | When it last sent. |
lastError | string or null optional | |
createdAt | string optional | When it was added. |
locationId | string or null optional | Only this location; null for all. |
sentiments | array of string: positive | neutral | negative | mixed optional | Only reviews the AI read as one of these; empty for any. |
tags | array of string optional | Only reviews with one of these tags; empty for any. |
keywords | array of string optional | Only reviews that mention one of these (in the text or its translation); empty for any. |
secret | string or null optional | A webhook’s signing secret. Shown only in this answer. |
ApiKey
| Field | Type | About |
|---|---|---|
id | integer | Key id. |
name | string | What it’s for. |
scope | string: read | write | read: GET only. write: everything. |
prefix | string | The start of the key, to tell keys apart. |
createdAt | string | When it was made. |
expiresAt | string or null | When it stops working; null for never. A rotated key’s grace period ends here. |
lastUsedAt | string or null | When it was last used. |
lastUsedIp | string or null | The address it was last used from. |
requestCount | integer | Requests made with it. |
mode | string: live | test | test: an ef_test_ key, which reaches the workspace’s sandbox (made-up data, no outside calls). |
NewApiKey
| Field | Type | About |
|---|---|---|
id | integer optional | Key id. |
name | string optional | What it’s for. |
scope | string: read | write optional | read: GET only. write: everything. |
prefix | string optional | The start of the key, to tell keys apart. |
createdAt | string optional | When it was made. |
expiresAt | string or null optional | When it stops working; null for never. A rotated key’s grace period ends here. |
lastUsedAt | string or null optional | When it was last used. |
lastUsedIp | string or null optional | The address it was last used from. |
requestCount | integer optional | Requests made with it. |
mode | string: live | test optional | test: an ef_test_ key, which reaches the workspace’s sandbox (made-up data, no outside calls). |
key | string optional | The key itself. Shown only in this answer; Earful keeps a hash. |
RotatedApiKey
| Field | Type | About |
|---|---|---|
previous | ApiKey optional |
Workspace
| Field | Type | About |
|---|---|---|
id | integer | Workspace id. |
name | string | Workspace name. |
businessName | string or null | The name AI drafts sign as. |
tone | string: friendly | professional | playful | concise | |
voiceNotes | string or null | |
signoff | string or null | |
plan | object | |
name | string: free | pro | |
locations | integer | Locations the plan covers. |
checkEveryMinutes | integer | How often reviews are collected. |
aiDraftsPerMonth | integer or null | null for unlimited. |
alertChannels | array of string | |
directReplies | boolean | Whether replies post straight to sites with an owner API. |
addons | object | How many of the plan’s locations the Local add-on (map rank points) covers. |
requestEmailsPerMonth | integer | |
requestTextsPerMonth | integer | |
rankPointsPerMonth | integer | Map rank grid points a month. |
appTopics | boolean | Whether AI topics for apps are included. |
usage | object | |
locations | integer | Locations in use. |
profiles | integer | Review pages in use. |
aiDraftsThisMonth | integer | AI drafts made this calendar month. |
aiDraftsLeft | integer or null | null for unlimited. |
requestEmailsThisMonth | integer | Sent and queued this calendar month. |
requestTextsThisMonth | integer | |
rankPointsThisMonth | integer | |
autoReply | object | Auto-replies: see reply rules. |
enabled | boolean | Off until you turn it on. |
dailyCap | integer | Most replies posted automatically in 24 hours. |
Candidate
| Field | Type | About |
|---|---|---|
candidateId | integer | Pass this to POST /locations/{id}/profiles to add it. |
site | string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra | |
name | string | The business or app. |
address | string or null | Street address; for apps, the developer. |
url | string | Its review page. |
rating | number or null | |
reviewCount | integer or null |
PlaceSearch
| Field | Type | About |
|---|---|---|
id | integer | Search id. |
query | string | What was searched for. |
near | string or null | |
status | string: running | done | failed | done once every site has finished (some may have failed); failed when all did. |
createdAt | string | When it started. |
sites | array of object | |
candidates | array of Candidate |
TagRule
| Field | Type | About |
|---|---|---|
id | integer | |
tag | string | Lower case letters, digits, - and _. |
description | string | When the AI should apply it. |
createdAt | string |
ReplyRule
| Field | Type | About |
|---|---|---|
id | integer | |
locationId | string or null | The location it covers; null for every location. |
minRating | integer | |
maxRating | integer | |
sites | array of string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra | Empty for every site. |
languages | array of string | Two-letter codes; empty for any. |
keywords | array of string | The review must mention one; empty for any. |
action | string: post | approve | post: post the AI reply where the site has a reply API, otherwise hold it. approve: always hold it. |
createdAt | string |
AutoReply
| Field | Type | About |
|---|---|---|
id | string | Auto-reply id. |
reviewId | string | The review. |
ruleId | integer or null | |
status | string: pending | posted | approved | rejected | skipped | pending is the approval queue; posted means posted automatically. |
text | string or null | The AI reply (as approved, if edited). |
reason | string or null | Why it’s held or skipped. |
decidedBy | string or null | auto, an email, an API key or a Slack user. |
createdAt | string | |
decidedAt | string or null | |
review | object | |
site | string: google | tripadvisor | trustpilot | booking | appstore | googleplay | feefo | reviewsio | yelp | deliveroo | justeat | ubereats | opentable | doordash | grubhub | facebook | g2 | capterra | |
rating | integer or null | |
author | string or null | |
text | string or null | |
profileName | string |
ReviewLink
| Field | Type | About |
|---|---|---|
locationId | string | The location. |
code | string | |
url | string | The short link to print or share; it redirects and counts the click. |
site | string or null | The site it sends people to, if it’s one Earful knows. |
targetUrl | string | Where it sends people: the site’s write-a-review page. |
clicks | integer | |
lastClickAt | string or null | |
qrSvg | string | A QR code for url, as an SVG document. |
createdAt | string |
ConnectedApp
An app someone connected to the workspace with OAuth, like a claude.ai connector to the MCP server.
| Field | Type | About |
|---|---|---|
id | integer | Connection id. |
name | string | The app’s name, as it registered. |
clientId | string | Its OAuth client id: efc_… when it registered itself, or the https address of its Client ID Metadata Document. |
redirectHosts | array of string | Where it sends people back to after sign-in. |
scope | string: read | write | read: search and read. write: also draft, post and add. |
connectedBy | string | Who allowed it. |
createdAt | string | When it was connected. |
lastUsedAt | string or null | When it last called the MCP server. |
lastUsedIp | string or null | The address it last called from. |
requestCount | integer | MCP requests it has made. |
ReviewRequest
| Field | Type | About |
|---|---|---|
id | integer | |
locationId | string | The location. |
locationName | string or null | |
channel | string: email | sms | |
contact | string | Email address, or mobile number in E.164. |
name | string or null | |
orderRef | string or null | |
source | string: api | csv | webhook | app | |
status | string: queued | sent | failed | suppressed | suppressed: the customer opted out before it went. |
sendAt | string | |
sentAt | string or null | |
remindedAt | string or null | When the one reminder went, to people who hadn’t clicked. |
clickedAt | string or null | First click on the review link. |
reviewId | string or null | The review Earful matched to this request by the reviewer’s name. |
unsubscribedAt | string or null | |
error | string or null | |
transactedAt | string or null | |
createdAt | string |
ReviewRequestSettings
| Field | Type | About |
|---|---|---|
emailSubject | string | |
emailBody | string | Must contain {link}. {name} and {business} are filled in. |
reminderSubject | string | |
reminderBody | string | |
smsBody | string | |
reminderDays | integer | 0 for no reminder. |
replyTo | string or null | |
acknowledged | boolean | Whether the owner agreed the sending rules; requests need it. |
acknowledgedAt | string or null | |
consentWording | string | Suggested wording for receipts and booking forms. |
Suppression
| Field | Type | About |
|---|---|---|
id | integer | |
contact | string | |
reason | string: unsubscribed | manual | |
createdAt | string |
App
| Field | Type | About |
|---|---|---|
id | string | The App Store or Google Play profile’s id. |
locationId | string | The location. |
site | string: appstore | googleplay | |
name | string | |
url | string | |
reviewCount | integer | |
averageRating | number or null | |
latestVersion | string or null |
AppTopics
| Field | Type | About |
|---|---|---|
appId | string | The app’s profile id. |
reviewCount | integer | Reviews the topics were drawn from (the newest written ones). |
topics | array of object | |
createdAt | string |
RankScan
| Field | Type | About |
|---|---|---|
id | integer | |
trackerId | integer | |
status | string: running | done | failed | |
points | integer | Grid points, which count against the plan’s monthly allowance. |
averageRank | number or null | Average Maps rank over the points where the business shows. |
top3Share | number or null | Share of points (0–1) where it’s in the top 3. |
foundShare | number or null | Share of points (0–1) where it’s in the top 20. |
costUsd | number or null | |
createdAt | string | |
finishedAt | string or null |
RankTracker
| Field | Type | About |
|---|---|---|
id | integer | |
locationId | string | The location. |
keyword | string | |
gridSize | integer: 3 | 5 | 7 | 9 | 11 | 13 | |
radiusKm | number | |
center | object | |
lat | number | |
lng | number | |
business | object | |
cid | string or null | |
name | string | |
every | string or null: week | fortnight | month | |
nextScanAt | string or null | |
createdAt | string | |
scans | array of RankScan | Newest first. |
RankScanDetail
| Field | Type | About |
|---|---|---|
id | integer optional | |
trackerId | integer optional | |
status | string: running | done | failed optional | |
points | integer optional | Grid points, which count against the plan’s monthly allowance. |
averageRank | number or null optional | Average Maps rank over the points where the business shows. |
top3Share | number or null optional | Share of points (0–1) where it’s in the top 3. |
foundShare | number or null optional | Share of points (0–1) where it’s in the top 20. |
costUsd | number or null optional | |
createdAt | string optional | |
finishedAt | string or null optional | |
competitors | array of object optional | |
grid | array of object optional | Row by row from the north-west corner. |
WebhookDelivery
One webhook send: what went, to which alert, and how it went.
| Field | Type | About |
|---|---|---|
id | string | Delivery id. |
alertId | string | The webhook alert it went to. |
event | string: reviews.new | profile.imported | The event. |
status | string: pending | delivered | failed | pending: will retry at nextAttemptAt. |
test | boolean | A test send (POST /alerts/{id}/test); never retried. |
attempts | integer | Tries so far. |
responseStatus | integer or null | Your endpoint’s last HTTP status. |
error | string or null | Why the last try failed. |
replayOf | string or null | The delivery this replays. |
createdAt | string | |
lastAttemptAt | string or null | |
nextAttemptAt | string or null | The next retry, while pending. |
deliveredAt | string or null |
ReviewsNewEvent
New reviews that match the alert’s filters.
| Field | Type | About |
|---|---|---|
id | string | The event’s id (evt_…); a replay sends the same one, so dedupe on it. |
event | any | |
livemode | boolean | false when it comes from the test-mode sandbox (an ef_test_ key’s workspace). |
createdAt | string | |
workspace | object | |
id | integer | |
name | string | |
reviews | array of Review |
ProfileImportedEvent
A profile’s history import is over (import.status done or failed).
| Field | Type | About |
|---|---|---|
id | string | The event’s id (evt_…); a replay sends the same one, so dedupe on it. |
event | any | |
livemode | boolean | false when it comes from the test-mode sandbox (an ef_test_ key’s workspace). |
createdAt | string | |
workspace | object | |
id | integer | |
name | string | |
profile | Profile |