Agent skill
Zigpoll Webhooks Skill
Receive and verify Zigpoll webhooks. Use when setting up Zigpoll survey webhook handlers, debugging X-Zigpoll-Signature verification, or handling submitted-responses and completed-survey events.
Install this skill
npx skills add hookdeck/webhook-skills --skill zigpoll-webhooks
When to Use This Skill
- How do I receive Zigpoll webhooks?
- How do I verify the
X-Zigpoll-Signatureheader? - How do I handle
submitted-responsesandcompleted-surveyevents? - Why is my Zigpoll webhook signature verification failing?
- Why does
tinX-Zigpoll-Signaturelook 1000x too big?
Essential Code (USE THIS)
Every Zigpoll delivery carries X-Zigpoll-Signature: t=<unix milliseconds>,v1=<hex>. v1 is the HMAC-SHA256 (hex) of "<t>.<raw body>", keyed with the account-wide Signing Secret (Integrations → Webhooks, shown once). Zigpoll's docs show one v1; rotation goes through Zigpoll support and applies immediately (queued retries switch to the new secret), so there is no overlap period. The examples still accept the header if any v1 matches, which is harmless. t is in milliseconds.
Verification (Node.js)
const crypto = require('crypto');
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyZigpoll(rawBody, header, secret) {
if (!secret || !header) return false;
let t; const v1 = [];
for (const part of header.split(',')) {
const [k, ...v] = part.trim().split('=');
if (k === 't') t = v.join('='); else if (k === 'v1') v1.push(v.join('='));
}
if (!/^\d+$/.test(t ?? '') || v1.length === 0) return false;
// Optional replay window, off by default (divide t by 1000 if you add one)
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'));
// 64 hex chars first: Buffer.from(x, 'hex') silently truncates invalid hex
return v1.some((c) => /^[0-9a-f]{64}$/i.test(c) &&
crypto.timingSafeEqual(Buffer.from(c.toLowerCase()), expected));
}
Verification (Python)
import hashlib, hmac
def verify_zigpoll(raw_body: bytes, header: str, secret: str) -> bool:
if not secret or not header:
return False
t, v1 = None, []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t": t = v
elif k == "v1": v1.append(v)
if not t or not (t.isascii() and t.isdigit()) or not v1:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
# bytes on both sides: a non-ASCII header is a rejection, not a TypeError (500)
return any(hmac.compare_digest(c.lower().encode(), expected) for c in v1)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Timestamp window: off by default
A 5-minute freshness check on t is common advice. Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries. Zigpoll re-signs each of its own attempts with a fresh t, but Hookdeck retries and replays do not. The examples therefore skip the check unless you set ZIGPOLL_ to a positive number of seconds (default 0 = off). Deduplicate on X-Zigpoll-Delivery-Id instead. references/verification.md shows exactly where the window can be dropped or widened.
Event Types
The payload type field says which delivery rule fired:
type | Fires | Notes |
|---|---|---|
submitted-responses | On every submission, even if the respondent stops partway | submissionsComplete tells you if they finished |
completed-survey | Only once the respondent reaches the end of the survey |
The body also has accountId, pollId, participantId, metadata, responses, submissionsComplete, timestamp, email and emails. See references/overview.md.
Important Headers
| Header | Description |
|---|---|
X-Zigpoll-Signature | t=<unix ms>,v1=<hex HMAC-SHA256> |
X-Zigpoll-Delivery-Id | Unique per event, the same across retries: use it to ignore duplicates |
X-Zigpoll-Attempt | Attempt number, starting at 1 |
X-Zigpoll-Triggered-At | When this attempt was sent |
The legacy X-Zigpoll-Secret static header is not part of signature verification; don't use it.
Environment Variables
ZIGPOLL_WEBHOOK_SECRET=your_signing_secret # account-wide Signing Secret, shown once
ZIGPOLL_TIMESTAMP_TOLERANCE_SECONDS=0 # optional; a positive value opts in to the replay window
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 zigpoll --path /webhooks/zigpoll
Reference Materials
- references/overview.md - Zigpoll webhook concepts, events, payload, retries
- references/setup.md - Creating the webhook and finding the Signing Secret
- references/verification.md - Signature details, the timestamp window, and debugging