# Guide to Foursquare Webhooks: Features and Best Practices

Foursquare webhooks tell your servers where your app's users are going: when someone arrives at or leaves a place, enters or exits a geofence, or makes progress on a journey. They come from Foursquare's Movement SDK (formerly the Pilgrim SDK), which detects these events on the user's device. Normally the SDK hands each event to your app, which forwards it to your backend. Server webhooks let Foursquare call your backend directly.

This guide covers how Movement SDK server webhooks are set up and authenticated, the three families of events and their payloads, what Foursquare leaves undocumented, and the best practices for running a receiver in production.

## What are Foursquare webhooks?

Foursquare's server webhooks belong to the Movement SDK, not the Places API. When the SDK in your mobile app detects a visit, a geofence event or a journey update, Foursquare sends an `application/json` `POST` with the event details to a server URL you configure for the project. The webhook fires in addition to the SDK's normal in-app callback and does not change how the SDK behaves.

Foursquare does not sign these requests. Each one includes your project's secret, as plain text, in a `Pilgrim-Secret` header, "so you know it's us". Your endpoint compares it with its stored copy.

## Foursquare webhook features

| Feature | Details |
| --- | --- |
| Configuration | Developer Console: project **Settings > Webhooks** under **Movement SDK** |
| Subscription | One server URL per project. Every arrival, departure and geofence event is sent |
| Authentication | `Pilgrim-Secret: <secret>`, sent raw |
| Where the secret lives | The **Pilgrim** page of the Developer Console |
| Body signature | None |
| Other headers | `Content-Type: application/json`, `User-Agent: Foursquare-PilgrimSDK/<version>` |
| Routing | The top-level `eventType` field |
| Timestamps | `timestamp` in milliseconds since the epoch |
| Retries and timeout | Not documented |
| IP allowlist | None published |

## Common events

Every body has a top-level `eventType`. There are three families:

| Family | `eventType` values | Family-specific fields |
| --- | --- | --- |
| Visits | `placeArrival`, `placeDeparture`, `placeHistorical` | `pilgrimVisitId`, `placeEvent`, and `segments` and `userState` if enabled |
| Geofences | `geofenceEnter`, `geofenceDwell`, `geofencePresence`, `geofenceExit` | `geofenceEvent` |
| Journeys | `journeysInProgress`, `journeysApproaching`, `journeysArrived`, `journeysCompleted`, `journeysCanceled` | `journeyId`, `status`, `startTime`, `lastUpdated`, `eta`, `path`, `confirmedArrival`, `venues`, `destination` |

Visit and geofence events share `timestamp`, `lat`, `lng`, `user` (custom user data, if you set it in the SDK), `installId`, `sdkType` (`iossdk` or `androidsdk`) and `sdkBuild`.

Here is Foursquare's `placeArrival` example, trimmed:

```json
{
  "pilgrimVisitId": "4c4763f7646e38002cc51789",
  "eventType": "placeArrival",
  "timestamp": 1548182419000,
  "placeEvent": {
    "venues": [
      {
        "id": "52af211911d2aa9d4a1f0e0a",
        "name": "Foursquare Chicago",
        "location": {
          "address": "20 W Kinzie St",
          "city": "Chicago",
          "state": "IL",
          "country": "US",
          "lat": 41.889264,
          "lng": -87.628911
        },
        "categories": [{ "id": "4bf58dd8d48988d125941735", "name": "Tech Startup", "primary": true }],
        "probability": 0.33320653250353993
      }
    ],
    "otherPossibleVenues": [{ "id": "577d3752498e0faef85a21c2", "name": "WeWork Kinzie" }]
  }
}

```

`placeEvent.venues` holds the matched venue, and `otherPossibleVenues` lists alternatives. Foursquare's field tables also describe `arrivalTime`, an optional `departureTime`, a `confidence` level and a `locationType` (`venue`, `home`, `work`, `none` or `unknown`).

Geofence events carry a `geofenceEvent` object with the geofence's ID, radius, location, properties and matching venue. Note that it has its own nested `eventType`. Foursquare's table says it holds values like `geofenceEnter`, but every published example shows `entrance`, `dwell`, `presence` or `exit` there. Route on the top-level `eventType`, which is consistent.

Foursquare publishes no example for journey events, only a field table.

## Setting up Foursquare webhooks

1. In the Foursquare Developer Console, open the project and go to the **Pilgrim** page. Copy the secret and store it server-side, for example as `FOURSQUARE_PILGRIM_SECRET`.
2. From the Developer Console home page, select the project. On its **Settings** page, click **Webhooks** under the **Movement SDK** section.
3. On the **Movement SDK Webhook** page, enter your server URL and click **Save**.

There is no per-event selection in the documentation: once the URL is set, Foursquare sends every arrival, departure and geofence event.

If you receive Foursquare webhooks through Hookdeck, enter the secret from the Pilgrim page in the Hookdeck source.

## Securing Foursquare webhooks

Read `Pilgrim-Secret`, compare it with your stored secret in constant time, and reject the request if it is missing or different. Fail closed if the secret is not configured.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyPilgrimSecret(headerValue, secret) {
  if (typeof headerValue !== 'string' || !secret) return false;
  const received = Buffer.from(headerValue, 'utf8');
  const expected = Buffer.from(secret, 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/foursquare', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyPilgrimSecret(req.get('pilgrim-secret'), process.env.FOURSQUARE_PILGRIM_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.eventType, then hand off to a queue.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_pilgrim_secret(header_value, secret) -> bool:
    if not header_value or not secret:
        return False
    return hmac.compare_digest(header_value.encode("utf-8"), secret.encode("utf-8"))

# verify_pilgrim_secret(request.headers.get("pilgrim-secret"), os.environ["FOURSQUARE_PILGRIM_SECRET"])

```

These payloads describe where a person is. Keep the endpoint on HTTPS, keep the secret out of logs, and store payloads with the same care as any other location data.

> **Make Foursquare webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `Pilgrim-Secret` header on every request, keeps each visit and geofence event in a durable queue, and lets you replay any of them into your services.

## Foursquare webhook limitations and pain points

### A static secret instead of a signature

**The Problem:** Anyone who learns the secret can send fabricated visits, and nothing protects the body in transit beyond TLS.

**Why It Happens:** Foursquare sends the same secret with every request rather than signing each body.

**Workarounds:**

* Use HTTPS and never log the `Pilgrim-Secret` header.
* Check that `installId` belongs to an install you know about before acting on an event.

**How Hookdeck Can Help:** Hookdeck checks the header on every request and rejects any that fail with a `VERIFICATION_FAILED` cause, so forged requests never become events for your services.

### No documented delivery behaviour

**The Problem:** If your server is down during a busy period, you cannot tell which visits were lost.

**Why It Happens:** Foursquare documents no timeout, success criterion or retry policy for server webhooks.

**Workarounds:**

* Return `200` as soon as the secret checks out, and process events in the background.
* Keep the SDK's in-app callback wired to your backend as a fallback for events you cannot afford to miss.

**How Hookdeck Can Help:** Hookdeck accepts each request as soon as it arrives and stores it, then delivers to your service with [automatic retries](/docs/retries), so your downtime does not drop location events.

### Geofence events have no ID

**The Problem:** A repeated geofence delivery cannot be told apart from a new event.

**Why It Happens:** Visits carry `pilgrimVisitId` and journeys carry `journeyId`, but geofence bodies have no unique identifier.

**Workarounds:**

* For geofences, build a key from `installId`, `geofenceEvent.geofenceId`, the top-level `eventType` and `timestamp`.
* For visits, key on `pilgrimVisitId` with `eventType`. In Foursquare's examples, an arrival and its departure carry the same `pilgrimVisitId`.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries based on the body fields you choose.

### Everything goes to one URL

**The Problem:** Analytics, notifications and CRM updates all need different subsets of events, but Foursquare sends everything to one URL.

**Why It Happens:** Each project has a single server webhook URL with no event selection.

**Workarounds:**

* Route inside your handler on `eventType` and fan out to internal queues.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can send visits, geofences and journeys to separate destinations from one Foursquare source, without code changes when you add a consumer.

## Best practices

### Route on the top-level `eventType`

It is the documented discriminator for all three families. Ignore `geofenceEvent.eventType` for routing, because its documented and example values disagree.

### Acknowledge fast and process asynchronously

Return `200` once the secret matches and the event is stored. Venue lookups, notifications and analytics can run afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Use `pilgrimVisitId` with `eventType` for visits and `journeyId` with `status` for journeys, and build a composite key for geofences. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Keep the units straight

`timestamp` is in milliseconds since the epoch, and coordinates are separate `lat` and `lng` fields.

## Conclusion

Foursquare's Movement SDK server webhooks send every visit, geofence and journey event for a project to one URL, unsigned, with the project secret in the `Pilgrim-Secret` header. Compare that header in constant time, route on the top-level `eventType`, build your own key for geofence events, and acknowledge quickly, because Foursquare documents no retries.

[Hookdeck Event Gateway](https://hookdeck.com) checks the `Pilgrim-Secret` header, stores every event, and routes each family to the service that needs it, so an outage on your side does not lose location data.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Foursquare webhooks reliably in minutes.