Every webhook request includes three headers used for signature verification:
Header
Description
svix-id
Unique message identifier
svix-timestamp
Unix timestamp of the message
svix-signature
HMAC signature
Use the official Svix library to verify signatures. To find your signing secret, go to Developers, click on a webhook endpoint, and copy the Signing Secret on the right side. It starts with whsec_.
You must use the raw request body when verifying webhooks. The cryptographic signature is sensitive to even the slightest changes. Watch out for frameworks that parse the request as JSON and then stringify it, as this will break the signature verification.
JavaScript
Python
PHP
Go
Ruby
Copy
import { Webhook } from 'svix';const secret = process.env.ZENO_WEBHOOK_SECRET; // whsec_...const payload = rawBody; // raw request body as stringconst headers = { 'svix-id': request.headers.get('svix-id'), 'svix-timestamp': request.headers.get('svix-timestamp'), 'svix-signature': request.headers.get('svix-signature'),};const wh = new Webhook(secret);try { const msg = wh.verify(payload, headers); const { type, data } = msg; switch (type) { case 'checkout.completed': // Payment successful — fulfill the order console.log(`Order ${data.orderId} completed`); break; case 'checkout.expired': // Checkout expired — notify the customer or cancel the order console.log(`Order ${data.orderId} expired`); break; }} catch (err) { // Signature verification failed console.error('Invalid webhook signature');}
Copy
from svix.webhooks import Webhook, WebhookVerificationErrorimport ossecret = os.environ.get('ZENO_WEBHOOK_SECRET') # whsec_...payload = raw_body # raw request body, not parsed JSONheaders = request_headerswh = Webhook(secret)try: msg = wh.verify(payload, headers)except WebhookVerificationError: # Signature verification failed raise Exception('Invalid webhook signature')event_type = msg['type']if event_type == 'checkout.completed': # Payment successful — fulfill the order print(f"Order {msg['data']['orderId']} completed")elif event_type == 'checkout.expired': # Checkout expired — notify the customer or cancel the order print(f"Order {msg['data']['orderId']} expired")
Copy
use Svix\Webhook;use Svix\Exception\WebhookVerificationException;$secret = getenv('ZENO_WEBHOOK_SECRET'); // whsec_...$payload = file_get_contents('php://input'); // raw body$headers = array_change_key_case(getallheaders(), CASE_LOWER);try { $wh = new Webhook($secret); $msg = $wh->verify($payload, $headers); switch ($msg['type']) { case 'checkout.completed': // Payment successful — fulfill the order fulfillOrder($msg['data']['orderId']); break; case 'checkout.expired': // Checkout expired — notify the customer or cancel the order handleExpiredCheckout($msg['data']['orderId']); break; } http_response_code(200);} catch (WebhookVerificationException $e) { // Signature verification failed http_response_code(400);}
Copy
import ( "encoding/json" "io" "net/http" "os" svix "github.com/svix/svix-webhooks/go")secret := os.Getenv("ZENO_WEBHOOK_SECRET") // whsec_...payload, _ := io.ReadAll(r.Body) // raw bodywh, _ := svix.NewWebhook(secret)err := wh.Verify(payload, r.Header)if err != nil { // Signature verification failed w.WriteHeader(http.StatusBadRequest) return}var event struct { Type string `json:"type"` Data json.RawMessage `json:"data"`}json.Unmarshal(payload, &event)switch event.Type {case "checkout.completed": // Payment successful — fulfill the ordercase "checkout.expired": // Checkout expired — notify the customer or cancel the order}w.WriteHeader(http.StatusOK)
Copy
require 'svix'require 'json'secret = ENV['ZENO_WEBHOOK_SECRET'] # whsec_...payload = raw_body # raw request bodyheaders = request_headerswh = Svix::Webhook.new(secret)begin msg = wh.verify(payload, headers) case msg['type'] when 'checkout.completed' # Payment successful — fulfill the order puts "Order #{msg['data']['orderId']} completed" when 'checkout.expired' # Checkout expired — notify the customer or cancel the order puts "Order #{msg['data']['orderId']} expired" endrescue # Signature verification failed puts 'Invalid webhook signature'end
Always verify the signature before processing the event. Never trust the payload without verification. You must use the raw request body — do not parse the JSON before verifying.
For framework-specific examples (Express, Next.js, Django, Flask, Laravel, Rails, etc.), see the Svix verification docs.
If Zeno Bank does not receive a 2xx response from your webhook endpoint, we will retry the delivery.Each message is attempted based on the following schedule, where each period starts after the failure of the preceding attempt:
5 seconds
5 minutes
30 minutes
2 hours
5 hours
10 hours
Respond with any 2xx status code to acknowledge the event. You can monitor delivery attempts and manually retry from the Developers section in the dashboard.
What are the delivery guarantees?
Zeno Bank webhooks provide at-least-once delivery. Every event will be delivered to your endpoint at least once, but may be delivered more than once in rare cases (such as network timeouts where your server processed the event but the acknowledgement was lost).To handle duplicates, use the svix-id header included with every webhook request. This is a unique identifier for each event delivery. Store processed svix-id values and skip any duplicates.
Can I retry webhook events manually?
Yes. Go to Developers in the dashboard, click on your webhook endpoint, find the event you want to retry, and click the replay button to resend it.
Do I need to set up webhooks if I use a plugin?
No. If you use one of our official plugins (WooCommerce, PrestaShop, WHMCS, etc.), webhooks are configured automatically. You don’t need to set up endpoints or verify signatures manually.