Skip to main content
All posts
Automation8 min read

How to Send New Kit Subscriber Alerts to a Shared Team Phone

Verify Kit's subscriber-activation webhook, collapse each signed batch into one privacy-minimal signal, and send fixed copy to a private PingRoom Room.

Two PingRoom avatars guide blank subscriber tokens through a small wooden verification gate while Pingo watches beside an empty queue tray.

To send a shared phone alert when a Kit subscriber becomes active, subscribe a paid Kit account's Webhooks 2.0 endpoint to subscriber.activated, verify the timestamped signature against the exact request bytes, claim every event UUID durably, and queue one fixed Ping for a private PingRoom Room. Kit remains the subscriber record. The Room receives only Kit subscriber active and Subscriber activity is ready to review in Kit.

This is a custom webhook recipe, not a native Kit integration. It does not forward a subscriber's name, email address, custom fields, form, sequence, or tags to PingRoom.

Choose the smallest route that works

Kit already provides simpler notification paths. Start with the one that matches the actual job.

NeedBetter starting point
The account owner or a team member only needs emailKit's account notification, which batches confirmed-subscriber notices hourly
One person wants a simple phone notificationAn IFTTT Kit notification applet; check its current polling interval for your IFTTT plan
A group needs one sparse signal in a shared private RoomThe signed relay and PingRoom workflow below
The event must update profiles, permissions, or campaign stateKeep that work in Kit or an owned workflow system

Use the relay when shared visibility, signature verification, explicit replay handling, and fixed copy matter. Do not build it merely to replace an email that already does the job.

Choose activation, not mere creation

Kit currently offers both subscriber.created and subscriber.activated. Its event reference says the first fires when a subscriber is added to the account; the second fires when the subscriber transitions to the active state.

This recipe uses subscriber.activated. That is a better fit for a “new active subscriber” signal than account creation, but it is still a narrow state transition. It does not prove the subscriber's consent covers a particular campaign, that the address is valuable, that a purchase happened, or that the person will remain active. Open Kit before taking any subscriber-specific action.

What you need

  • A paid Kit plan and permission to create a webhook.
  • A public HTTPS relay that can retain the exact raw request body.
  • Durable unique storage and a queue that can commit event claims and the queue item together.
  • A private PingRoom Room whose owner has PingRoom Pro. Incoming webhooks are Pro connections.
  • A controlled test subscriber and a safe confirmation path.

Store the Kit endpoint secret, the relay's replay secret, and the PingRoom webhook URL in protected server configuration. Keep them out of browser code, analytics, logs, screenshots, and source control.

Prepare the PingRoom destination

Open your Rooms in the PingRoom browser app, choose the destination Room, then open Settings → Connections → From another app.

  1. Add an incoming webhook and name it for the Kit activation signal.
  2. Keep Quick Ping on Custom message.
  3. Save and test it once.
  4. Store the complete secret webhook URL in the relay worker's protected configuration.

The incoming-webhook reference documents the current request fields, limits, cooldown behavior, idempotency, and response shape. The relay should be the only component that knows both the Kit endpoint secret and the PingRoom destination.

Create one Kit webhook

In Kit, go to Automation → Webhooks, select New webhook, and configure:

  • Event: subscriber.activated only.
  • Endpoint URL: the public HTTPS receiver on your relay.
  • Name: a clear label such as Shared active-subscriber signal.
  • Description: note that the endpoint discards subscriber data and queues fixed copy.

Create the webhook, reveal its signing secret, and store that secret on the receiver. Kit can rotate secrets and send more than one v1 signature during the overlap, so the receiver checks every v1 candidate.

Kit's delivery format carries an events array. Most deliveries contain one event, but a bulk action can produce up to 100 events of the same type in one delivery. This recipe deliberately turns one accepted delivery into one Ping, not as many as 100. The fixed message therefore says that subscriber activity is ready to review rather than claiming exactly one person joined.

Verify, claim, and queue before acknowledging

Kit signs timestamp + "." + raw body with HMAC-SHA256. Verify that signature before parsing, reject timestamps outside a five-minute tolerance, then validate the envelope. Do not re-serialize parsed JSON and try to verify that; whitespace and key order can change.

The receiver below is a trimmed dependency-free core from this article's controlled checks. claimEventsAndEnqueue is an adapter your database must implement as one transaction: identify event UUIDs not previously claimed, claim them, and add the fixed queue item. If the transaction fails, throw and return 503 so Kit retries.

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

const TOLERANCE = 300;
const UUID =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

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

function validSignature(rawBody, header, secret, nowSeconds) {
  const parts = String(header ?? "")
    .split(",")
    .map((part) => part.trim());
  const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2);
  const signedAt = Number(timestamp);
  if (
    !Number.isInteger(signedAt) ||
    Math.abs(nowSeconds - signedAt) > TOLERANCE
  ) {
    return false;
  }

  const expected = createHmac("sha256", secret)
    .update(Buffer.from(`${timestamp}.`, "utf8"))
    .update(rawBody)
    .digest("hex");
  return parts
    .filter((part) => part.startsWith("v1="))
    .some((part) => sameHex(part.slice(3), expected));
}

export async function receiveKit({
  headers,
  rawBody,
  endpointSecret,
  replaySecret,
  claimEventsAndEnqueue,
  nowSeconds = Math.floor(Date.now() / 1000),
}) {
  if (
    !validSignature(
      rawBody,
      headers["x-kit-signature"],
      endpointSecret,
      nowSeconds,
    )
  ) {
    return { status: 401, outcome: "invalid_signature" };
  }

  let envelope;
  try {
    envelope = JSON.parse(rawBody.toString("utf8"));
  } catch {
    return { status: 400, outcome: "invalid_json" };
  }

  if (
    !Number.isSafeInteger(envelope?.delivery_id) ||
    String(envelope.delivery_id) !== headers["x-kit-delivery"] ||
    !Array.isArray(envelope.events) ||
    envelope.events.length < 1 ||
    envelope.events.length > 100
  ) {
    return { status: 400, outcome: "invalid_envelope" };
  }

  const eventIds = [];
  for (const event of envelope.events) {
    if (
      !UUID.test(event?.id ?? "") ||
      event?.type !== "subscriber.activated" ||
      event?.data?.subscriber?.state !== "active"
    ) {
      return { status: 400, outcome: "unexpected_event" };
    }
    eventIds.push(event.id.toLowerCase());
  }
  if (new Set(eventIds).size !== eventIds.length) {
    return { status: 400, outcome: "duplicate_event_in_delivery" };
  }

  const digest = createHmac("sha256", replaySecret)
    .update([...eventIds].sort().join("\n"))
    .digest("hex");
  const batchKey = `kit-active-${digest}`;
  const job = {
    headers: {
      "content-type": "application/json",
      "idempotency-key": batchKey,
    },
    body: JSON.stringify({
      action: null,
      title: "Kit subscriber active",
      message: "Subscriber activity is ready to review in Kit.",
      correlation_id: batchKey,
    }),
  };

  try {
    const claimed = await claimEventsAndEnqueue(eventIds, job);
    return {
      status: 200,
      outcome: claimed > 0 ? "queued" : "replay",
    };
  } catch {
    return { status: 503, outcome: "queue_unavailable" };
  }
}

The complete checked fixture also enforces a one-megabyte receiver limit and tests the worker that posts each queue item to PingRoom. Those are transport details, not reasons to weaken the core rule: return 2XX only after a durable transaction has either queued the signal or confirmed every event as a replay.

Kit's retry documentation says a failed delivery can be attempted seven more times over roughly 41 hours. A retry resends the entire event array with a fresh signature. Keep event UUID claims long enough to cover that window and any manual replay policy your team permits. Deduplicate on each event id, not delivery_id; Kit documents that an upstream re-emission can place the same event in a new delivery.

Rehearse the real boundaries

Kit does not provide a test button for these webhooks. Use a controlled subscriber and a real activation event, then inspect Automation → Webhooks → your webhook → Attempts.

  1. Confirm the first delivery passes signature and timestamp verification.
  2. Inspect the durable queue item. It should contain only the fixed title, fixed message, opaque correlation ID, and idempotency header.
  3. Confirm one batch creates one Room Ping, even if the fixture contains several active-subscriber events.
  4. Replay the exact event UUIDs. The transaction should report a replay and add no queue item.
  5. Re-emit one old UUID beside one new UUID. Only the new UUID should be claimed, and the delivery should still create at most one fixed Ping.
  6. Send subscriber.created, a non-active state, malformed JSON, a stale signature, a wrong secret, and a duplicate UUID inside one delivery. None may queue a Ping.
  7. Make the durable transaction fail. The receiver must return 503 so Kit can retry.
  8. Disable the PingRoom webhook and confirm the queue consumer retains the identical request and idempotency key for its own retry.

The controlled fixture for this article covers those cases with signed Kit-shaped data, a durable-transaction stand-in, and a local PingRoom request mock. It uses no live Kit account, subscriber, endpoint secret, PingRoom Room, phone, or delivery receipt.

A PingRoom avatar checks an empty queue while another holds a phone with its screen hidden, and Pingo watches a duplicate blank token stopped outside the verification gate.

Pingo the Pigeon, PingRoom's fictional Chief Marketing Officer, is studying the duplicate that did not pass the gate. He has not joined the private Room, inspected a subscriber, or decided what the team should do next. The human team owns the list, the workflow, and every subscriber decision.

Read the signal correctly

A successful Ping means the configured Kit delivery passed the relay's checks, at least one new activation event was durably claimed, the fixed request was queued, and PingRoom accepted that queue item. It does not prove:

  • how the subscriber entered Kit;
  • which messages they consented to receive;
  • that a purchase or meaningful engagement happened;
  • that the subscriber is still active when someone reads the Ping;
  • that somebody reviewed the event;
  • or that every member's phone displayed a notification.

Kit remains authoritative for the subscriber record and later state. PingRoom stores the short shared signal and sends it through its normal delivery path. Connectivity, operating-system policy, notification permissions, Focus settings, Room mutes, and device state can still affect what appears.

If that boundary fits your team, create or choose the private Room, add its incoming webhook, and rehearse one controlled Kit activation before enabling the relay for normal traffic.

Illustrations are AI-generated editorial scenes featuring Pingo the Pigeon and approved PingRoom avatars. The people and situation are fictional examples, not customers or product screens. The human team is responsible for product claims and decisions.

PingRoom

Short-form communication.

View all posts