Help Centre

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.

Last reviewed October 20269 min read

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

  1. Go to Developers > Webhooks in the dashboard and select Add webhook.
  2. Paste the URL your tool gave you and, if you like, give it a name such as "New reviews to our CRM".
  3. Choose the events. Every event also sends events we add later; or tick only the ones you want.
  4. 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.
  5. Select Add webhook and copy the signing secret. It starts with whsec_ and is shown once only.
  6. 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

EventWhen it is sent
review.createdA 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.negativeA new review of 1 or 2 stars. Sent as well as review.created, so you can route these on their own.
scan.completedA geo-grid scan finished (Google Maps, Google Search or AI Mode), manual or scheduled.
geo_grid.movedA 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.changedProfile 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.createdA 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.plannedA 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.completedA 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.testSent 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 as id in 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 of id.timestamp.body, made with your secret (the part after whsec_, 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 id to spot repeats and created_at to 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.
Was this helpful?

Still stuck? Contact our support team and we'll help you out, usually within one UK working day.