Verifying DSP Webhook Signatures
Every DSP webhook integration eventually confronts the same question: how do you prove that an inbound POST claiming to carry a royalty settlement actually originated from the digital service provider, and not from a replayed capture, a misconfigured test harness, or an attacker probing a public endpoint? Signature verification answers that question at the perimeter of Webhook Event Ingestion for Real-Time Royalty Events, before a single field of the payload is trusted enough to touch a payee balance. Getting the check itself right — the byte-for-byte comparison, the timing behavior, the tolerance for clock drift — is what separates a verification step that actually blocks forgery from one that only looks like it does.
Signature Verification Trade-offs
DSPs and payment providers do not standardize on one authentication scheme for webhooks, and the choice affects how much trust the ingestion layer can place in an event before reconciliation runs.
| Approach | How it verifies the sender | Operational overhead | Royalty Impact |
|---|---|---|---|
| HMAC shared secret (HMAC-SHA256) | Recipient recomputes a keyed hash over the raw body and compares it to a header value | Low: one secret per source, rotated periodically | Cheap and fast to verify per event, but a leaked secret lets an attacker forge settlement events until rotation completes |
| Asymmetric signature (e.g., Ed25519/RSA) | Sender signs with a private key; recipient verifies with a published public key | Medium: key distribution and periodic key-ID rotation, no shared secret to leak | Forgery requires the private key, not just recipient-side config, so a data breach on the ingestion side cannot itself produce valid signatures |
| mTLS | Both sides present certificates during the TLS handshake; the connection itself is authenticated | High: certificate issuance, renewal, and revocation infrastructure on both sides | Strong transport-level guarantee, but a misissued or expired certificate can cause a hard outage in settlement delivery rather than a soft validation failure |
| IP allowlist | Recipient accepts requests only from a known source IP range | Low to set up, high to maintain as providers rotate egress ranges | Weakest guarantee here — a compromised host inside the allowed range, or a stale range after a provider’s infrastructure change, can pass forged or silently drop legitimate events |
For most royalty ingestion pipelines, HMAC shared secret is the pragmatic default: it is what the majority of DSPs and aggregators actually offer, and it is cheap enough to verify on every event without adding measurable latency to the acknowledgment path. Asymmetric signatures and mTLS are worth the added key-management cost for high-value payment-provider integrations where the downstream action is an irreversible payout rather than a metadata update. IP allowlisting should never be the sole control on a royalty-relevant endpoint; treat it as a defense-in-depth layer alongside HMAC, not a replacement for it.
Prerequisites & Assumptions
The patterns below assume Python 3.11 or later, fastapi/starlette for the receiving endpoint, and the standard library hmac and hashlib modules, which implement the keyed-hash construction defined in RFC 2104 — no third-party cryptography package is required for HMAC-SHA256 verification. The DSP is assumed to send a per-request signature header (commonly X-Signature-256 or similar) computed over the exact bytes of the request body, plus a timestamp header used to bound replay. The shared secret is assumed to be provisioned through a secrets manager and readable by the ingestion service at startup, never hardcoded or committed to source control. pydantic>=2 is used for the validated event model once a payload passes verification.
Implementation
Step 1: Capture the Raw Body Before Any Parsing
HMAC verification must run against the exact bytes the DSP signed. If a framework’s automatic body parsing consumes the request stream and re-serializes it before your handler sees it, whitespace or key-ordering differences will break every signature check. Read the raw body explicitly and verify before touching request.json().
from fastapi import Request, HTTPException, status
async def get_verified_body(request: Request, shared_secret: bytes) -> bytes:
raw_body: bytes = await request.body()
signature_header = request.headers.get("X-Signature-256", "")
if not verify_hmac_signature(raw_body, signature_header, shared_secret):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid signature")
return raw_body
Step 2: Compute and Compare With a Constant-Time Check
A naive == comparison on the computed digest leaks timing information that an attacker can use to guess the correct signature one byte at a time. hmac.compare_digest runs in constant time regardless of where the strings first differ, closing that side channel.
import hmac
import hashlib
def verify_hmac_signature(raw_body: bytes, signature_header: str, shared_secret: bytes) -> bool:
expected = hmac.new(shared_secret, raw_body, hashlib.sha256).hexdigest()
provided = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, provided)
Step 3: Enforce a Timestamp Tolerance Window
A valid signature alone does not prevent replay: a captured request with a correct signature can be resent verbatim at any point in the future. Requiring a signed timestamp within a small tolerance window closes that gap, at the cost of rejecting events during severe clock drift between the DSP and the ingestion host.
import time
REPLAY_TOLERANCE_SECONDS = 300
def within_replay_tolerance(event_timestamp: int, now: int | None = None) -> bool:
now = now if now is not None else int(time.time())
return abs(now - event_timestamp) <= REPLAY_TOLERANCE_SECONDS
Step 4: Rotate Signing Keys Without Downtime
Secrets must be rotated periodically without a window where either the DSP or the ingestion side is signing or verifying with the wrong key. Supporting two active secrets, keyed by an ID the DSP includes in the request, lets rotation happen gradually.
def verify_with_rotation(
raw_body: bytes,
signature_header: str,
key_id: str,
secrets_by_key_id: dict[str, bytes],
) -> bool:
secret = secrets_by_key_id.get(key_id)
if secret is None:
return False
return verify_hmac_signature(raw_body, signature_header, secret)
Verification & Validation
Every verification attempt, successful or not, should produce an audit log entry that records the event_id, source_id, key_id, the pass/fail outcome, and the timestamp of the check — this is what lets a dispute over a settlement amount months later be traced back to whether the originating event was ever cryptographically confirmed. Track the verification failure rate per source_id as an operational metric: a sudden spike usually indicates a DSP-side key rotation that has not yet been mirrored on the ingestion side, rather than an actual attack. Once verified, the payload still passes through the same ISRC resolution and split-sum assertions as any other ingested event; a valid signature confirms authenticity, not correctness of the royalty data it carries.
Edge Cases & Gotchas
An upstream proxy or API gateway that decompresses gzip/br content encoding, normalizes trailing whitespace, or re-encodes the body before it reaches your handler will silently break every signature check, because the bytes verified are no longer the bytes the DSP signed — verify as close to the network edge as possible. Some DSPs sign a compact, minified JSON body while a test harness or client library sends pretty-printed JSON for the same logical payload; these will never match, so signature testing must use the exact wire format, not a reconstructed one. Clock drift between the DSP’s signing host and the ingestion host can push legitimate events outside the replay tolerance window during a rare NTP failure — log rejected-for-timestamp events separately from rejected-for-signature events so the two failure modes are not conflated during an incident. Finally, a retried delivery of the same event carries an unchanged signature but a fresh Date header in some provider implementations; the idempotency key used for deduplication should come from the payload’s own event_id, not from anything in the transport headers.
Related
A signature that fails verification, or a payload that verifies but arrives after retries have already exhausted, should route to the same dead-letter queue patterns used for other failed records rather than being silently discarded, preserving the raw payload for forensic review. This verification step is the first gate every event passes through on its way into Webhook Event Ingestion for Real-Time Royalty Events, and it should never be bypassed for convenience during development against a sandbox source.