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.
Why HubSpot webhooks need a buffer
Section titled “Why HubSpot webhooks need a buffer”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
404from 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.
Connect HubSpot
Section titled “Connect HubSpot”You need a Railhook project and an API key. The same steps are available under Incoming in the dashboard.
export RAILHOOK_URL=https://railhook.io # or your own instanceexport RAILHOOK_API_KEY=...export PROJECT_ID=...-
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
idand theingressUrlfrom the response. -
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
webhooksdirectory undersrc/app/with a*-hsmeta.jsonfile whosesettings.targetUrlis theingressUrl, and list what you want undersubscriptions. Non-HTTPS URLs are rejected. (HubSpot: Configure webhooks) -
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)
-
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"'"}' -
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}' -
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.
How the signature is checked
Section titled “How the signature is checked”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.
Deduplicate per event, not per request
Section titled “Deduplicate per event, not per request”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.
What your service receives
Section titled “What your service receives”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.
Develop locally with the CLI
Section titled “Develop locally with the CLI”-
Install the CLI and log in, as in CLI, then open a tunnel to your app’s port:
Terminal window railhook listen 3000 -
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 reacheshttp://localhost:3000/webhooks/hubspot. -
Change a contact in a test portal and the batch arrives in your local app. Disable that destination when you stop the tunnel.
Replay after an outage
Section titled “Replay after an outage”Each replayed batch goes to every destination of the source as a new forward:
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.