Webhooks¶
merchants provides utilities for verifying webhook signatures and parsing normalised webhook events from payment providers.
Signature Verification¶
Use verify_signature to confirm that a webhook came from your provider using HMAC-SHA256 constant-time comparison. This prevents timing attacks.
import merchants
try:
merchants.verify_signature(
payload=request.body, # raw bytes
secret="whsec_…",
signature=request.headers["Stripe-Signature"],
)
except merchants.WebhookVerificationError:
# Reject the request — signature is invalid
return 400
Never skip signature verification
Without verification, anyone who knows your webhook endpoint URL can send fake payment-success notifications. Always verify before processing.
Parameters¶
| Parameter | Type | Description |
|---|---|---|
payload |
bytes |
Raw request body |
secret |
str |
Webhook secret from your provider dashboard |
signature |
str |
Signature header value from the incoming request |
WebhookVerificationError¶
Raised when the computed HMAC does not match the provided signature:
from merchants import WebhookVerificationError
try:
merchants.verify_signature(payload, secret, signature)
except WebhookVerificationError as e:
print(e) # "invalid webhook signature"
Event Parsing¶
Use parse_event to parse a raw webhook payload into a normalised WebhookEvent:
import merchants
event = merchants.parse_event(request.body, provider="stripe")
print(event.event_type) # e.g. "payment_intent.succeeded"
print(event.state) # e.g. PaymentState.SUCCEEDED
print(event.payment_id) # e.g. "pi_3LHpu2…"
print(event.provider) # "stripe"
Best-effort parsing
parse_event does a best-effort conversion of the raw payload to a WebhookEvent. Unknown event types or unrecognised status strings result in state=PaymentState.UNKNOWN. Check event.raw for the full original payload.
Parameters¶
| Parameter | Type | Description |
|---|---|---|
payload |
bytes |
Raw webhook body |
provider |
str |
Provider key (e.g. "stripe", "paypal") |
WebhookEvent Fields¶
| Field | Type | Description |
|---|---|---|
event_id |
str \| None |
Provider-assigned event ID |
event_type |
str |
Provider-specific event type string |
payment_id |
str \| None |
Associated payment ID |
state |
PaymentState |
Normalised payment state |
provider |
str |
Provider key |
raw |
dict |
Original parsed payload for provider-specific fields |
Full Webhook Handler Example¶
Here is a complete webhook handler using Django-style pseudo-code:
import merchants
from merchants import WebhookVerificationError
def webhook_view(request):
# 1. Verify signature
try:
merchants.verify_signature(
payload=request.body,
secret=settings.STRIPE_WEBHOOK_SECRET,
signature=request.headers.get("Stripe-Signature", ""),
)
except WebhookVerificationError:
return HttpResponse(status=400)
# 2. Parse the event
event = merchants.parse_event(request.body, provider="stripe")
# 3. Handle by event type or state
if event.state == merchants.PaymentState.SUCCEEDED:
fulfill_order(event.payment_id)
elif event.state == merchants.PaymentState.FAILED:
notify_failure(event.payment_id)
return HttpResponse(status=200)
Khipu Webhook Verification¶
Khipu v3.0 uses a different signature scheme. Use verify_khipu_signature for the x-khipu-signature header:
from merchants.webhooks import verify_khipu_signature, WebhookVerificationError
try:
timestamp = verify_khipu_signature(
payload=request.body,
secret="YOUR_WEBHOOK_SECRET",
header_value=request.headers["x-khipu-signature"],
)
# timestamp is the unix millisecond string from the header (for replay-attack checks)
except WebhookVerificationError:
return 400
The header format is t=<unix_ms>,s=<base64_signature>. The signed message is "<timestamp>.<body>".
Use KhipuProvider for integrated verification
When using KhipuProvider with webhook_secret set, parse_webhook calls verify_khipu_signature automatically before parsing the event. You do not need to call it manually.
Using Provider's parse_webhook¶
For provider-specific parsing (including state maps defined in the provider), use the provider's own method via the client:
# Access the provider directly if needed
provider = client._provider
event = provider.parse_webhook(request.body, dict(request.headers))
Use event.raw for provider-specific fields
The WebhookEvent.raw field holds the original parsed payload. Access it when you need provider-specific fields that are not covered by the normalised model.