The webhook integration playbook sounds simple: receive a POST, verify the signature, process the event, return 200. What the docs don't tell you is that each provider makes slightly different choices about content type, batching, retry behavior, and signature algorithms — choices that quietly break generic webhook handlers and cause subtle production bugs.
This isn't a complaint about any particular provider. These are reasonable engineering tradeoffs. But they're tradeoffs you need to know about before you discover them at 2am when your webhook handler is dropping events.
Here's what we've learned integrating with Twilio, SendGrid, PagerDuty, and HubSpot.
Twilio: Form-Encoded by Default, URL-Tied Signatures
The first thing that trips people up with Twilio is the content type. Voice and SMS webhooks — StatusCallback, MessageStatusCallback, incoming message handlers — arrive as application/x-www-form-urlencoded, not JSON.
POST /webhooks/twilio HTTP/1.1
Content-Type: application/x-www-form-urlencoded
MessageSid=SM1234&MessageStatus=delivered&To=%2B15005550006&From=%2B15005550001You can't json.Unmarshal this. You need r.ParseForm() in Go or req.body after express.urlencoded() in Node. The mistake is usually a generic webhook router that assumes JSON and silently fails to decode anything.
The signature verification scheme is also unusual. Twilio's X-Twilio-Signature is an HMAC-SHA1 (yes, SHA1) of your full request URL concatenated with the sorted, concatenated POST parameters. The exact URL matters — including https:// vs http://, trailing slashes, and query strings.
func verifyTwilioSignature(authToken, url, signature string, params url.Values) bool {
// Sort parameter keys, concatenate key+value pairs
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
sb.WriteString(url)
for _, k := range keys {
sb.WriteString(k)
sb.WriteString(params.Get(k))
}
mac := hmac.New(sha1.New, []byte(authToken))
mac.Write([]byte(sb.String()))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}The practical implication: if you put Twilio webhooks behind a reverse proxy that rewrites the URL or strips query parameters, signature validation will break. Configure your proxy to pass the original Host header and reconstruct the full URL faithfully.
Twilio retries for up to 4 hours with increasing delays if you return anything other than a 2xx. This is generous but means you'll see duplicate deliveries if your handler is slow or intermittently unavailable. Make your handlers idempotent on MessageSid.
SendGrid: Batched JSON Arrays and ECDSA Signatures
SendGrid's Event Webhook delivers events in batches — a single POST body is a JSON array containing up to 1,000 events. Your handler needs to iterate the array, not just handle a single object.
[
{
"email": "user@example.com",
"timestamp": 1712000000,
"event": "delivered",
"sg_event_id": "abc123",
"sg_message_id": "msg456"
},
{
"email": "user@example.com",
"timestamp": 1712000001,
"event": "open",
"sg_event_id": "def789",
"sg_message_id": "msg456"
}
]If you process events serially in your handler and even one event takes too long, you'll exhaust the response timeout and SendGrid will retry the entire batch — including the events you already processed. Process the batch fast (write to a queue, then return 200) or make every operation in the batch idempotent on sg_event_id.
The signature scheme is the other surprise: SendGrid uses ECDSA with P-256 and SHA-256, not HMAC. The public key is provided in your SendGrid dashboard settings. The headers are X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp.
| Header | Value |
|---|---|
X-Twilio-Email-Event-Webhook-Signature | Base64-encoded ECDSA signature over timestamp + body |
X-Twilio-Email-Event-Webhook-Timestamp | Unix timestamp as a string |
Because it's asymmetric, you only need the public key to verify — you can't accidentally leak the signing secret. But your verification code needs an ECDSA library, not just crypto/hmac.
Also worth knowing: SendGrid fires events for both transactional (API/SMTP) and marketing (campaigns) emails, but they come through the same endpoint with different event types. If you have separate processing logic for transactional vs marketing, you'll need to route on the presence of fields like sg_template_id or marketing_campaign_id.
PagerDuty: Per-Message Arrays with HMAC-SHA256
PagerDuty's v3 webhook schema wraps events in a messages array, similar to SendGrid's batching model. Each message has a event object and a webhook metadata block identifying which subscription triggered the delivery.
{
"messages": [
{
"id": "01ABCDEF",
"event": {
"id": "01234567",
"event_type": "pagertduty.incident.triggered",
"occurred_at": "2026-04-01T09:15:00.000Z",
"agent": { "html_url": "...", "id": "...", "type": "service_reference" },
"client": null,
"data": { ... }
},
"webhook": {
"endpoint_url": "https://your-endpoint.example.com",
"name": "Production Alerts",
"description": "",
"webhook_subscription_id": "WXYZ123",
"type": "webhook_subscription"
}
}
]
}Signature verification is HMAC-SHA256 over the raw request body, with the signature in X-PagerDuty-Signature. The value is prefixed with v1= — similar to Stripe's format. If you already handle Stripe-style signatures, PagerDuty slots in cleanly.
PagerDuty webhook subscriptions can be scoped at three levels: a specific service, a team, or the entire account. Account-level subscriptions receive everything, which can be a high volume of events in a large org. Be specific with your scope unless you genuinely need account-wide visibility.
One behavior that catches teams off guard: PagerDuty will pause delivery and eventually disable a webhook subscription if your endpoint returns 5xx or times out too many times. There's no automatic re-enable. You need to re-enable it from the dashboard or API after fixing your endpoint.
HubSpot: Versioned Signatures and Sub-Second Expectations
HubSpot's CRM webhooks have two signature versions, and they work very differently.
v1 signs only the request body. v3 signs method + URL + body + timestamp, which prevents replay attacks. HubSpot expects you to migrate to v3, but v1 is still in use on older integrations.
// v3 verification
func verifyHubSpotV3(secret, method, url, body, timestamp, signature string) bool {
sourceString := method + url + body + timestamp
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(sourceString))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}The X-HubSpot-Signature-Version header tells you which version was used. Check it before verifying — using the v3 algorithm on a v1 payload will always fail.
HubSpot also has an unusually strict expectation on response time: their documentation recommends your endpoint responds within 1 second. In practice they allow a few seconds, but if you're doing anything non-trivial in the handler path — database writes, third-party calls — you'll want to queue the payload and return 200 immediately.
Like the others, HubSpot batches CRM events. The payload is a JSON array. Each object has a subscriptionType field identifying the event (contact.creation, deal.propertyChange, etc.) and an objectId for the CRM object.
Practical Advice: Normalize at the Edge
If you're routing webhooks from multiple providers through the same infrastructure, the worst thing you can do is handle these quirks in application code scattered across your codebase. Instead, normalize at the edge:
| Provider | Content-Type | Batched? | Signature Algorithm |
|---|---|---|---|
| Twilio (SMS/Voice) | application/x-www-form-urlencoded | No | HMAC-SHA1 over URL + params |
| SendGrid | application/json | Yes (array) | ECDSA P-256 |
| PagerDuty | application/json | Yes (messages[]) | HMAC-SHA256, v1= prefix |
| HubSpot | application/json | Yes (array) | HMAC-SHA256 (v1 or v3) |
A dedicated inbound gateway — whether GetHook or something you build yourself — handles verification per-provider and emits normalized internal events. Your application code never sees the raw provider payload or needs to know whether an event arrived as form data or a JSON array.
If you're building that gateway yourself, budget more time than you expect for each provider. The documentation describes the happy path; the quirks only surface when you test with real traffic.
Ready to stop writing per-provider adapters? GetHook handles ingest, signature verification, and delivery — so your application code works with clean, normalized events regardless of which provider sent them.