Skip to content

Integrations

Webhooks

We send a POST to your server when a collection ends, so your code doesn't have to keep asking whether it's done.

Which plans have them

Webhooks come with Starter, Pro, and Scale. On Free, every webhook call answers 402 PLAN_GATE and we send nothing. details.upgradeTo names the plan to move to: starter. Change your plan in the console.

Without webhooks, your code can read a collection with GET /v1/collections/{id} until it ends, or follow its live progress.

Add an endpoint

An endpoint is the address on your server that we POST to. Add one with this call, or on the Webhooks page in the console:

curl -X POST https://api.gmaps.dev/v1/webhooks \
  -H "Authorization: Bearer $GMAPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/gmaps" }'

The answer holds secret, which starts with whsec_. This is the only time we show it. Store it with your other secrets: it's how your server checks that a request came from us.

Two more fields are optional. events picks which events to send, and leaving it out sends both. description is a note for you, such as which of your services this is.

What the URL needs

We check the URL when you add it, and again before every send, because the address behind a name can change:

  • It starts with https://. We never send your results over a connection that isn't encrypted.
  • Its name points to a public address. Private networks and your own machine, such as localhost, are refused. To test from your laptop, use a tool that gives it a public https address.
  • It has no username or password in it. The signature is what proves a request came from us.
  • It isn't already one of your endpoints. Adding the same URL twice answers 409 CONFLICT.
  • It's the final address. We don't follow redirects.

Events

EventWhen we send it
collection.completedA collection ended with status succeeded or partial. Collections explains both. Its counts are zero when the search found nothing.
collection.failedA collection ended without any places, because its searches couldn't run. It cost nothing. data.error says why and what to do.
pingOnly when you send a test.

A collection you cancel sends nothing: you already know it stopped.

What we send

A small JSON body with counts and a link, never the places themselves. A collection.completed looks like this:

{
  "id": "3f6c2a9e-1b7d-4e58-8a21-6d0f4b9c2e75",
  "timestamp": "2026-09-24T14:05:12.345Z",
  "type": "collection.completed",
  "data": {
    "collection": {
      "id": "8c1f5e2a-7b3d-4f60-9e14-2a6d0b9c7f31",
      "status": "succeeded",
      "stopReason": null
    },
    "counts": {
      "placesDelivered": 212,
      "placesFresh": 180,
      "placesCached": 32,
      "creditsCharged": 180,
      "creditsRefunded": 0
    },
    "resultUrl": "https://api.gmaps.dev/v1/collections/8c1f5e2a-7b3d-4f60-9e14-2a6d0b9c7f31/places"
  }
}
  • id: the event's own id. It stays the same on every retry.
  • type and timestamp: which event it is, and when it happened.
  • data.collection: the collection's id and status, and stopReason, which says why it stopped early or found nothing. It is null when the collection searched the whole area or reached its maxPlaces. Collections explains each reason.
  • data.counts: the places the collection handed over and the credits it charged, as on the collection itself. Collections explains each count.
  • data.resultUrl: where to read the places, a page at a time, with your API key.
  • A collection.failed carries data.error in place of counts and a link, with the same code, message and messageKey as any API error.

Every request also carries these headers:

HeaderWhat it holds
content-typeapplication/json
x-gmaps-signatureWhen we sent it, and the signature. See below.
x-gmaps-deliveryThe event's id again, so you can spot a repeat before you read the body.
user-agentgmaps.dev webhooks

Check the signature

Anyone who finds your URL can send a request to it. The signature proves a request came from us. It's in the x-gmaps-signature header:

x-gmaps-signature: t=1790000000,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
  • t is when we sent it, in Unix seconds.
  • v1 is an HMAC-SHA256 of t, a dot and the raw body. It's made with your whole secret, whsec_ included, and written in hex. An HMAC is a code that only someone who holds the secret can make.

To check it, make the same HMAC and compare the two. Refuse the request if they differ, or if t is more than 5 minutes away from your clock, so nobody can send an old request again.

Use the raw body: the exact bytes you received. If you parse the JSON and turn it back into text, the bytes change and the check fails.

The SDK's verifyWebhook does all of this:

import { verifyWebhook } from "gmaps-sdk";

const body = await request.text();
const signed = await verifyWebhook(
  process.env.GMAPS_WEBHOOK_SECRET,
  body,
  request.headers.get("x-gmaps-signature"),
);
if (!signed) return new Response(null, { status: 400 });
const event = JSON.parse(body);

Without the SDK, it takes a few lines in any language. Here it is in Python:

import hashlib, hmac, time

def verify(secret: str, body: bytes, header: str | None) -> bool:
    if not header:
        return False
    parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    try:
        sent = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - sent) > 300:
        return False
    signed = f"{sent}.".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Answers and retries

Answer with any 2xx status as soon as the request arrives, and do the slow work afterwards. Anything else counts as a failure:

  • A 5xx, 408, 425, 429, a timeout or a failed connection: we try again, up to 5 tries in all. The usual wait starts at 30 seconds and doubles after each failed attempt. A longer Retry-After header sets that attempt's wait instead, capped at one hour.
  • Any other 4xx: we don't try again. Your server said no, and it would say no again.
  • A redirect: we don't follow it, and we don't try again. The same goes for a domain that doesn't point to any address, or points to a private network. Point the endpoint at your server's final, public address.

Because of retries, the same event can arrive more than once. Keep the ids you've handled and skip repeats.

When we stop sending

If 10 deliveries in a row fail, we turn the endpoint off. Each counts once, when we stop retrying it. You can see that in two places:

  • The Webhooks page in the console shows the endpoint as off, and says why.
  • GET /v1/webhooks answers with enabled: false, and disabledReason says why in English, with the last error. disabledReasonKey is the same reason as a message key, and disabledReasonParams fills its blanks, so a program can show it in its reader's language.

While it's off, the events it misses aren't kept, so they never arrive. To catch up, list your collections with GET /v1/collections.

Fix your server, then turn the endpoint back on. Its failure count starts again from zero:

curl -X POST https://api.gmaps.dev/v1/webhooks/4d7a1c3e-9b2f-4e8a-b6d1-0c5f7e2a9b34 \
  -H "Authorization: Bearer $GMAPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'

The count also goes back to zero each time a delivery works.

Send a test

Before a real collection depends on it, send a ping event. It shows whether your code reads the request and whether the signature checks out:

curl -X POST https://api.gmaps.dev/v1/webhooks/4d7a1c3e-9b2f-4e8a-b6d1-0c5f7e2a9b34/test \
  -H "Authorization: Bearer $GMAPS_API_KEY"
  • It answers at once with the delivery, usually still pending. The test is signed and retried like a real event.
  • Send message in the body to put your own words in the event, so you can spot it in your logs.
  • An endpoint that is off can't be tested: the call answers 409 CONFLICT. Turn it on first.

See what we sent

The delivery log lists what we sent to one endpoint, newest first. Look here when an event never showed up:

curl https://api.gmaps.dev/v1/webhooks/4d7a1c3e-9b2f-4e8a-b6d1-0c5f7e2a9b34/deliveries \
  -H "Authorization: Bearer $GMAPS_API_KEY"
  • status: pending while we're still trying, then delivered or failed.
  • attempts: how many tries it has taken.
  • responseStatus: the status your server answered, or null if it never answered.
  • error: what went wrong and how to fix it, in English. errorKey is the same sentence as a message key, and errorParams fills its blanks, so a program can show it in its reader's language.

We keep the log for 7 days. limit sets how many deliveries come back.

Change or delete an endpoint

POST /v1/webhooks/{id} changes events, description or enabled. Send only what you want to change: the rest stays as it was.

  • To pause an endpoint, set enabled to false. It keeps its secret, so your code doesn't change when you turn it back on.
  • DELETE /v1/webhooks/{id} removes the endpoint and its delivery log. You can't undo it.
  • The URL and the secret can't be changed. To get new ones, delete the endpoint and add it again.