Webhooks
There are two independent webhook systems, and it helps to keep them straight:
- Inbound — Stripe calls the app so HubSpot stays current in real time.
- Outbound — the app calls your servers so you can react to syncs.
Inbound: Stripe → app #
Once Stripe is connected, the app receives Stripe events and applies each change to HubSpot as it happens — the real-time layer described in Running syncs. There are two endpoints, depending on how you connected Stripe:
| Mode | Endpoint | Org resolution | Signing secret |
|---|---|---|---|
| Stripe Connect | POST /api/webhooks/stripe |
from event.account |
platform secret |
| Direct key | POST /api/webhooks/stripe/:connectionId |
the connection | that connection’s own secret |
You don’t normally configure these by hand — connecting Stripe wires them up.
Signature verification #
Every inbound request is verified against the relevant Stripe signing secret
using the raw request body. A request that fails verification is rejected with
400 and never touches HubSpot.
Deduplication #
Events are deduplicated by their Stripe event id. The first delivery wins; any duplicate redelivery is recorded and skipped, so reprocessing is safe.
Deletions #
Deletion events apply the per-object When deleted in Stripe policy. With the
default Keep policy, the synced HubSpot record and its mapping are left
untouched. If you set the policy to Archive, the record is archived; if you
set it to Mark, it’s tombstoned in place. For example, how customer.deleted
treats the synced Company/Contact — and price.deleted the synced Product —
follows whichever policy you’ve chosen.
Suspended organizations still record inbound events for audit, but no HubSpot writes are performed until the organization is reactivated.
Outbound: app → your servers #
Register your own HTTPS endpoints and the app will POST a signed JSON event each time something happens. Manage endpoints on the Webhooks screen or via the API.
POST /v1/webhook-endpoints
Authorization: Bearer sk_test_...
Content-Type: application/json
{ "url": "https://example.com/hooks", "events": ["record.synced", "sync.completed"] }
List endpoints with GET /v1/webhook-endpoints and remove one with
DELETE /v1/webhook-endpoints/:id. Use ["*"] to subscribe to every event type.
Event types #
| Event | When | Payload data |
|---|---|---|
record.synced |
a single record was synced | object, stripeId, hubspotId, eventType |
sync.completed |
a backfill or reconcile run finished | the run summary |
Each delivery body has this envelope:
{
"id": "whd_...",
"type": "record.synced",
"created": "2026-06-25T12:00:00.000Z",
"data": { "object": "customer", "stripeId": "cus_123", "hubspotId": "...", "eventType": "customer.updated" }
}
Verifying the signature #
Every delivery is signed with HMAC-SHA256 over the timestamped raw body. Two headers accompany the request:
| Header | Value |
|---|---|
X-Webhook-Signature |
t=<unix_ts>,v1=<hmac_hex> |
X-Webhook-Event |
the event type, e.g. record.synced |
To verify, recompute the HMAC of "<t>.<raw_body>" using your endpoint’s secret
and compare it to the v1 value with a constant-time comparison:
parse t and v1 from X-Webhook-Signature
expected = hex( HMAC_SHA256( endpoint_secret, t + "." + raw_request_body ) )
valid = constant_time_equals(expected, v1)
Always verify against the raw body bytes, before any JSON parsing.
Retries and the delivery log #
Deliveries are made by a durable worker with a 10-second timeout. A non-2xx
response (or a network error) is retried with exponential backoff, up to 5
attempts, after which the delivery is marked failed. Each attempt records the
HTTP status and the last error.
Inspect outbound history — status, attempts, and response codes — with:
GET /v1/webhook-deliveries
Authorization: Bearer sk_test_...
Related #
- Running syncs — backfill, real-time, and reconcile.
- API & authentication — keys, scopes, and conventions.