Skip to main content

Webhook best practices

The short version: the producer signs every payload, gives each event a unique ID and versions its event types; the consumer verifies the signature on the raw body, returns a 2xx within a few seconds, processes the event in the background and skips event IDs it has already handled. The sections below cover each rule for both sides of the integration, the producer (sender) and the consumer (receiver), with Hook0's own formats where they apply.

Try it on your own endpoint

The free Hook0 Cloud plan signs and retries 100 events a day, with no credit card. Create an account, or send a signed request to Hook0 Play to inspect the headers your endpoint will receive.

For producers (sending webhooks)​

Sign every payload​

Every outgoing webhook should include an HMAC-SHA256 signature in the headers. This lets consumers verify the payload hasn't been tampered with. Include a timestamp in the signed string so that a captured request cannot be replayed later. Hook0's header carries the timestamp (t), the names of the signed headers (h) and the signature (v1):

X-Hook0-Signature: t=1765443663,h=content-type x-event-id,v1=85da0586ae0b711d...

Generate the signature from the raw request body using the shared secret. Never sign a re-serialized version of the payload; byte differences will break verification.

For a step-by-step implementation with code examples, see Webhook authentication tutorial. Hook0 generates HMAC signatures automatically for every delivery -- see Subscription secrets for where the signing key comes from.

Include an idempotency key​

Add a unique identifier to each event so consumers can deduplicate:

{
"event_id": "evt_01H8...",
"event_type": "invoice.paid",
"created_at": "2025-01-15T10:30:00Z",
"data": { }
}

Use UUIDs or ULIDs. The consumer stores seen event IDs and skips duplicates.

Design payloads for stability​

Keep payloads self-contained. Include the data the consumer needs rather than forcing them to make API calls back to you:

Good (self-contained):

{
"event_type": "order.shipped",
"data": {
"order_id": "ord_123",
"tracking_number": "1Z999AA10123456784",
"carrier": "ups",
"shipped_at": "2025-01-15T14:00:00Z"
}
}

Avoid (requires follow-up API call):

{
"event_type": "order.shipped",
"data": {
"order_id": "ord_123"
}
}

Version your webhooks​

When you change payload shapes, use event type versioning:

order.shipped.v1
order.shipped.v2

Give consumers time to migrate. Support old versions for at least 6 months after announcing deprecation.

For consumers (receiving webhooks)​

Verify signatures first​

Before processing any webhook, verify the HMAC signature. Reject requests with missing or invalid signatures immediately. For a Hook0 v1 signature, the signed string is the timestamp, the header names, the header values and the raw body, joined by dots:

import hmac
import hashlib

def verify_hook0_signature(raw_body: bytes, headers, secret: str, signature_header: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
names = parts.get("h", "")
if names:
values = ".".join(headers.get(n, "") for n in names.split(" "))
signed = f"{parts['t']}.{names}.{values}.".encode() + raw_body
else:
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))

Also reject requests whose t is too old (a few minutes) to block replays.

Use constant-time comparison (hmac.compare_digest) to prevent timing attacks. See Secure webhook endpoints for a more detailed walkthrough, and Hook0's security model for how signatures fit into the broader security architecture.

Respond fast, process later​

Return a 200 OK within 5 seconds. Do the actual processing asynchronously:

If your endpoint takes too long, the producer may time out and retry, leading to duplicate processing. See Client error handling for patterns on handling these edge cases.

Handle duplicates (idempotency)​

Webhooks can be delivered more than once. Track processed event IDs:

CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT NOW()
);

Before processing, check if the event ID exists. If it does, skip it.

Use HTTPS endpoints only​

Always expose webhook endpoints over HTTPS. This prevents payload interception and man-in-the-middle attacks. Hook0 accepts both http:// and https:// targets, so a plain-HTTP URL works for local testing; use HTTPS for every production subscription. See Subscriptions for how to configure your endpoints in Hook0.

Payload design guidelines​

Use a consistent envelope structure​

Every event should follow the same top-level structure:

{
"event_id": "evt_...",
"event_type": "resource.action",
"api_version": "2025-01-15",
"created_at": "2025-01-15T10:30:00Z",
"data": { }
}

Event type naming​

Use resource.action format with dot separators:

  • user.created
  • invoice.payment_succeeded
  • subscription.trial_ending

Be specific. user.updated is better than user.changed. invoice.payment_failed is better than invoice.error.

Hook0 uses a service.resource_type.verb convention for event types that we consider even better ! See Event types for how this works, and Event types & subscriptions tutorial for a hands-on setup guide.

Timestamp format​

Always use ISO 8601 with timezone: 2025-01-15T10:30:00Z. Never use Unix timestamps in the payload; they are harder to read when debugging.

Monitoring and alerting​

Metrics to track​

As a producer:

  • Delivery success rate (target: >99.5%)
  • P95 delivery latency
  • Retry rate per endpoint
  • Deliveries that exhausted their retries

As a consumer:

  • Processing success rate
  • Duplicate detection rate
  • Average processing time
  • Queue depth

Alerting thresholds​

Alert on:

  • Delivery success rate drops below 99%
  • Any endpoint has >5 consecutive failures
  • Events whose retries were exhausted without a success
  • Processing latency exceeds 30 seconds

See Monitor webhook performance for Hook0-specific monitoring setup.

Further reading​