# 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)

```javascript
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)

```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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/zigpoll-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/zigpoll-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/zigpoll-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/zigpoll-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/zigpoll-webhooks/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

```bash
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

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 zigpoll --path /webhooks/zigpoll

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/zigpoll-webhooks/references/overview.md) - Zigpoll webhook concepts, events, payload, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/zigpoll-webhooks/references/setup.md) - Creating the webhook and finding the Signing Secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/zigpoll-webhooks/references/verification.md) - Signature details, the timestamp window, and debugging