What webhooks are
A webhook is how QR Walrus tells another app that something happened. When someone sends a form on your page, scans one of your codes or adds a guest photo, we send the details straight away to an address you choose: a web address from Zapier, Make or your own server. Technically it's a POST request with JSON. That app can then add a row to a spreadsheet, message your team, create a contact in your CRM, or anything else it can do.
- 1
Something happens
A form is sent, a code is scanned or a guest photo is added.
- 2
We send it
To your address, within seconds, signed so it can be checked.
- 3
Your app acts
Adds a row, sends a message, updates your CRM.
Plan and where to find it
Webhooks come with the Pro, Business and Enterprise plans. On other plans the Integrations screen says Webhooks are part of the Pro plan
, with a link to the plans.
There are two ways in:
- From the sidebar: open Integrations (under Account) and click Add webhook, or Add your first webhook if you have none yet.
- From a page's leads: in Pages, open the page's ⋯ menu and choose Leads. In the Send leads to other apps box, click Add a webhook for this page (or Add another). The same window opens with Form sent on a page ticked and that page chosen.
The Add a webhook window asks for:
- Address (https): the address from Zapier, Make or your server, like
https://hooks.zapier.com/hooks/catch/…. - Name (optional): so you can tell your webhooks apart, like Leads to Google Sheets.
- Send these events: tick one or more (see the next section).
- Pages or QR codes: All pages and All codes unless you pick one.
Click Add webhook, then Send a test on the webhook's card to check it works. The card also shows its status, its events, its signing secret, when it last sent something, and Recent deliveries. Its ⋯ menu has Edit, New signing secret, Turn off and Remove.
The three events
Each webhook only gets the events you tick for it. Every event arrives in the same wrapper, shown in full in each example below.
Form sent on a pagelead.created
Can be narrowed to one page
Sent when a visitor sends a form on one of your pages: an enquiry, a booking, a sign-up. Inside data.lead:
fields: each answer under its label on your form, likefields.Phone. This is the easy one to use in Zapier and Make.answers: the same answers as a list, with each field'sfieldIdandtype. The id doesn't change if you rename the label.page: which page, andform.path: where on it (/for the main page, or a page inside it like/book).source.qrId: the QR code the visitor scanned to get there, ornullif they came another way.
Every answer is text, even numbers and dates (a date looks like 2026-11-02; a tick box is Yes or No). Fields the visitor left empty aren't included.
Example payload
{
"id": "evt_Qm8xT2vLk9PzR4aN",
"type": "lead.created",
"created": "2026-10-25T09:41:07.512Z",
"version": 1,
"workspaceId": "ws_4f9a1c7e2b8d3a6f0e51",
"data": {
"lead": {
"id": "lead_1842",
"createdAt": "2026-10-25T09:41:07.498Z",
"page": {
"id": "pg_7c3e9a1f4b2d8e",
"name": "Spice Route Café",
"url": "https://spiceroute.qrwalrus.page"
},
"form": {
"blockId": "bq4k7m2x",
"path": "/book"
},
"fields": {
"Name": "Asha Rao",
"Phone": "+91 98450 12345",
"Date": "2026-11-02"
},
"answers": [
{
"fieldId": "fa8k2p1q",
"label": "Name",
"type": "text",
"value": "Asha Rao"
},
{
"fieldId": "fz3m9w4e",
"label": "Phone",
"type": "phone",
"value": "+91 98450 12345"
},
{
"fieldId": "fd6t1y8u",
"label": "Date",
"type": "date",
"value": "2026-11-02"
}
],
"source": {
"qrId": "Xk29dPq7LmZa"
}
}
}
}QR code scannedscan.created
Can be narrowed to one QR code
Sent when one of your dynamic codes is scanned. Each webhook gets at most 120 scans a minute: extra ones in that minute are skipped, and the webhook's card counts them (scan events skipped
). The scans themselves still count in QR Walrus as usual.
No IP address and no precise location are sent: only the country (a two-letter code like IN) and city. Also inside data.scan:
qr: which code;device: mobile, tablet or desktop;os,browserandlanguage.repeat:truewhen the same phone scanned this code a moment ago.rule: the smart rule that chose where the scan went, ornull;served: what kind of content it opened.referrerandutm: usuallynullfor a camera scan.
Example payload
{
"id": "evt_Lw4pZ8cRt2NkX7vB",
"type": "scan.created",
"created": "2026-10-25T12:05:33.871Z",
"version": 1,
"workspaceId": "ws_4f9a1c7e2b8d3a6f0e51",
"data": {
"scan": {
"qr": {
"id": "Xk29dPq7LmZa",
"name": "Table tent",
"contentType": "page"
},
"scannedAt": "2026-10-25T12:05:33.860Z",
"repeat": false,
"device": "mobile",
"os": "Android",
"browser": "Chrome",
"country": "IN",
"city": "Pune",
"language": "en",
"referrer": null,
"rule": null,
"served": "page",
"utm": null
}
}
}Guest photo addedguest_photo.submitted
Can be narrowed to one page
Sent when a visitor adds a photo to a Guest photos block. It waits for your approval: status is pending, and reviewUrl opens the screen where you approve it. The photo only shows on your page after you do.
Inside data.photo you also get the photo's url, its width and height, and uploaderName if the visitor typed a name (otherwise null).
Example payload
{
"id": "evt_Gt6yH1mQs9DfJ3wP",
"type": "guest_photo.submitted",
"created": "2026-10-25T18:22:41.230Z",
"version": 1,
"workspaceId": "ws_4f9a1c7e2b8d3a6f0e51",
"data": {
"photo": {
"id": "photo_311",
"submittedAt": "2026-10-25T18:22:41.214Z",
"status": "pending",
"page": {
"id": "pg_2b8d5f0a9c1e7d",
"name": "Riya & Kabir's wedding",
"url": "https://riyakabir.qrwalrus.page"
},
"blockId": "bw7e2r5t",
"url": "https://ik.imagekit.io/qrwalrus/page-uploads/pg_2b8d5f0a9c1e7d/guest_Lq2wX9.jpg",
"width": 1600,
"height": 1200,
"uploaderName": "Meera",
"reviewUrl": "https://www.qrwalrus.com/dashboard/pages/pg_2b8d5f0a9c1e7d/photos"
}
}
}The wrapper every event comes in
id: the event, likeevt_Qm8x…. It's the same for every webhook that gets the event, and stays the same on retries, so use it to skip repeats.type: which event.created: when it happened, in UTC.version:1. It only changes if a field is ever removed or changes meaning. New fields can appear at any time, so ignore ones you don't use.workspaceId: your workspace.data: the event itself.
Fields can arrive in a different order than in these examples. Read them by name.
The test event
Send a test sends a ping event to that one webhook, whatever events it's set to. Use it to check the address works and to see the wrapper; it shows up in the delivery log too. It has no form fields, so to set up field mapping in Zapier or Make, send one real form as well.
{
"id": "evt_Hc3nW7sKp1VbY6dE",
"type": "ping",
"created": "2026-10-25T09:30:12.004Z",
"version": 1,
"workspaceId": "ws_4f9a1c7e2b8d3a6f0e51",
"data": {
"endpoint": "wh_Zx81LmQp4TnA",
"message": "This is a test event from QR Walrus. Your endpoint is set up correctly."
}
}Only one page, or one code
When you tick Form sent on a page or Guest photo added, a Pages list appears: keep All pages, or pick Only one page. When you tick QR code scanned, a QR codes list does the same for codes. The card then says One page only
or One code only
.
The page you pick applies only to form and photo events, and the code only to scans. So one webhook can take one page's leads and every scan, or every lead and one code's scans.
Set up with Zapier
Zapier needs a paid plan for this
Webhooks by Zapier, the Zapier app that receives our events, needs a paid Zapier plan (Professional, from about $19.99 a month). New Zapier accounts get a 14-day trial. If you'd rather not pay, Make's free plan works.
- In Zapier, create a new Zap. For the trigger, search for Webhooks by Zapier and choose the event Catch Hook. Click Continue, leaving the optional settings empty.
- Zapier shows your webhook URL, starting with
https://hooks.zapier.com/hooks/catch/. Copy it. - In QR Walrus, open Integrations → Add webhook, paste it into Address, tick the events and click Add webhook.
- Click Send a test on the new webhook. You should see
Test delivered (200)
. - Back in Zapier, click Test trigger. It finds the test event (type
ping). - For your form's fields to show up, send one real form on your page yourself (fill in every field), then test the trigger again in Zapier and pick that request.
- Add the action (Google Sheets, Gmail, Slack, your CRM…) and map the fields. Form answers are under
data.lead.fields, which Zapier shows as names like Data Lead Fields Phone. Then publish the Zap.
Tip: make one webhook per Zap and tick only the event that Zap handles, so a scan never lands in your leads sheet.
Set up with Make
Make's free plan works with webhooks.
- In Make, create a new scenario. Click the big + and choose Webhooks → Custom webhook.
- Click Create a webhook (or Add), name it (like QR Walrus leads) and save. Make shows its address, starting with
https://hook.. Click Copy address to clipboard. - In QR Walrus, open Integrations → Add webhook, paste it into Address, tick the events and click Add webhook.
- While Make is waiting for data, click Send a test in QR Walrus. Make says it successfully determined the data structure: it has learned what our events look like.
- The test has no form fields. For Make to learn yours, open the webhook module, click Redetermine data structure, then send one real form on your page (fill in every field). Now
data.lead.fieldsand each field in it are ready to map. - Add the next module (Google Sheets, Gmail, Slack, a CRM…), map the fields, save, and switch the scenario on so it runs as soon as data arrives.
Leads into Google Sheets
Every form sent on your page becomes a new row. This uses Make, which is free for this:
- In Google Sheets, make a sheet with headings in the first row, like Date, Name, Phone, Message: one per form field you want to keep.
- In QR Walrus, open the page's Leads screen and click Add a webhook for this page (or use Integrations → Add webhook and tick only Form sent on a page).
- In Make, set up a Custom webhook as in Set up with Make, paste its address into QR Walrus, and send one real form so Make learns your fields.
- Add a Google Sheets module after it and choose Add a Row. Connect your Google account, pick the spreadsheet and the sheet, and say the table has headers.
- Map each column: Date to
data.lead.createdAt, Name todata.lead.fields.Name, Phone todata.lead.fields.Phone, and so on. - Save, switch the scenario on, and send another form to see the row appear.
- Fields are named after their labels on your form. If you rename a field, map it again.
- A field the visitor left empty isn't sent, so its cell stays blank.
- Times are in UTC (they end in Z).
- Zapier works too, on a paid Zapier plan: Catch Hook as the trigger, then Google Sheets Create Spreadsheet Row.
Your own server
For developers: we send a POST with a JSON body to your https address. Answer with any 2xx status within 5 seconds; we don't read the body of your answer. Do anything slow (sending email, calling other services) after you answer, or put it on a queue.
Headers
Content-Type
application/json
The body is JSON.
User-Agent
QRWalrus-Webhooks/1.0
Who is calling.
X-QRWalrus-Event
lead.created
The event, the same as type in the body.
X-QRWalrus-Delivery
dlv_5821
This delivery to this address. It stays the same when the delivery is retried or resent.
X-QRWalrus-Signature
t=1792921267,v1=37954c241d9ba3…
When we signed it (Unix seconds) and the signature: hex HMAC-SHA256 of the timestamp, a dot and the raw body.
Check that it came from us
- Read the raw body exactly as it arrived, before any JSON parsing.
- Split
X-QRWalrus-Signatureat the comma intot(a Unix timestamp in seconds) andv1. - Compute HMAC-SHA256 of
<t>.<raw body>(the timestamp, a dot, then the body), keyed with the webhook's signing secret, the whole thing includingwhsec_. Write it as lowercase hex. - Compare it with v1 using a constant-time comparison.
- Reject timestamps more than 5 minutes old. Every try is signed afresh, so retries pass this check.
- Skip an event whose
idyou've already handled: a retry after a timeout can arrive twice.
Node.js (Express)
// server.mjs: npm install express, then node server.mjs
// Run it behind HTTPS on your domain: we only send to https:// addresses on port 443.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.QRWALRUS_WEBHOOK_SECRET; // the whole whsec_… value
const seen = new Set(); // event ids already handled; keep them in your database in production
function isGenuine(rawBody, header) {
const parts = {};
for (const piece of (header || "").split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!parts.t || !Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // more than 5 minutes old
const expected = crypto.createHmac("sha256", SECRET).update(parts.t + ".").update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function handle(event) {
if (event.type === "lead.created") console.log("New lead:", event.data.lead.fields);
else if (event.type === "scan.created") console.log("Scan:", event.data.scan.qr.name, event.data.scan.city);
else if (event.type === "guest_photo.submitted") console.log("Photo to approve:", event.data.photo.reviewUrl);
else if (event.type === "ping") console.log("Test event received");
}
const app = express();
// express.raw keeps the exact bytes we signed. Don't put express.json() in front of this route.
app.post("/webhooks/qrwalrus", express.raw({ type: "application/json" }), (req, res) => {
if (!Buffer.isBuffer(req.body) || !isGenuine(req.body, req.get("X-QRWalrus-Signature"))) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (seen.has(event.id)) return res.sendStatus(200); // a repeat: already handled
seen.add(event.id);
res.sendStatus(200); // answer within 5 seconds…
setImmediate(() => handle(event)); // …then do the slow work
});
app.listen(3000, () => console.log("Listening on port 3000"));
Python (Flask)
# app.py: pip install flask, then flask --app app run --port 5000
# Run it behind HTTPS on your domain: we only send to https:// addresses on port 443.
import hashlib
import hmac
import json
import os
import threading
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["QRWALRUS_WEBHOOK_SECRET"].encode() # the whole whsec_… value
seen = set() # event ids already handled; keep them in your database in production
def is_genuine(raw_body: bytes, header: str) -> bool:
parts = {}
for piece in header.split(","):
key, sep, value = piece.strip().partition("=")
if sep:
parts[key] = value
t = parts.get("t", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300: # more than 5 minutes old
return False
expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), parts.get("v1", "").encode())
def handle(event: dict) -> None:
if event["type"] == "lead.created":
print("New lead:", event["data"]["lead"]["fields"])
elif event["type"] == "scan.created":
print("Scan:", event["data"]["scan"]["qr"]["name"], event["data"]["scan"]["city"])
elif event["type"] == "guest_photo.submitted":
print("Photo to approve:", event["data"]["photo"]["reviewUrl"])
elif event["type"] == "ping":
print("Test event received")
@app.post("/webhooks/qrwalrus")
def qrwalrus_webhook():
raw = request.get_data() # the exact bytes we signed
if not is_genuine(raw, request.headers.get("X-QRWalrus-Signature", "")):
abort(401)
event = json.loads(raw)
if event["id"] in seen: # a repeat: already handled
return "", 200
seen.add(event["id"])
threading.Thread(target=handle, args=(event,)).start() # answer within 5 seconds, work after
return "", 200
PHP
<?php
// webhook.php: use this file's https:// address as the webhook's address.
$secret = (string) getenv('QRWALRUS_WEBHOOK_SECRET'); // the whole whsec_… value
$raw = file_get_contents('php://input'); // the exact bytes we signed
$header = $_SERVER['HTTP_X_QRWALRUS_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $piece) {
$pair = explode('=', trim($piece), 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
$t = $parts['t'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
// Reject a bad signature, or one more than 5 minutes old.
if (!ctype_digit($t) || abs(time() - (int) $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($raw, true);
// To skip repeats, save $event['id'] in your database and stop here if it is already there.
switch ($event['type']) {
case 'lead.created':
$fields = $event['data']['lead']['fields']; // for example $fields['Name'], $fields['Phone']
error_log('New lead: ' . json_encode($fields));
break;
case 'scan.created':
error_log('Scan of ' . $event['data']['scan']['qr']['name']);
break;
case 'guest_photo.submitted':
error_log('Photo to approve: ' . $event['data']['photo']['reviewUrl']);
break;
case 'ping':
error_log('Test event received');
break;
}
http_response_code(200);
Using another framework? Make sure you hash the body before anything parses it. In a Next.js route handler, for example, read it with await request.text().
Check your code with this example
With these values, your code should get exactly this signature (leave out the 5-minute check while you try it):
- Secret
- whsec_example_only
- Body
- {"id":"evt_test","type":"ping"}
- Header
- t=1792921267,v1=37954c241d9ba3f302e51f93c2873ddff36d42416aa48c4939b49305695a6c23
X-QRWalrus-Deliverynames one delivery to one address. The eventidin the body is the one to skip repeats by.- Resend in the log sends the same event with the same
id. If your receiver skips ids it has seen, only mark an id as seen once you've handled it.
Retries and the delivery log
A 2xx answer means delivered. If a try doesn't go through, we try again on this schedule, 7 tries in all over about 35 hours:
| Try | When |
|---|---|
| 1 | Right away, as soon as the event happens |
| 2 | 1 minute after try 1 |
| 3 | 5 minutes after try 2 |
| 4 | 30 minutes after try 3 |
| 5 | 2 hours after try 4 |
| 6 | 8 hours after try 5 |
| 7 | 24 hours after try 6 |
A retry is never sent early, but it can go out a little later than listed.
Tried again
- 429 (too many requests)
- Any 5xx (a server error)
- No answer within 5 seconds
- Couldn't connect
Not tried again
- Any other 4xx, like 400, 401, 404
- A redirect (3xx): we don't follow redirects, so use the final address
- An address that is no longer reachable from the internet
After 20 deliveries in a row fail for good, we turn the webhook off so it stops piling up failures. Its card then says: Turned off after 20 deliveries in a row failed. Fix the address, then turn it back on.
Deliveries still waiting to be retried stop too, and events that happen while it's off aren't sent.
The status on each webhook's card:
- Working
- The last delivery got a 2xx answer.
- Failing
- The last try didn't go through. Retries may still be on their way.
- Not used yet
- Nothing has been sent yet. Send a test.
- Off
- You turned it off. Nothing is sent until you turn it on.
- Turned off
- We turned it off after 20 deliveries in a row failed.
- Paused (plan)
- Your plan doesn't include webhooks at the moment.
The delivery log
Click Recent deliveries on a webhook to see its latest 50 deliveries. They're kept for 30 days. Each one shows:
- Delivered, Retrying or Failed, the event, the time, the HTTP status or the error, how many tries it took and, while retrying, when the next try is.
- Payload: exactly what we sent.
- Resend: sends it again now, with a fresh set of retries. It appears on delivered and failed rows while the webhook is on.
To bring back a webhook that was turned off: fix the address or your receiver, then choose ⋯ → Turn on. That clears its failure count. Then resend, from the log, any failed deliveries you still need.
Your signing secret
Each webhook has its own signing secret, starting with whsec_. You only need it if you check signatures on your own server; Zapier and Make don't use it.
- See it: on the webhook's card, next to Signing secret, click the eye to show it, or the copy button to copy it.
- Make a new one: choose ⋯ → New signing secret → Make new secret. The old secret stops working at once, so update it wherever you check signatures, or our deliveries will look forged there. Deliveries waiting to be retried are signed with the new one.
Keep the secret on your server, never in a web page or an app. If you think someone has seen it, make a new one.
Limits
- Webhooks
- Up to 10 per workspace.
- Address
- Starts with https:// on the standard port (443). A domain name, not an IP address. No user name or password in it. At most 2,048 characters.
- Reachable
- On the public internet. Not localhost, private network addresses (like 192.168.x.x or 10.x.x.x) or names like printer.local.
- Time to answer
- 5 seconds. We don't read what you answer, only the status code.
- Redirects
- Not followed. Use the final address.
- Scan events
- Up to 120 a minute per webhook. Extra scans in that minute are skipped and counted.
- Tries
- 7 per delivery, over about 35 hours.
- Turned off
- After 20 deliveries in a row fail for good.
- Delivery log
- The latest 50 deliveries per webhook, kept 30 days.
We check the address when you save it and again before every delivery.
If it doesn't work
Zapier says Webhooks by Zapier needs an upgrade
- It's a premium Zapier app: it needs a paid Zapier plan (Professional, from about $19.99 a month). New accounts get a 14-day trial.
- Or use Make, whose free plan works.
The test fails with a 4xx answer, like “Answered 404”
- 404 usually means the address was copied incompletely, or the Zap, scenario or webhook at the other end was deleted. Copy the address again and paste it in with Edit.
- 401 or 403 means your server refused it. Check your signature code (below). If your address needs a login or an API key, use a signature check instead: we can't add a password or key to the request.
- 400 means your server didn't accept the body. Open Payload in the log to see what we sent.
- These aren't retried. Fix the cause, then click Send a test or Resend.
The test fails with a 5xx, “No answer within 5 seconds” or “Couldn't connect”
- Your receiver is down, slow or erroring. Check its logs. We retry these on the schedule above.
- Answer first and do slow work afterwards: anything over 5 seconds counts as no answer.
- Your address needs a valid HTTPS certificate. A self-signed or expired one can't be connected to.
“Redirected (301); use the final address”
- The address sent us somewhere else, and we don't follow redirects. Open it in a browser, see where it ends up (often www., https or a trailing slash) and use that.
“That address isn't reachable from the internet”
- The address is on your own computer or network: localhost, an address like 192.168.x.x or 10.x.x.x, a name like printer.local, or a name without a dot.
- Use a public address. To test a server on your computer, a tunnel such as ngrok or Cloudflare Tunnel gives you a public https address.
- Other messages when you save:
Use a domain name, not an IP address
,Use the standard https port
(remove:8443or similar), andLeave the user name and password out of the address
.
Signatures don't match
- Hash the raw body exactly as it arrived. Parsing the JSON and turning it back into text changes it.
- Use the whole secret, including whsec_, and the secret of this webhook: each one has its own.
- If you made a new signing secret, update it on your server.
- Sign the timestamp, a dot and the body, and compare lowercase hex.
- If old timestamps are rejected, check your server's clock.
- Try the worked example in the Your own server section.
The same event arrives twice
- We didn't get your answer within 5 seconds, or the connection dropped, so we tried again. Or someone clicked Resend.
- Skip events whose id you've already handled, and answer before doing slow work.
“Scan events skipped (over 120 a minute)”
- A busy code went over 120 scans a minute, so the extra scan events weren't sent to this webhook. This protects your receiver from floods.
- The scans themselves still count in QR Walrus. Only the webhook for them is skipped.
The webhook was turned off
- We turn a webhook off after 20 deliveries in a row fail for good. Open Recent deliveries to see the errors.
- Fix the address or your receiver, then ⋯ → Turn on. Resend the failed deliveries you need from the log (within 30 days).
- Events from while it was off weren't sent and can't be resent.
Delivered, but nothing happens in Zapier or Make
- Publish the Zap, or switch the Make scenario on. Until then they may accept the event without acting on it.
- Check the webhook has the right events ticked, and isn't narrowed to a different page or code.
- A change you just made can take up to a minute to apply.
Zapier or Make doesn't show my form's fields
- The test event has no form fields. Send one real form on your page, then test the trigger again (Zapier) or redetermine the data structure (Make).
- Fill in every field in that form: empty fields aren't sent.
- If you renamed a field on your form, it arrives under its new name. Map it again.