# Guide to Zigpoll Webhooks: Features and Best Practices

Zigpoll webhooks send survey answers to your own systems as respondents submit them. If you run on-site or Shopify surveys with Zigpoll, a webhook is how those answers reach a CRM, a data warehouse or an automation platform without waiting for an export.

This guide covers how Zigpoll webhooks are created and signed, what the payload contains, how retries work, and the practices that keep a Zigpoll receiver reliable.

## What are Zigpoll webhooks?

Each Zigpoll webhook subscribes to one survey and fires according to a delivery rule: on every submission, or only when the respondent completes the survey. Zigpoll sends a JSON `POST` to your HTTPS URL. Survey logic can also fire a webhook through the **Trigger webhook** action, for example only when someone picks a particular answer, and those deliveries use the same payload, signature and retry behaviour.

Every delivery is signed. The `X-Zigpoll-Signature` header looks like `t=1754400000000,v1=5257a8...`, where `t` is the send time in milliseconds and `v1` is the hex HMAC-SHA256 of `<t>.<raw body>`, keyed with your account's Signing Secret.

## Zigpoll webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Integrations > Webhooks**, or the API (`GET`/`POST`/`DELETE /webhooks`, Premium plan and above) |
| Subscription model | One survey per webhook, up to 20 webhooks per survey |
| Delivery rules | Partial or complete submissions (`submitted-responses`), or completions only (`completed-survey`) |
| Signature header | `X-Zigpoll-Signature: t=<unix ms>,v1=<hex>` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | `<t>.<raw body>` |
| Secret | One Signing Secret per account, shown once. Rotation through Zigpoll support |
| Idempotency key | `X-Zigpoll-Delivery-Id`, the same on every attempt |
| Response window | `2xx` within 10 seconds |
| Retries | 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours |
| Failure alerts | Email after a day with no successful delivery and at least one delivery given up on |
| Source IPs | Not published |

## Common events

Zigpoll has no event catalogue. The body's `type` field tells you which delivery rule fired:

| `type` | Fires |
| --- | --- |
| `submitted-responses` | On every submission, even if the respondent stops partway |
| `completed-survey` | Only once the respondent reaches the end of the survey |

Zigpoll documents the payload like this:

```javascript
const payload = {
  type: 'submitted-responses',   // or 'completed-survey'
  accountId: '',
  pollId: '',
  participantId: '',
  metadata: {},
  responses: {},
  submissionsComplete: false,    // did the respondent finish the survey?
  timestamp: 1754400000000,
  email: 'jane@example.com',     // first email found for this respondent, or null
  emails: ['jane@example.com']   // every email found, or []
};

```

That is a commented JavaScript object with empty IDs rather than a captured delivery, so log a real one before you build parsing around `responses` and `metadata`. `timestamp` is in milliseconds. Responses are batched briefly before sending, so one delivery can carry several answers from the same respondent. Surveys in anonymous mode always send `email: null` and `emails: []`. Zigpoll does not say which `type` a survey-logic webhook carries.

## Setting up Zigpoll webhooks

1. In the Zigpoll dashboard, go to **Integrations** and scroll to **Webhooks**.
2. Create a webhook with a public `https://` URL, the survey, and the delivery rule.
3. Copy the **Signing Secret** and store it server-side, for example as `ZIGPOLL_WEBHOOK_SECRET`. Zigpoll shows it once.

The Signing Secret belongs to the account, not the webhook. Every webhook and every survey-logic webhook on the account uses it, and adding or deleting webhooks does not change it. There is no self-serve rotation: you ask Zigpoll support, and queued retries switch to the new secret at once.

Saved endpoints also receive a legacy `X-Zigpoll-Secret` header containing the webhook's own Secret Key. Zigpoll keeps it for older integrations and says to verify the signature instead. When you create a Hookdeck source for Zigpoll, paste the account's Signing Secret, not the per-webhook Secret Key.

## Securing Zigpoll webhooks

Parse `t` and `v1` from `X-Zigpoll-Signature`, compute an HMAC-SHA256 of `<t>.<raw body>` with the Signing Secret, and compare the hex digests in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyZigpollSignature(rawBody, header, secret) {
  if (!header || !secret) return false;
  let t;
  const candidates = [];
  for (const part of header.split(',')) {
    const [key, ...rest] = part.trim().split('=');
    if (key === 't') t = rest.join('=');
    else if (key === 'v1') candidates.push(rest.join('='));
  }
  if (!/^\d+$/.test(t || '') || candidates.length === 0) return false;

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'),
    'utf8'
  );
  return candidates.some((candidate) => {
    const received = Buffer.from(candidate.toLowerCase(), 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/zigpoll', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.ZIGPOLL_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyZigpollSignature(req.body, req.get('x-zigpoll-signature'), secret)) {
    return res.sendStatus(401);
  }

  const delivery_id = req.get('x-zigpoll-delivery-id');
  const payload = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on delivery_id, branch on payload.type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_zigpoll_signature(raw_body: bytes, header, secret: str) -> bool:
    if not header or not secret:
        return False
    t, candidates = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            candidates.append(value)
    if not t or not (t.isascii() and t.isdigit()) or not candidates:
        return False
    expected = hmac.new(
        secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256
    ).hexdigest().encode("utf-8")
    return any(hmac.compare_digest(c.lower().encode("utf-8"), expected) for c in candidates)

```

Zigpoll's own sample rejects any `t` more than 5 minutes from the current time. The [Zigpoll webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/zigpoll-webhooks) keeps that window off by default, so it accepts Hookdeck retries and replays of older events. If you turn the window on with `ZIGPOLL_TIMESTAMP_TOLERANCE_SECONDS`, note that Zigpoll re-signs each of its own retries with a fresh `t`, but Hookdeck retries and replays resend the original timestamp, so any that arrive after the window has passed fail the check. If you write your own check, remember that `t` is in milliseconds. Use `X-Zigpoll-Delivery-Id` to catch duplicates.

> **Make Zigpoll webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Zigpoll-Signature` header, stores every survey response, and delivers it to your services with retries and replay you control.

## Zigpoll webhook limitations and pain points

### A dropped delivery is gone for good

**The Problem:** Answers submitted during an outage never reach your endpoint.

**Why It Happens:** Zigpoll retries a timeout, refused connection, `5xx`, `429` or `408` five more times over about eight and a half hours, then stops. Any other `4xx` is treated as permanent and not retried at all, and a DNS failure stops after three attempts. Once Zigpoll gives up, it does not send that response again. The answers stay in the dashboard, exports and API.

**Workarounds:**

* Return `2xx` within 10 seconds and process afterwards.
* Don't return `4xx` for transient problems such as a missing dependency. Use `503` so Zigpoll retries.
* Backfill from Zigpoll's exports or API after an outage.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, so an outage on your side does not use up Zigpoll's retry ladder. You can then [retry](/docs/retries) or replay events once your service is back.

### Timestamps in milliseconds

**The Problem:** A freshness check rejects every delivery, or accepts deliveries from decades in the future.

**Why It Happens:** `t` in the signature header and `timestamp` in the body are milliseconds since the epoch, while most webhook schemes use seconds.

**Workarounds:**

* Divide `t` by 1000 before comparing it with a time in seconds.
* Remember that the signed string uses `t` exactly as sent, in milliseconds.

**How Hookdeck Can Help:** Hookdeck's Zigpoll source type verifies the HMAC over `<t>.<body>` with `t` as sent and applies no timestamp window, so the units never cause a failed verification.

### One secret for the whole account

**The Problem:** Rotating the secret affects every survey at once, and you can't do it yourself.

**Why It Happens:** The Signing Secret is account-wide and only Zigpoll support can rotate it. Because every attempt is signed with the current secret, retries that are already queued switch to the new one immediately.

**Workarounds:**

* Keep the secret in one place that every receiver reads from.
* Coordinate the rotation with support so you can deploy the new value straight away.

**How Hookdeck Can Help:** With Hookdeck in front, the secret lives on one source. Update it there and every service behind it keeps receiving verified events.

## Best practices

### Verify against the raw body

Zigpoll warns that parsing and re-serialising the JSON can reorder keys and break the signature. Use `express.raw()` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse only after the check passes.

### Deduplicate on `X-Zigpoll-Delivery-Id`

Delivery is at-least-once, and a slow response can produce a second delivery that you have already processed. The delivery ID stays the same across attempts, while the signature changes. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Pick the delivery rule deliberately

`submitted-responses` fires on partial submissions too, so the same respondent can produce several deliveries as they progress. Check `submissionsComplete`, or use `completed-survey` if you only want finished surveys.

### Acknowledge fast and process asynchronously

Zigpoll allows 10 seconds. Verify, store the payload, return `200`, and do the CRM or warehouse write afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Zigpoll signs every delivery with `X-Zigpoll-Signature`: a millisecond `t` and a hex HMAC-SHA256 of `<t>.<raw body>`, keyed with the account-wide Signing Secret. Verify against the raw bytes, deduplicate on `X-Zigpoll-Delivery-Id`, keep any timestamp check optional, and answer within 10 seconds, because a delivery Zigpoll gives up on is never sent again.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Zigpoll signature, and holds each response until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Zigpoll webhooks reliably in minutes.