Skip to main content
All posts
Developers8 min read

Send Paddle Completed-Transaction Alerts to Your Team's Phones

Verify Paddle's transaction.completed webhook, queue one shared alert, and keep customer and payment details out of the Ping.

Two PingRoom avatars guide one plain clay token through a brass timestamp gate while Pingo watches beside a walnut queue.

To send one Paddle completed-transaction signal to a group of phones, subscribe a Paddle Billing notification destination to transaction.completed, verify the Paddle-Signature header against the raw request body, and atomically queue one privacy-minimal job per event_id. A worker can then send fixed copy to a PingRoom Room.

This is a webhook recipe, not a native Paddle integration. Paddle remains the source of truth for transactions, subscriptions, customers, invoices, payouts, and notification delivery. PingRoom carries one selected operational signal to the people who need it.

Choose completed, not merely paid

Paddle emits transaction.paid when it has captured payment. It emits transaction.completed later, after completed-transaction processing has added details such as fees, earnings, invoice information, and any subscription relationship.

That distinction decides the signal's meaning:

Team questionBetter event or system
“Was payment captured so we can start provisioning?”Use transaction.paid in the application workflow
“Did Paddle finish processing this selected transaction?”Use the transaction.completed relay below
“Was the money paid out, settled, refunded, or reconciled?”Open Paddle and use the relevant Paddle record or event
“Should access, fulfillment, inventory, or accounting state now change?”Keep that decision in your durable application workflow

The Room alert below deliberately waits for transaction.completed. It does not claim to be the earliest payment signal, and it must not drive fulfillment.

Prepare one destination, product, and Room

You need:

  • a private PingRoom Room whose members should receive this operational signal;
  • PingRoom Pro, which currently allows up to four incoming webhooks per Room;
  • a public HTTPS receiver backed by an atomic durable store or queue;
  • one Paddle Billing webhook notification destination subscribed to transaction.completed;
  • that destination's secret key; and
  • one Paddle product ID the receiver should accept.

In PingRoom, create or choose the Room, then add an incoming webhook under Settings → Connections → From another app. Store the complete URL as a server-side secret. Its final path segment is a credential; keep it out of browser code, source control, screenshots, and logs. The incoming-webhook walkthrough covers the Room-side setup.

In Paddle, go to Developer Tools → Notifications, create a webhook destination for your receiver, use Platform only or Both when the destination should receive live events, and subscribe only to transaction.completed. Copy that destination's secret key into server-side configuration. A Simulation only destination is for the webhook simulator, not production traffic.

Paddle can deliver a transaction containing several items. This recipe accepts the event when at least one item's price.product_id matches the configured product. If different products need different people or meanings, give them separate routes or queues rather than widening one alert until nobody knows what it means.

Verify timestamp and raw body first

Paddle signs a payload made from the Unix timestamp, a colon, and the exact raw request body. The header looks like ts=...;h1=.... During secret rotation it may contain more than one h1 value, so check every valid candidate without weakening the timestamp rule.

Capture raw bytes before a framework parses or re-serializes JSON. A whitespace change is enough to invalidate the signature. Paddle's SDKs use a five-second timestamp tolerance by default; keep the server clock synchronized and use the same bounded check when implementing the algorithm yourself.

This framework-neutral receiver expects the host framework to provide raw bytes and an atomic putIfAbsent operation:

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

const MAX_BODY_BYTES = 128 * 1024;
const TOLERANCE_SECONDS = 5;

function parseSignature(header) {
  const timestamps = [];
  const signatures = [];

  for (const part of String(header ?? "").split(";")) {
    const separator = part.indexOf("=");
    if (separator < 1) continue;
    const key = part.slice(0, separator).trim();
    const value = part.slice(separator + 1).trim();
    if (key === "ts" && /^\d{1,16}$/.test(value)) {
      timestamps.push(Number(value));
    }
    if (key === "h1" && /^[a-f0-9]{64}$/i.test(value)) {
      signatures.push(value.toLowerCase());
    }
  }

  return timestamps.length === 1 && signatures.length
    ? { timestamp: timestamps[0], signatures }
    : null;
}

function verifyPaddle({ rawBody, header, secret, nowSeconds }) {
  const parsed = parseSignature(header);
  if (!parsed) return false;
  if (Math.abs(nowSeconds - parsed.timestamp) > TOLERANCE_SECONDS) {
    return false;
  }

  const expected = createHmac("sha256", secret)
    .update(`${parsed.timestamp}:`, "utf8")
    .update(rawBody)
    .digest();

  return parsed.signatures.some((candidate) => {
    const received = Buffer.from(candidate, "hex");
    return (
      received.length === expected.length && timingSafeEqual(received, expected)
    );
  });
}

Reject a missing or invalid signature and a stale or implausibly future timestamp. Do not parse the JSON first and then stringify it again for verification.

Queue one fixed signal per event

After signature verification, check the event type, completed status, product, and event ID before writing anything:

export async function receiveCompletedTransaction({
  rawBody,
  headers,
  env,
  queue,
  nowSeconds = Math.floor(Date.now() / 1000),
}) {
  const bytes = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
  if (bytes.length > 128 * 1024) return { status: 413 };

  if (
    !verifyPaddle({
      rawBody: bytes,
      header: headers.get("paddle-signature"),
      secret: env.PADDLE_WEBHOOK_SECRET,
      nowSeconds,
    })
  ) {
    return { status: 403 };
  }

  let event;
  try {
    event = JSON.parse(bytes.toString("utf8"));
  } catch {
    return { status: 400 };
  }

  if (event?.event_type !== "transaction.completed") {
    return { status: 200, code: "ignored_event" };
  }
  if (event?.data?.status !== "completed") return { status: 400 };

  const hasProduct = event?.data?.items?.some(
    (item) => item?.price?.product_id === env.PADDLE_PRODUCT_ID,
  );
  if (!hasProduct) return { status: 200, code: "ignored_product" };

  if (!/^evt_[a-z0-9]{26}$/.test(event?.event_id ?? "")) {
    return { status: 400 };
  }

  const eventKey = `paddle:transaction-completed:${event.event_id}`;
  try {
    const inserted = await queue.putIfAbsent(eventKey, { eventKey });
    return { status: 200, code: inserted ? "queued" : "duplicate" };
  } catch {
    return { status: 503, code: "queue_unavailable" };
  }
}

Paddle describes delivery as at least once and recommends event_id as the deduplication key. It also expects HTTP 200 within five seconds. The receiver therefore stores a minimal job atomically before acknowledgement and leaves PingRoom delivery to a worker.

The queued job contains only the derived key. Customer ID, email, name, product name, amount, currency, invoice number, custom data, fees, earnings, and payout details stay out of the queue and out of the Ping.

Two PingRoom avatars inspect one sealed signal envelope beside a closed detail drawer while Pingo watches the queue divider.

Pingo, PingRoom's fictional CMO, is the queue observer in this illustrated example. His deadpan look is editorial punctuation, not product evidence: he cannot inspect Paddle data, access the Room, or send a Ping. The human team chooses the signal and remains responsible for its meaning.

Send fixed copy from a worker

Build the Ping from reviewed constants, not the transaction payload:

export async function sendPaddleSignal({ job, webhookUrl, fetchImpl = fetch }) {
  const body = JSON.stringify({
    title: "Paddle transaction completed",
    message: "Open Paddle to review the transaction",
    correlation_id: job.eventKey,
  });

  let response;
  try {
    response = await fetchImpl(webhookUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": job.eventKey,
      },
      body,
    });
  } catch {
    return { outcome: "retry" };
  }

  if (response.status === 429 || response.status >= 500) {
    return { outcome: "retry" };
  }
  if (!response.ok) return { outcome: "configuration_failure" };

  const result = await response.json().catch(() => null);
  return result?.success === true
    ? { outcome: "delivered" }
    : { outcome: "retry" };
}

Keep the request body and idempotency key unchanged across retries. A 429, server error, or network failure stays on the retry path. Another 4xx usually needs a person to repair the destination, Pro state, secret, or payload.

The queue's atomic insert stops repeated Paddle deliveries from producing several jobs. PingRoom's idempotency handling lets one worker retry without intentionally creating another Ping. This is replay-safe processing, not an exactly-once guarantee.

Test the boundaries

Use Paddle's webhook simulator or sandbox, a test Room, and non-sensitive data.

  1. Send one transaction.completed event containing the configured product. Confirm signature verification passes, the queue stores only the derived event key, and the Room receives fixed copy.
  2. Replay the same event. Confirm the receiver reports duplicate and keeps one job.
  3. Send a valid transaction.paid event and a completed transaction for another product. Confirm each is acknowledged and ignored.
  4. Change one raw-body byte, remove the signature, or sign with the wrong secret. Confirm nothing is stored.
  5. Move the signed timestamp beyond the allowed tolerance in either direction. Confirm the request is rejected, then check the server clock rather than widening the window casually.
  6. Make the durable store unavailable. Confirm the receiver returns non-2xx so Paddle can retry instead of losing the event.
  7. Make the PingRoom call return 429, 500, and a network error. Confirm the worker retries with the same body and key. Treat another 4xx as a configuration failure for human review.

Our recorded check covers these paths with Paddle-shaped fixtures, local HMAC generation, an injected clock, a stateful in-memory queue, and a mocked PingRoom receiver. It also asserts that customer, amount, currency, invoice, note, and product-label values never enter the stored job or outgoing Ping. No live Paddle account, live PingRoom Room, or physical phone was used, so the harmless end-to-end simulator test remains part of your rollout.

Read the signal conservatively

The Ping means only the relay validated a fresh Paddle delivery, accepted one transaction.completed event containing the configured product, saved one minimal job, and sent the reviewed signal toward PingRoom. Arrival still depends on each recipient's device, connectivity, and notification settings. Open Paddle to review the transaction and its current state.

Do not treat the Ping as proof of payout, settlement, fulfillment, access provisioning, accounting closure, or staff acknowledgement. Paddle's webhook model and delivery contract explain event identity, retries, ordering, and the paid-versus-completed sequence. Its signature guide documents the raw-body input and timestamp rule, while the transaction.completed reference defines the event itself.

Review PingRoom's complete webhook contract for current fields, errors, cooldowns, and acknowledgements. The incoming webhook feature page describes the Room connection without presenting it as Paddle-native.

When the seven tests pass, open PingRoom, choose the private destination Room, and activate only the completed-transaction signal its members are prepared to act on.

The two editorial illustrations were created with AI using approved PingRoom character references and reviewed by the PingRoom team.

PingRoom

Short-form communication.

View all posts