What signature verification does
Zonka Feedback can sign every webhook it sends. Your server checks the signature to confirm the request really came from Zonka Feedback, and that the data was not changed on the way.
Verifying is optional. We recommend it for any webhook that feeds a production system.
In this article, you will learn
Turn on signing
For a new webhook
Go to Settings → Developers → Webhooks → Add Webhook.
Fill in the name, event, target URL and survey.
Leave Sign this webhook switched on. It's on by default.
Click Add.
For an existing webhook
Click Edit on the webhook.
In the Signing section, click Enable signing.
Before you start
Signing is available for the response.added and partial.response.added events.
The target URL must start with https://.
You need the Developer Settings permission to manage signing.
Get your signing secret
Open the webhook with Edit.
In the Signing section, click the eye icon to show the secret, or the copy icon to copy it.
The secret looks like whsec_… and each webhook has its own.
Store it on your server the way you would a password, for example in an environment variable. Don't put it in your code or share it publicly.
What we send
Every webhook request includes these headers.
Header | Example | Meaning |
Zonka-Signature | sha256=IU6nIEe/HZf…5Ro= | The signature. Sent only when signing is on |
Zonka-Delivery-Id | ffc0feb6-be53-448a-… | Unique for each delivery attempt |
Zonka-Delivery-Timestamp | 1793334965 | When the request was sent, in Unix seconds |
Zonka-Event-Type | response.added | The event that triggered the webhook |
User-Agent | ZonkaFeedback-Webhooks/1.0 | Identifies Zonka Feedback |
Verify the signature
For every request:
Read the raw request body exactly as received, before any JSON parsing.
Compute an HMAC-SHA256 of the raw body, using your full secret (including whsec_) as the key.
Base64-encode the result and put sha256= in front of it.
Compare it with the Zonka-Signature header using a constant-time comparison. If they match, process the webhook. If they don't, reject it with 401.
expected = "sha256=" + base64( HMAC-SHA256( secret, rawBody ) )
Use the raw body. The most common reason verification fails is parsing the JSON and serialising it again before computing the signature. Even a single changed space or a different key order gives a different signature. Compute the signature on the bytes exactly as you received them.
Code samples
Node.js (Express)
const crypto = require("crypto");
app.post("/zonka-webhook", express.raw({ type: "application/json" }), (req, res) => {
// Sign the raw Buffer, never parsed JSON
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.ZONKA_WEBHOOK_SECRET)
.update(req.body)
.digest("base64");
const received = req.get("Zonka-Signature") || "";
const ok = received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// Process the event
res.status(200).end();
});
Python (Flask)
import base64, hashlib, hmac, os
from flask import request, abort
@app.post("/zonka-webhook")
def zonka_webhook():
raw = request.get_data()
expected = "sha256=" + base64.b64encode(
hmac.new(os.environ["ZONKA_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).digest()
).decode()
received = request.headers.get("Zonka-Signature", "")
if not hmac.compare_digest(received, expected):
abort(401)
event = request.get_json()
# Process the event
return "", 200
PHP
$raw = file_get_contents('php://input');
$expected = 'sha256=' . base64_encode(hash_hmac('sha256', $raw, getenv('ZONKA_WEBHOOK_SECRET'), true));
$received = $_SERVER['HTTP_ZONKA_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// Process the event
Rotate the secret
Replace a secret, for example if it was exposed:
Open the webhook with Edit.
In the Signing section, click the Rotate icon and confirm.
Rotation takes effect immediately. The new secret signs the very next request, and the old secret stops working straight away. There is no overlap period.
Have the new secret ready to deploy before you rotate.
Any webhook that arrives between rotating and updating your server will fail verification and be rejected. Failed deliveries are retried, so a short gap usually recovers on its own, but check your webhook logs afterwards to confirm nothing was lost.
Turn signing off
In the Signing section, click the Disable icon and confirm. This deletes the secret immediately, and requests are sent without a Zonka-Signature header.
If you turn signing on again later, you get a new secret.
Handle duplicate deliveries
Only the request body is signed. The other headers, including the timestamp, are not.
Retries. If a delivery fails, we retry it, and a retry has a new Zonka-Delivery-Id.
Replays. Someone who captured a request could send it again, and the signature would still be valid.
To protect against both, process each event only once, using the eventId field in the body. You can also ignore events whose receivedDate is older than you expect.
Troubleshooting
Problem | Likely cause |
The signature never matches | The body was parsed or re-serialised before hashing — use the raw bytes. Or the signature was hex-encoded instead of base64. Or whsec_ was left out of the key |
It fails only after a rotation | Your server is still using the old secret. Rotation takes effect immediately, so copy the new secret from the Signing section and deploy it. |
There's no Zonka-Signature header | Signing is off for this webhook, or the event isn't signed. Only response.added and partial.response.added are signed |
The header name isn't found | Some frameworks rename headers. PHP uses HTTP_ZONKA_SIGNATURE, and many frameworks lowercase them to zonka-signature |
