# Guide to Tally Webhooks: Features and Best Practices

Tally webhooks notify your application when someone submits one of your forms. If you're routing form submissions into a CRM, a database, or a downstream workflow, webhooks are how you react in real time without polling.

This guide covers how Tally webhooks work, the single event you'll handle, how to verify the optional `Tally-Signature` (and what to do when there's no secret), the retry behavior, and the best practices for production.

## What are Tally webhooks?

Tally webhooks are HTTP POSTs sent to a URL you configure per form, delivered when a form is submitted. Tally webhooks are free on all plans. Signing is optional: if you set a signing secret, deliveries carry a `Tally-Signature` header; if you don't, requests are unsigned, so your verification code has to handle both cases.

## Tally webhook features

| Feature | Details |
| --- | --- |
| Configuration | Form's Integrations tab (or the API `signingSecret` field) |
| Event | `FORM_RESPONSE` (the only event type) |
| Signature header | `Tally-Signature` (case-insensitive); present only if a secret is set |
| Signature scheme | base64 HMAC-SHA256 over the payload, keyed with the signing secret |
| Secret | Optional; unsigned when unset |
| Acknowledgement | Return 2xx within a 10-second timeout |
| Retries | 5 min, 30 min, 1 hr, 6 hr, 1 day |
| Plan | Free on all plans |
| SDK | No official SDK |

## The event

There's one event type, `FORM_RESPONSE`. The payload looks like:

```json
{
  "eventId": "...",
  "eventType": "FORM_RESPONSE",
  "createdAt": "...",
  "data": {
    "responseId": "...",
    "submissionId": "...",
    "respondentId": "...",
    "formId": "...",
    "fields": [ /* answers */ ]
  }
}

```

Answers live in `data.fields`. Use `data.responseId` (or `data.submissionId`) as an idempotency key.

## Setting up Tally webhooks

Add a webhook in the form's Integrations tab (or via the API). Optionally set a signing secret there (or with the API `signingSecret` field). If you set one, verify the `Tally-Signature`; if you don't, requests are unsigned and you should authenticate the endpoint another way.

## Securing Tally webhooks

When a signing secret is set, each delivery carries a `Tally-Signature` header: a base64 HMAC-SHA256 over the payload, keyed with the secret. Verify against the raw body and compare in constant time. Crucially, handle the unsigned case: if no secret is configured, there's no header to check, so decide explicitly whether to accept unsigned requests (and if so, add another layer such as a secret path).

```javascript
const crypto = require("crypto");

const SIGNING_SECRET = process.env.TALLY_SIGNING_SECRET; // may be unset

function verify(rawBody, signature) {
  if (!SIGNING_SECRET) return false; // no secret: don't treat as verified
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(rawBody)
    .digest("base64");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["tally-signature"];

  if (SIGNING_SECRET && !verify(req.body, signature)) {
    return res.sendStatus(401);
  }
  // If SIGNING_SECRET is unset, requests are unsigned -- gate the endpoint another way.

  res.sendStatus(200); // acknowledge within 10s
  processQueue.add(JSON.parse(req.body)); // process asynchronously
});

```

Tally's own example signs `JSON.stringify(payload)`; verifying against the raw body avoids re-serialization differences, so prefer the raw body where your framework preserves it.

## Tally webhook limitations and pain points

### Signing is optional

The Problem: If no secret is set, deliveries are unsigned, and an endpoint that assumes a signature will either reject everything or (worse) accept anything. Any POST to an unsigned webhook URL looks legitimate.

Why It Happens: Tally makes the signing secret opt-in per webhook.

Workarounds:

* Always set a signing secret and verify `Tally-Signature`.
* If you must accept unsigned deliveries, gate the endpoint with a secret path or a header your infrastructure checks.

How Hookdeck Can Help: Hookdeck gives every source a dedicated URL and can enforce its own authentication, so an unsigned Tally webhook isn't your only line of defense.

### One event, answers in `data.fields`

The Problem: There's only `FORM_RESPONSE`, and the answers are nested in `data.fields`. Handlers expecting top-level answer fields miss the data.

Why It Happens: Tally models every submission as a single event with a fields array.

Workarounds:

* Read answers from `data.fields`, mapping by field key/label.

How Hookdeck Can Help: Hookdeck's transformations can reshape the payload before it reaches your app, so downstream code gets the structure it expects.

### Retries bring duplicates

The Problem: Failed deliveries retry on a 5 min, 30 min, 1 hr, 6 hr, 1 day ladder, so the same submission can arrive more than once.

Why It Happens: At-least-once delivery favors eventual delivery.

Workarounds:

* Dedupe on `data.responseId` (or `data.submissionId`) and make side effects idempotent.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge, so retries don't double-process submissions. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### The 10-second timeout

The Problem: You must return 2xx within 10 seconds or the delivery is retried. Slow synchronous work (CRM writes, downstream calls) risks the window.

Why It Happens: Tally caps how long it waits for an ack.

Workarounds:

* Acknowledge fast and process off a queue.

How Hookdeck Can Help: Hookdeck acknowledges Tally within the window and durably queues submissions, decoupling the ack from your processing time.

## Best practices

### Set a secret and verify (and handle unsigned)

Always configure a signing secret and verify `Tally-Signature` over the raw body in constant time; if a webhook is unsigned, gate the endpoint another way rather than trusting it.

### Read answers from `data.fields` and dedupe

Map answers from `data.fields`, and dedupe on `data.responseId`.

### Acknowledge within 10 seconds, process asynchronously

Return 2xx immediately and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Tally webhooks are simple, free on every plan, one `FORM_RESPONSE` event, with answers in `data.fields`, but the details that matter are that signing is optional (so handle the unsigned case deliberately), the retry ladder brings duplicates, and there's a 10-second ack window.

[Hookdeck](https://hookdeck.com) verifies the signature, adds authentication when a webhook is unsigned, deduplicates, and durably queues every submission at the edge, so your app only ever processes trustworthy, unique form responses.

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