API & MCP
Connect Zapier, GoHighLevel and your own tools with webhooks
Webhooks send new reviews, negative reviews, finished scans, Business Profile changes and tasks to Zapier, Make, n8n, GoHighLevel or your own server, signed and retried.
On this page
Webhooks send what happens in SearchOps straight into your own tools as it happens: a new or negative Google review into your CRM, a finished geo-grid scan or a real move into a client report, a Business Profile change or a new task into your team's board. Each event is a signed HTTPS POST with a small JSON body, so it works with Zapier, Make, n8n, GoHighLevel or your own server.
What you'll need
- A plan that includes webhooks. Webhooks are available on Pro and above, like the API.
- To be an owner or admin of the account. Other team members can't see or change webhooks.
- A URL that accepts a POST request. No-code tools give you one (see below), or use your own server. It must start with
https://and be reachable from the internet.
Add a webhook
- Go to Developers > Webhooks in the dashboard and select Add webhook.
- Paste the URL your tool gave you and, if you like, give it a name such as "New reviews to our CRM".
- Choose the events. Every event also sends events we add later; or tick only the ones you want.
- Choose the locations. Every location is the default; or pick some, which is handy for sending one client's events to that client's own CRM.
- Select Add webhook and copy the signing secret. It starts with
whsec_and is shown once only. - Select Send test. A test event goes straight away and the page tells you what your endpoint answered.
You can have up to 100 webhooks, so an agency can give each client its own. Each one can be turned off and on, edited, or deleted from the same page.
Tip: Send a test before you build the rest of your Zap or scenario. Most tools need one sample event to learn the fields you can map.
Use with Zapier, Make, n8n and GoHighLevel
Every tool works the same way: it gives you a URL, you paste it into SearchOps, you press Send test, and the tool learns the fields.
- Zapier: create a Zap with the Webhooks by Zapier trigger and choose Catch Hook. Copy the webhook URL Zapier shows you.
- Make: add a Webhooks module, choose Custom webhook, add a webhook and copy its address. Leave it listening while you press Send test so it can work out the data structure.
- n8n: add a Webhook node, set the HTTP method to POST, and copy the production URL (the test URL only listens while you are editing).
- GoHighLevel: in Automation, create a workflow with the Inbound Webhook trigger and copy its URL. After Send test, select the sample request in the trigger so the fields can be used in later steps, such as creating a task or sending an SMS on a negative review.
No-code tools don't check signatures, so you can skip the next section if you only use one of them. Keep the webhook URL private: anyone who has it can send data to your Zap or workflow.
The events
| Event | When it is sent |
|---|---|
review.created | A new review was found on one of your locations. Sent for reviews posted in the last week, as soon as Google tells us (connected profiles) or the next review check finds it. |
review.negative | A new review of 1 or 2 stars. Sent as well as review.created, so you can route these on their own. |
scan.completed | A geo-grid scan finished (Google Maps, Google Search or AI Mode), manual or scheduled. |
geo_grid.moved | A finished scan moved up or down by more than the normal swing between two runs of the same scan, the same "real gain" or "real drop" the scan emails report. |
profile.changed | Profile Guard spotted a change to a connected Google Business Profile that SearchOps did not make: name, address, phone, website, hours, categories, description, verification and more. One event per changed field. |
task.created | A task was added by a person, the agent or SearchOps, or a suggestion was added from Up next. Suggestions still waiting are not sent. |
task.planned | A task went on a location's plan for the week, with its steps (steps), how it gets finished (done_when), its area, the week (planned_week) and the Friday it is due (due_on). The one to send to Asana, ClickUp, monday.com or Trello. |
task.completed | A task was finished. closed_by is person when someone ticked it off, or searchops when SearchOps saw from Google's own data that the work is done, so a copy in another tool can close either way. |
webhook.test | Sent only when you press Send test. |
What a request looks like
Every event has the same shape, so you can map fields by name:
{
"type": "review.negative",
"id": "evt_3m9QxR2bVt0aYw1Lk8sZpA",
"created_at": "2026-10-12T09:30:12.418Z",
"account_id": "7d1c2f4e-...",
"location": { "id": "c2a9e1b0-...", "business_name": "Northgate Dental" },
"data": {
"platform": "google",
"rating": 2,
"text_excerpt": "Waited 40 minutes past my appointment and nobody explained why.",
"text_truncated": false,
"reviewer_name": "Jamie R.",
"has_reply": false,
"published_at": "2026-10-12T08:55:00Z",
"reply_url": "https://searchops.co/dashboard/gbp/reply-inbox?profile=..."
}
}
location is null for events that belong to no single location, such as a test. Review text is cut at about 500 characters (text_truncated says when).
A finished scan:
{
"type": "scan.completed",
"data": {
"scan_id": "f0b1...",
"keyword": "emergency dentist",
"prompt": null,
"engine": "google_maps",
"engine_label": "Google Maps",
"measure": "top_3",
"grid_size": 7,
"grid_points": 49,
"points_answered": 49,
"points_in_top_3_or_named": 18,
"share": 0.367,
"share_low": 0.245,
"share_high": 0.509,
"scheduled": true,
"real_move": "down",
"completed_at": "2026-10-12T06:14:03Z",
"scan_url": "https://searchops.co/dashboard/scans/f0b1...",
"keyword_url": "https://searchops.co/dashboard/scans/keyword/..."
}
}
share is the points in the top 3 out of the points that returned an answer, with its likely range. For AI Mode, measure is named and the count is points where your business was named. prompt is the AI Mode prompt asked at every point instead of the keyword, or null when the keyword was asked. real_move is up, down or null. geo_grid.moved carries the same fields plus direction, points_gained, points_lost and points_compared.
A Business Profile change:
{
"type": "profile.changed",
"data": {
"field": "phoneNumbers",
"field_label": "Phone number",
"kind": null,
"severity": "high",
"before": "020 7946 0000",
"after": "020 7946 0999",
"changed_by": "google",
"profile_url": "https://searchops.co/dashboard/gbp/profiles/..."
}
}
before and after are short summaries, the same words as the Profile Guard email: opening hours read like "7 opening periods", verification reads "verified" or "not verified". changed_by is google when Google made the edit and someone_else otherwise. kind is verification or duplicate for those two changes, and null for the rest.
A task:
{
"type": "task.completed",
"data": {
"task_id": "9e7a...",
"title": "Reply to 3 unanswered reviews",
"description": null,
"priority": "high",
"status": "done",
"added_by": "searchops",
"due_on": "2026-10-16",
"created_at": "2026-10-10T07:00:00Z",
"completed_at": "2026-10-12T10:02:44Z",
"task_url": "https://searchops.co/dashboard/tasks?task=9e7a...",
"area": "Reviews",
"steps": ["Start with any 1 or 2 star reviews, then work through the rest, newest first."],
"done_when": "Checks itself from Google each week: every new review has a reply.",
"checks_itself": true,
"minutes": 10,
"planned_week": "2026-10-12",
"open_in_tool": "https://searchops.co/dashboard/gbp/reply-inbox?profile=...",
"closed_by": "searchops"
}
}
Note: A number we don't know is sent as null, never 0. A scan whose numbers could not be read sends null for share, so a missing figure can't look like a drop.
Check the signature
Every request carries three headers, following the Standard Webhooks spec, so any Standard Webhooks library can verify it:
webhook-id: the event id, the same asidin the body. It stays the same on every retry and on a replay, so you can safely ignore an id you have already handled.webhook-timestamp: when this attempt was sent, in Unix seconds.webhook-signature:v1,followed by a base64 HMAC-SHA256 ofid.timestamp.body, made with your secret (the part afterwhsec_, base64-decoded).
In Node.js, with no extra packages:
import crypto from "node:crypto";
const secret = process.env.SEARCHOPS_WEBHOOK_SECRET; // starts with whsec_
export function verifySearchOps(rawBody, headers) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"] ?? "";
// Refuse anything more than 5 minutes old.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
Use the body exactly as it arrived. If your framework parses JSON first, the signature won't match: in Express, use express.raw({ type: "application/json" }) on the route and convert the buffer to a string.
Warning: Treat the signing secret like a password. If it is exposed, select Rotate secret. You get a new secret at once, and for the next 24 hours we sign with both, so you have time to update your receiver.
Retries and the delivery log
Answer with any 2xx status within 10 seconds. Do slow work after you have answered. Anything else counts as a failure, including a timeout and a redirect (we don't follow redirects, so use the final URL). We then try again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: eight attempts over about 28 hours, after which the delivery is marked failed.
Select Deliveries on a webhook to see what was sent in the last 30 days, with the status, your endpoint's response code and how long it took. Open a row to see the exact body we sent and the start of what came back. Replay sends the same event again with the same id.
If every attempt to a webhook fails for five days, we turn it off and email the account's owners. Fix the receiving app, turn the webhook back on, press Send test, then replay anything you missed from the log.
Webhooks stop sending if the account moves to a plan below Pro. Nothing is deleted, and they start again after an upgrade.
Things to know
- Events can arrive out of order and, rarely, more than once. Use
idto spot repeats andcreated_atto order them. - Reviews posted more than a week before we find them are not sent, so connecting a new location doesn't flood your tools with its whole history.
- We only send to public internet addresses. Local and private network addresses are refused.
Still stuck? Contact our support team and we'll help you out, usually within one UK working day.