Skip to main content

Webhook Signature Verification

Verify that a webhook really came from Zonka Feedback using your signing secret and the Zonka-Signature header. Includes Node, Python and PHP samples.

Written by Nitish Walia

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

  1. Go to Settings → Developers → Webhooks → Add Webhook.

  2. Fill in the name, event, target URL and survey.

  3. Leave Sign this webhook switched on. It's on by default.

  4. Click Add.

For an existing webhook

  1. Click Edit on the webhook.

  2. 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

  1. Open the webhook with Edit.

  2. 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:

  1. Read the raw request body exactly as received, before any JSON parsing.

  2. Compute an HMAC-SHA256 of the raw body, using your full secret (including whsec_) as the key.

  3. Base64-encode the result and put sha256= in front of it.

  4. 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:

  1. Open the webhook with Edit.

  2. 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

Did this answer your question?