# How to Send Tally Form Response Alerts to Your Team's Phones

> Verify Tally's signed form webhook, keep answer content out of the alert, and send one fixed Ping when a response arrives.

- Published: 2026-10-24
- Author: PingRoom
- Category: Developers
- Topics: Tally form response push notifications, Tally webhook notifications, webhook push notifications
- Canonical: https://pingroom.io/blog/tally-form-response-push-notifications
- Cover image: https://pingroom.io/blog/tally-form-response-push-notifications-cover.webp
- Cover description: Two PingRoom avatars keep a response box closed while Pingo watches one blank signal tile beside a face-down phone.

To send a Tally form response alert to a shared phone-alert Room, connect Tally's **Webhooks** integration to a small HTTPS relay. The relay should verify Tally's signature, accept responses from one expected form, discard the answer content, and send one fixed message to a PingRoom incoming webhook.

This guide sends **New Tally response — Workshop request received a response.** The answers, respondent details, PDF link, and preview link stay in Tally. This is a webhook recipe you configure, not a native Tally integration.

## Choose email, Slack, or a shared Room

Tally already offers [automatic email notifications](https://tally.so/help/email-notifications). Its [Slack integration](https://tally.so/help/slack-integration) can send a submission notification to a channel or direct message. Use those first when they already reach the right people.

| Need                                             | Better fit                                       |
| ------------------------------------------------ | ------------------------------------------------ |
| One person checks an inbox                       | Tally email notification                         |
| The team already works in a Slack channel or DM  | Tally's native Slack integration                 |
| A small opt-in Room needs one short phone signal | The signed Tally-to-PingRoom relay below         |
| Another system needs the submitted answers       | An approved backend with its own access controls |

The Room should contain only the people who need the signal. It does not replace Tally's response record, permissions, retention policy, or follow-up process.

## Decide what the phone is allowed to reveal

Use a fixed, non-sensitive label such as **Workshop request**, **Volunteer signup**, or **Equipment check**. Do not build the lock-screen message from Tally's `fields` array or its response links.

For an illustrative workshop form, the Room receives:

> **New Tally response**
>
> Workshop request received a response.

The team follows its normal authorized route to Tally for the details. The alert does not name the respondent, quote an answer, or claim that anyone has reviewed or accepted the submission.

[Pingo the Pigeon](/Pingo), our fictional CMO, appears beside the sealed response box in the illustrations. His contribution is a suspicious look at the duplicate signal tile. He cannot read the form, access a private Room, or send a Ping; the human team chooses the copy, membership, and follow-up.

## Prepare the two webhook ends

Tally says webhooks are available free to all Tally users. Publish the form, open **Integrations**, connect **Webhooks**, and enter your relay's HTTPS endpoint. Add a signing secret and keep it in your relay as `TALLY_SIGNING_SECRET`.

The destination Room's owner needs active **PingRoom Pro** for incoming webhooks. Follow the [incoming webhook setup](/blog/webhook-to-push-notification): open the Room's **Settings → Connections → From another app**, add a webhook named **Tally**, choose **Custom message**, and copy its URL.

The complete PingRoom webhook URL is a credential. Store it as `PINGROOM_WEBHOOK_URL` in server-side configuration; never place it in the Tally form, browser code, source control, screenshots, or logs. Also store the expected Tally form ID as `TALLY_FORM_ID` and your approved public-safe label as `TALLY_FORM_LABEL`.

## Verify, filter, and forward

Tally documents a `Tally-Signature` header calculated as an HMAC-SHA256 over `JSON.stringify(payload)`, encoded in Base64. This Node-compatible handler follows that expression, compares the digest in constant time, accepts only `FORM_RESPONSE` from the configured form, and never reads the `fields`, `submissionPdfUrl`, or `submissionPreviewUrl` values.

```javascript
import { createHash, createHmac, timingSafeEqual } from "node:crypto";

function secureEqual(left, right) {
  const a = Buffer.from(left);
  const b = Buffer.from(right);
  return a.length === b.length && timingSafeEqual(a, b);
}

function validPingRoomUrl(value) {
  return /^https:\/\/api\.pingroom\.io\/api\/webhooks\/[^/?#\s]+\/[^/?#\s]+$/.test(
    value || "",
  );
}

export async function handleTally(request, env, fetchImpl = fetch) {
  const label = (env.TALLY_FORM_LABEL || "").trim();
  if (
    !env.TALLY_SIGNING_SECRET ||
    !env.TALLY_FORM_ID ||
    !validPingRoomUrl(env.PINGROOM_WEBHOOK_URL) ||
    !label ||
    Array.from(label).length > 70 ||
    /[\r\n\t]/.test(label)
  ) {
    return new Response("server configuration error", { status: 500 });
  }

  let event;
  try {
    event = JSON.parse(await request.text());
  } catch {
    return new Response("invalid JSON", { status: 400 });
  }

  const received = request.headers.get("tally-signature") || "";
  const expected = createHmac("sha256", env.TALLY_SIGNING_SECRET)
    .update(JSON.stringify(event))
    .digest("base64");
  if (!secureEqual(received, expected)) {
    return new Response("invalid signature", { status: 401 });
  }

  if (
    event.eventType !== "FORM_RESPONSE" ||
    event.data?.formId !== env.TALLY_FORM_ID
  ) {
    return new Response("ignored", { status: 202 });
  }
  if (typeof event.eventId !== "string" || !event.eventId) {
    return new Response("invalid event", { status: 400 });
  }

  const digest = createHash("sha256").update(event.eventId).digest("hex");
  const eventKey = `tally-${digest}`;
  const pingBody = JSON.stringify({
    action: null,
    title: "New Tally response",
    message: `${label} received a response.`,
    correlation_id: eventKey,
  });

  let pingResponse;
  try {
    pingResponse = await fetchImpl(env.PINGROOM_WEBHOOK_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": eventKey,
      },
      body: pingBody,
      redirect: "error",
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return new Response("upstream unavailable", { status: 502 });
  }

  let accepted = false;
  try {
    accepted = (await pingResponse.json()).success === true;
  } catch {
    // A non-JSON response cannot confirm acceptance.
  }
  if (pingResponse.status !== 200 || !accepted) {
    return new Response("upstream unavailable", { status: 502 });
  }

  return new Response("accepted", { status: 200 });
}
```

The `eventId` becomes a stable, opaque PingRoom `Idempotency-Key` after hashing. If Tally repeats the same event, the relay sends the same body with the same key, so PingRoom can return the prior accepted result instead of creating another Ping. Do not add the current time or any response field to that body: either would weaken the duplicate boundary or expose content.

![Two PingRoom avatars keep a sealed response envelope in its tray while Pingo watches a duplicate signal tile stopped before a one-slot chute.](/blog/tally-form-response-push-notifications-1.webp)

_AI-generated editorial illustration. Pingo is PingRoom's fictional mascot; the people and workflow are illustrative._

## Test privacy, filtering, and retries

Tally requires a successful 2xx response within 10 seconds. Its documented retries begin after 5 minutes, then continue after 30 minutes, 1 hour, 6 hours, and 1 day when delivery still fails. The handler gives PingRoom eight seconds and returns non-2xx when acceptance cannot be confirmed, leaving time for the relay to respond before Tally's deadline.

Before connecting a real form, use harmless sample data and verify:

1. A valid signed `FORM_RESPONSE` from the configured form produces one fixed Ping.
2. The Ping body contains no respondent data, answers, form name, PDF URL, preview URL, submission ID, or Tally secret.
3. Replaying the same event produces no second Ping.
4. A different form or event type is ignored without calling PingRoom.
5. A missing or altered signature returns `401` and sends nothing.
6. A PingRoom rejection makes the relay return non-2xx so Tally can retry.

For this article, we imported the exact fenced handler and ran it locally with signed Tally fixtures plus a stateful mock of PingRoom's documented webhook contract. The check covered valid delivery, exact replay, changed events, wrong form, wrong event type, malformed JSON, missing and altered signatures, invalid configuration, PingRoom rejection, malformed upstream responses, and network failure. It made no live Tally, PingRoom, or phone request.

## Read acceptance correctly

A PingRoom response with HTTP `200` and `success: true` means the Ping was accepted and queued. It does not guarantee that every phone displayed an alert under every network, Focus, mute, battery, or notification setting. Current request limits, Room cooldown behavior, and accepted fields are documented in the [webhook reference](/documents/webhooks).

Tally remains the source of truth for the response. If the form controls healthcare, safeguarding, payment, legal, emergency, or regulated work, keep the approved case system, access controls, audit record, and escalation path authoritative. The Ping is an attention cue, not proof that someone reviewed or acted on the response.

Start with one harmless form and a small [PingRoom Room](/app/rooms). If your form lives in Typeform instead, use the separate [signed Typeform response recipe](/blog/typeform-response-push-notifications).
