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-Signature header?
  • How do I handle submitted-responses and completed-survey events?
  • Why is my Zigpoll webhook signature verification failing?
  • Why does t in X-Zigpoll-Signature look 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_TIMESTAMP_TOLERANCE_SECONDS 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:

typeFiresNotes
submitted-responsesOn every submission, even if the respondent stops partwaysubmissionsComplete tells you if they finished
completed-surveyOnly 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

HeaderDescription
X-Zigpoll-Signaturet=<unix ms>,v1=<hex HMAC-SHA256>
X-Zigpoll-Delivery-IdUnique per event, the same across retries: use it to ignore duplicates
X-Zigpoll-AttemptAttempt number, starting at 1
X-Zigpoll-Triggered-AtWhen 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 9, 2026

View on GitHub →