Strava webhook “callback url not verifiable” — how to pass the validation

You POST to https://www.strava.com/api/v3/push_subscriptions and get back:

{"message":"Bad Request","errors":[
  {"resource":"PushSubscription","field":"callback url","code":"not verifiable"}]}

Strava's docs say the most common cause of subscription-creation failure is “a failure to respond in a timely manner to the validation GET request, or failure to correctly echo the hub.challenge field.” That's the whole game: when you POST the creation request, Strava immediately sends a GET to your callback_url and grades your answer. Here's what it sends, what it expects, and the five ways handlers get it wrong.

What the validation GET looks like

GET https://your-callback?hub.verify_token=STRAVA&hub.challenge=15f7d1a91c1f40f8a748fd134752feb3&hub.mode=subscribe

Your endpoint must respond within two seconds with status 200 and echo the challenge as JSON, content type application/json:

{ "hub.challenge": "15f7d1a91c1f40f8a748fd134752feb3" }

The JSON key is the literal string "hub.challenge" — dot included. Pass, and your original POST returns {"id": 1} with your subscription id. Fail, and you get the 400 above.

Cause 1: you echoed like Meta — Strava wants JSON

Strava's handshake looks exactly like Meta's hub.challenge check — same hub.mode/hub.challenge/hub.verify_token query params — so people port their WhatsApp/Messenger handler. But Meta wants the raw challenge string as the response body; Strava wants a JSON object with a "hub.challenge" key. A plain-text echo that passes Meta fails Strava, and vice versa. Related trap: echoing hub.verify_token instead of hub.challenge — the token is there for you to check the request really came from Strava's subscription service (it's the verify_token you chose in the creation POST); it is not what you send back.

Cause 2: your route only answers POST

Events arrive as POSTs, so many handlers register a POST-only route. The validation is a GET to the same URL — a 404/405 on GET means “not verifiable” every time, even though your event handling is perfect.

Cause 3: too slow — the deadline is two seconds

Cold-starting serverless functions, spinning up a tunnel, or doing any real work before responding can eat the entire two-second budget. Answer the GET first; do everything else after.

Cause 4: the creation POST itself is malformed

The subscription-creation parameters must be sent as HTTP form data (-F/urlencoded — not a JSON body): client_id, client_secret, callback_url (max 255 characters), and your chosen verify_token. And note each application may only have one subscription — to change the callback URL, view the existing one (GET /push_subscriptions?client_id=…&client_secret=…), DELETE it, then re-create.

Cause 5: not publicly reachable

localhost, private IPs, or a broken TLS chain — Strava's subscription service has to reach the URL from the internet. For local development, see webhooks to localhost.

Pass it with a capture bin — zero code

A Hookden bin can pass Strava's validation before you've written any handler, so you can watch real event payloads first:

  1. Create a bin (button below), open Settings, and set: status 200, content type application/json, response body:
    {"hub.challenge":"{{query.hub.challenge}}"}
  2. Create the subscription against the bin:
    curl -X POST https://www.strava.com/api/v3/push_subscriptions \
      -F client_id=YOUR_ID \
      -F client_secret=YOUR_SECRET \
      -F callback_url=https://hookden.pages.dev/h/YOUR-BIN \
      -F verify_token=PICK-ANYTHING
  3. The bin answers the validation GET within the deadline (and captures it, so you can see exactly what Strava sent — including the hub.verify_token echo). Ride, rename an activity, and the event POSTs appear live.

Want to see what the event POSTs look like before you subscribe? The Strava activity update payload example shows a realistic sample body — and one click loads it into a live bin.

This is the fourth handshake style we've catalogued: Slack echoes a POST body field, Zoom wants an HMAC of the token, Meta wants a raw GET echo — and Strava wants the GET echo wrapped in JSON.

After validation: the events are unsigned

Strava event deliveries carry no signature header at all — the verify_token only appears in the validation GET, never on events. Treat the payload as a pointer, not a fact: it contains object_type (activity/athlete), object_id, aspect_type (create/update/delete), an updates hash, owner_id, subscription_id and event_time — then you fetch the real object from the API with your access token, which is your integrity check. Notable semantics, straight from the docs: updates keys for activity updates are title, type, private; an athlete deauthorizing your app arrives as an athlete event with "updates": {"authorized": "false"}; and with only activity:read scope, an athlete flipping an activity to “Only You” reaches you as a delete event (and back as a create) — respect it. One “save” by the athlete can also fan out into multiple events, because some activity attributes update asynchronously.

Retries

Each event POST must be acknowledged with a 200 within two seconds; failed pushes are retried up to a total of three attempts, then the event is gone — do your processing async. Full comparison in the retry-schedule table across 26 providers.

Related: Meta's hub.challenge check (the plain-text sibling), Twitch EventSub's challenge, and webhooks to localhost for developing the handler.

No signup needed. Or from your terminal: curl https://hookden.pages.dev/new

← All guides · Signature debugger · Payload examples · Docs · Hookden vs webhook.site