Skip to content
Back to site

Receive HubSpot webhooks reliably

HubSpot posts batches of CRM events to a Railhook source. The source verifies the v3 signature, stores each batch as it arrived and forwards it to your service with retries.

HubSpot retries a failed delivery up to 10 times across 24 hours, with exponential backoff and some randomisation so a mass failure does not re-fire all at once. It retries on a connection failure, a timeout and most error statuses, but not on 400, 403 or 404, which it treats as permanent. (HubSpot: Updated webhook retry logic)

A route that is briefly missing during a deploy answers 404, and HubSpot writes the event off on the first try. With Railhook in front:

  • HubSpot always reaches a URL that exists and answers immediately, so a 404 from your app is never HubSpot’s answer.
  • Your service gets up to 5 attempts per forward, with waits from 1 minute to 1 hour, and what still fails lands in Failed Forwards.
  • Anything stored can be replayed after you fix the route.

You need a Railhook project and an API key. The same steps are available under Incoming in the dashboard.

Terminal window
export RAILHOOK_URL=https://railhook.io # or your own instance
export RAILHOOK_API_KEY=...
export PROJECT_ID=...
  1. Create the Railhook source to get the URL HubSpot should post to:

    Terminal window
    curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources" \
    -H "X-API-Key: $RAILHOOK_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name":"HubSpot","providerType":"HUBSPOT","verificationMode":"NONE"}'

    Keep the id and the ingressUrl from the response.

  2. Point your app’s webhooks at it. In a project-built app, webhooks are configuration in the app’s source rather than a form: add a webhooks directory under src/app/ with a *-hsmeta.json file whose settings.targetUrl is the ingressUrl, and list what you want under subscriptions. Non-HTTPS URLs are rejected. (HubSpot: Configure webhooks)

  3. Find the client secret. HubSpot signs with your app’s client secret, not a per-webhook key. In your developer account it is under Development → Projects → your project, on the app’s Auth tab, in Client credentials. (HubSpot: Manage apps in HubSpot)

  4. Give Railhook the secret and turn verification on:

    Terminal window
    curl -X PUT "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources/$SOURCE_ID" \
    -H "X-API-Key: $RAILHOOK_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name":"HubSpot","verificationMode":"PROVIDER","hmacSecret":"'"$HUBSPOT_CLIENT_SECRET"'"}'
  5. Add a destination, the URL of your own service:

    Terminal window
    curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources/$SOURCE_ID/destinations" \
    -H "X-API-Key: $RAILHOOK_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url":"https://api.example.com/webhooks/hubspot","authType":"BEARER","authConfig":"{\"token\":\"...\"}","enabled":true}'
  6. Trigger a subscribed event (create a contact, change a property) in a portal where the app is installed. It appears under Incoming → Received, with one forward per destination.

HubSpot v3 sends X-HubSpot-Signature-v3: a base64 HMAC-SHA256 of the request method, the full URL, the raw body and the timestamp, concatenated in that order with no separators, keyed with the client secret. X-HubSpot-Request-Timestamp carries that timestamp in milliseconds, and HubSpot says to refuse anything older than five minutes, which Railhook does. (HubSpot: Validate requests)

A request that does not verify, or that arrives more than five minutes late, is answered 401 and is not stored.

A HubSpot request is a JSON array of events, each with its own eventId and an attemptNumber that counts up across retries. There is no id for the batch, so Railhook records no provider event id for a HubSpot source and does not deduplicate its requests: keying a batch on its first event would drop the rest of it if HubSpot re-cut the batch on a retry.

Deduplicate in your own handler, on eventId together with portalId. The signed five-minute window and the replay cache are what stop a captured request being replayed at Railhook’s door; they are not a substitute for an idempotent handler.

The body exactly as HubSpot sent it, byte for byte: the array, not one event per request. The signature headers are not forwarded: your service authenticates Railhook through the destination’s credentials, and the v3 signature covers the ingress URL, so it would not verify against yours. See Destinations.

  1. Install the CLI and log in, as in CLI, then open a tunnel to your app’s port:

    Terminal window
    railhook listen 3000
  2. Add a second destination to the source with the printed public URL and your route appended, such as https://<host>/tunnel/<slug>/webhooks/hubspot. It reaches http://localhost:3000/webhooks/hubspot.

  3. Change a contact in a test portal and the batch arrives in your local app. Disable that destination when you stop the tunnel.

Each replayed batch goes to every destination of the source as a new forward:

Terminal window
curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-events/bulk-replay" \
-H "X-API-Key: $RAILHOOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sourceId":"'"$SOURCE_ID"'","from":"2026-09-18T10:00:00Z","to":"2026-09-18T14:00:00Z","verified":true}'

Replay reaches back as far as events are kept: 7 days on Railhook Cloud, and whatever you configure when self-hosting. An event your app answered 404 never reached HubSpot’s retry logic, because Railhook answered, so the batch is still here to replay. See Replay.