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:
{
"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
- 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 - From the Developer Console home page, select the project. On its Settings page, click Webhooks under the Movement SDK section.
- 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.
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:
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 checks the
Pilgrim-Secretheader 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-Secretheader. - Check that
installIdbelongs 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
200as 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, 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-leveleventTypeandtimestamp. - For visits, key on
pilgrimVisitIdwitheventType. In Foursquare's examples, an arrival and its departure carry the samepilgrimVisitId.
How Hookdeck Can Help: Hookdeck's 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
eventTypeand fan out to internal queues.
How Hookdeck Can Help: Hookdeck 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.
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.
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 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 for free and handle Foursquare webhooks reliably in minutes.