Receive webhooks
Verify Voidhash webhook signatures by hand and handle events in any language.
Voidhash signs every webhook request with your endpoint's signing secret. Verify the signature before you trust a request: anyone who knows your URL can send it a forged event. This page verifies with your language's HMAC-SHA256 and constant-time comparison functions; the Node.js, PHP, Go and Rust libraries do it for you.
Create an endpoint
In Studio, open Settings → Webhooks, select New webhook, and enter your endpoint's public
HTTPS URL, its environment and the events it receives. Studio shows the signing secret
(whsec_…) once; store it in your secret manager, here as VOIDHASH_WEBHOOK_SECRET. See
Endpoints for every field.
The signature scheme
Each request carries two headers for verification:
X-Webhook-Timestamp: the Unix time, in seconds, when the attempt was signed.X-Webhook-Signature:v1=followed by a lowercase hex HMAC-SHA256 digest.
The digest covers the string {timestamp}.{body}: the timestamp header's value, a period, and the
raw request body exactly as received. The HMAC key is the full secret string, whsec_ prefix
included, encoded as UTF-8.
v1 names the scheme. Reject signatures with any other prefix.
Verify a request
- Read the raw request body as bytes, before any JSON parser touches it. Re-serialized JSON differs from what Voidhash signed.
- Read
X-Webhook-TimestampandX-Webhook-Signature. Reject the request if either is missing or sent more than once. - Check that the timestamp is within 300 seconds of your server's clock, in either direction. This stops an attacker from replaying a captured request later.
- Compute HMAC-SHA256 over
{timestamp}.{body}with your secret, and hex-encode it. - Compare
v1={digest}with the signature header using a constant-time comparison. - If any check fails, answer
400and do nothing else.
Every retry is signed again with a new timestamp, so a retried delivery passes the timestamp check.
Example in Python
This Flask handler verifies a request with the standard library:
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["VOIDHASH_WEBHOOK_SECRET"] # whsec_...
TOLERANCE_SECONDS = 300
@app.post("/webhooks/voidhash")
def voidhash_webhook():
body = request.get_data() # raw bytes
timestamp = request.headers.get("X-Webhook-Timestamp", "")
signature = request.headers.get("X-Webhook-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
abort(400)
digest = hmac.new(
SECRET.encode("utf-8"),
timestamp.encode("utf-8") + b"." + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(f"v1={digest}", signature):
abort(400)
event_type = request.headers["X-Webhook-Event"]
payload = json.loads(body)
# Store the event, then process it after answering.
return "", 200Handle events
Read the event type from the X-Webhook-Event header. Every payload except test.ping repeats it
in its type field. Webhook events lists every type and payload field.
- Answer first. Answer any
2xxwithin 30 seconds: store the event, answer200, and do the work afterwards. A redirect, a4xxor5xx, a timeout or a connection error is a failed attempt. - Skip duplicates. Delivery is at least once, and payloads carry no event ID. Build an
idempotency key from
type, the subject's ID (subscriptionId,purchaseIdorpersonId) andoccurredAt(erasedAtforperson.deleted). Record each key and skip ones you have seen. - Don't rely on order. Compare
occurredAtbefore you overwrite newer state, or check access for the current answer. - Accept unknown types. New event types may be added. Answer
200for types you don't handle, includingtest.ping.
Retries
A failed delivery is retried 5 minutes, 30 minutes, 2 hours and 24 hours after the attempt before
it, then marked exhausted. After 20 consecutive failed attempts Voidhash sets the endpoint to
Failed and stops sending to it. See Retries and the
delivery log.
Test locally
Voidhash can't reach localhost, so expose your local server through a tunnel such as
ngrok http 3000 or cloudflared tunnel --url http://localhost:3000, and create a Test
endpoint with the tunnel's HTTPS URL. Then:
- Select Send test in the endpoint's actions menu, or run
voidhash webhook test <webhook>, which waits for your server's answer and prints it. A test only goes to an endpoint that is Active. - Make a sandbox or simulated purchase to receive real lifecycle events. See Test purchases (Help center).
Troubleshooting
- Every signature fails. Your framework parsed the body before you verified it, or you are using another endpoint's secret. Each endpoint has its own secret, and rotating replaces it at once.
- Signatures fail only sometimes. Check your server's clock. Drift beyond 300 seconds rejects valid requests; keep clocks synced with NTP.
- A proxy rewrites the body. Some proxies re-encode JSON or change line endings. Verify against the bytes your application receives, and turn off body rewriting on the webhook route.
Next steps
- Webhook events: every event type and its payload.
- Webhook delivery: environments, retries and the delivery log.
- Check access
Something wrong or missing on this page? Tell us by email or on Discord.