What webhooks do
Handled calls a URL you choose when something changes: a location is created, updated or deleted, or a visitor sends an enquiry through the locator. Your system receives the full record, signed, within seconds, and does not have to poll the Location API on a schedule to find out.
Webhooks are available on the Pro and Business plans, the same plans as the API. They are managed by an account owner under Settings → API & developer tools → Webhooks, or over the API.
Events
location.createdwhen a location is added by hand, restored from the bin, or created by a CSV import, a Google Sheets sync or the API.location.updatedwhen any field changes, including tags, custom fields, visibility, the photo and the hours.location.deletedwhen a location is moved to the bin. Restoring it fireslocation.created.lead.createdwhen a visitor sends an enquiry, after it has been routed to the dealer whose territory covers the address.pingcannot be subscribed to. Every endpoint receives one when it is created and whenever you press Send test, so you can prove the receiver before anything real fires.
Add an endpoint
- Open Settings → API & developer tools and choose the Webhooks tab.
- Enter the HTTPS URL of your receiver and untick any events you do not want.
- Press Add endpoint. A ping is queued at once. Open Deliveries on the new row to see whether your receiver answered 2xx.
- Press Show secret and copy the signing secret into your receiver's configuration.
An account can have ten endpoints. A URL must be public and use HTTPS; loopback and private addresses are refused because nothing here can reach them.
The payload
Every delivery is a JSON POST with the same envelope. The data object carries a location in the shape the Location API returns, or a lead.
{
"id": "5f1c2e4a-…",
"event": "location.updated",
"createdAt": "2026-09-19T14:02:11.418Z",
"accountId": "c063eac6-…",
"data": {
"location": {
"id": "8d2b…",
"name": "Northlake Sleep Co Duluth",
"address": "1200 Superior St, Duluth, MN 55802",
"lat": 46.786,
"lng": -92.1,
"phone": "+1 218 555 0140",
"categoryIds": ["…"],
"customFields": { "Book a fitting": "https://…" },
"visibility": "visible",
"priority": 0
}
}
}{
"id": "…",
"event": "lead.created",
"createdAt": "…",
"accountId": "…",
"data": {
"lead": {
"id": "…",
"name": "Dana Reyes",
"email": "dana@example.com",
"phone": null,
"message": "Do you deliver to Rochester?",
"searchAddress": "Rochester, MN",
"postcode": "55901",
"lat": 44.02,
"lng": -92.47,
"locationId": "…",
"territoryId": "…",
"routedBy": "territory"
}
}
}Three headers travel with it: X-Handled-Event (the event name), X-Handled-Delivery (the delivery id, the same as id in the body) and X-Handled-Signature.
Verify the signature
The signature header is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" under the endpoint's secret. Check it against the raw request body, before parsing, and reject anything whose timestamp is more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Retries and delivery order
A delivery that does not get a 2xx is retried after 1 minute, then 5, 15, 60, 180, 360, 720 and 1440 minutes, eight attempts over roughly two days. After that it is marked failed and stays in the endpoint's delivery log for thirty days.
Twenty-five consecutive failures switch the endpoint off, so a dead URL stops costing retries. Fix the receiver and press Resume. A success at any point resets the count.
Deliveries are not guaranteed to arrive in order. Two edits to one location a second apart may reach you reversed if the first is retried. Use createdAt in the body, or re-read the location from the API, when order matters.
Each delivery is sent at least once. Keep the delivery id and ignore a repeat.
Manage endpoints over the API
The same endpoints can be created and removed with a write key, which is how a Zap or an integration subscribes itself without anyone opening the dashboard.
# List
curl -H "Authorization: Bearer $HANDLED_KEY" https://app.handledlocal.com/api/v1/webhooks
# Create. events accepts an array, a comma list, or "*" for everything.
curl -X POST -H "Authorization: Bearer $HANDLED_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/handled","events":["lead.created"],"description":"CRM"}' \
https://app.handledlocal.com/api/v1/webhooks
# Send a ping
curl -X POST -H "Authorization: Bearer $HANDLED_KEY" https://app.handledlocal.com/api/v1/webhooks/<id>
# Remove
curl -X DELETE -H "Authorization: Bearer $HANDLED_KEY" https://app.handledlocal.com/api/v1/webhooks/<id>The create and list responses include the signing secret, since a subscriber needs it to verify what it receives.
What is not sent
Visitor searches, map moves and clicks stay in the browser as widget events and in analytics; they are not webhooks. Changes to settings, labels, tags themselves or territories do not fire either. A sync from Google Sheets fires one event per location it created or changed, not one for the run.