# Send Lemon Squeezy Sale Alerts to Your Team's Phones

> Verify Lemon Squeezy's signed order webhook, keep customer data out of the alert, and send one fixed sale signal to a shared PingRoom Room.

- Published: 2026-10-30
- Author: PingRoom
- Category: Developers
- Topics: Lemon Squeezy sale notification, Lemon Squeezy webhook phone alert, Lemon Squeezy order_created webhook
- Canonical: https://pingroom.io/blog/lemon-squeezy-sale-push-notifications
- Cover image: https://pingroom.io/blog/lemon-squeezy-sale-push-notifications-cover.webp
- Cover description: Two PingRoom avatars guide one plain clay token through a brass verification ring into a wooden tray while Pingo watches.

To send a Lemon Squeezy sale alert to a shared group of phones, subscribe to the `order_created` webhook, verify its raw-body signature in a server-side receiver, and queue one derived event identity before returning HTTP 200. A worker can then send fixed copy to a PingRoom incoming webhook with that same identity as its idempotency key.

This is a webhook recipe, not a native Lemon Squeezy integration. Lemon Squeezy remains the source of truth for the order. PingRoom carries one selected signal to the people in a Room.

## Decide whether you need a shared Room

A direct sales-alert service may be the shorter path for one operator. A shared PingRoom Room fits when a small group should see the same restrained signal without putting customer or order details on several lock screens.

| Need                                                                     | Better starting point                                  |
| ------------------------------------------------------------------------ | ------------------------------------------------------ |
| One person wants a direct mobile sale alert                              | A dedicated direct alert service                       |
| A bounded team needs one shared “sale recorded” signal                   | The signed relay below                                 |
| The alert must contain customer, product, amount, tax, or receipt detail | Keep that detail in Lemon Squeezy and open the order   |
| The event must update access, fulfillment, or accounting state           | Your application's durable webhook-processing workflow |

Lemon Squeezy's order payload can include a customer's name and email, currency, totals, product information, custom checkout data, and a signed receipt URL. None of that is required to tell a group that a sale needs review. The safer boundary is to verify the complete payload, retain only the store and order identities needed for replay control, then discard the rest.

## Prepare the Room and source webhook

You need:

- a private PingRoom Room whose members should receive this operational signal;
- PingRoom Pro for the Room's incoming webhook;
- a public HTTPS endpoint with a durable queue or database; and
- the Lemon Squeezy store ID that this endpoint should accept.

In PingRoom, create or choose the Room, then add an incoming webhook under **Settings → Connections → From another app**. Copy the complete URL into protected server-side configuration. The final path segment is a credential; do not place it in client code, screenshots, or logs. The [incoming-webhook walkthrough](/blog/webhook-to-push-notification) covers the Room-side setup.

In Lemon Squeezy, open **Settings → Webhooks**, add the public receiver URL, choose a random signing secret, and select only `order_created`. Lemon Squeezy recommends selecting only the events your application needs. Keep the signing secret and the PingRoom webhook URL in separate secret values.

The source request includes `X-Event-Name`, `X-Signature`, and a JSON:API body. Lemon Squeezy's [signature guide](https://docs.lemonsqueezy.com/help/webhooks/signing-requests) signs the unmodified request body with HMAC-SHA256. Capture the raw bytes first; parsing and re-serializing the JSON before verification can change them.

## Verify, filter, and save one minimal job

The receiver below shows the framework-neutral core. Its queue must implement an atomic `putIfAbsent` operation backed by durable storage—not an in-memory set in a production process.

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

const MAX_BODY_BYTES = 128 * 1024;

export async function receiveSale({ rawBody, headers, env, queue }) {
  const bytes = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
  if (bytes.length > MAX_BODY_BYTES) return { status: 413 };

  const signature = headers.get("x-signature") ?? "";
  if (!/^[a-f0-9]{64}$/i.test(signature)) return { status: 401 };

  const expected = createHmac("sha256", env.LEMON_SIGNING_SECRET)
    .update(bytes)
    .digest();
  const received = Buffer.from(signature, "hex");

  if (
    received.length !== expected.length ||
    !timingSafeEqual(received, expected)
  ) {
    return { status: 401 };
  }

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

  if (
    headers.get("x-event-name") !== "order_created" ||
    event?.meta?.event_name !== "order_created"
  ) {
    return { status: 200, code: "ignored_event" };
  }

  const { data } = event;
  const storeId = String(data?.attributes?.store_id ?? "");
  const orderId = typeof data?.id === "string" ? data.id : "";

  if (data?.type !== "orders") return { status: 200, code: "ignored" };
  if (!/^\d+$/.test(orderId)) return { status: 400 };
  if (storeId !== env.LEMON_STORE_ID) return { status: 200, code: "ignored" };
  if (data?.attributes?.status !== "paid")
    return { status: 200, code: "ignored" };
  if (data?.attributes?.test_mode !== false)
    return { status: 200, code: "ignored" };

  const eventKey = `lemonsqueezy:order_created:${storeId}:${orderId}`;

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

The important property is what reaches storage: `{ eventKey }`. Customer name, email, product, amount, currency, custom data, and receipt URL never enter the alert queue.

The event header and body must agree. The store must match exactly. The JSON:API resource must be an order with a numeric string ID, `paid` status, and `test_mode: false`. A valid but irrelevant event receives HTTP 200 so Lemon Squeezy does not retry it. A signature, JSON, identity, or storage failure receives a non-200 response so it stays visible as failed work.

Lemon Squeezy [requires HTTP 200 for successful capture](https://docs.lemonsqueezy.com/help/webhooks/webhook-requests). Other responses can be retried up to three additional times with exponential backoff. Return 200 only after the minimal job is durably saved or already exists.

![Two PingRoom avatars separate one accepted clay token from a duplicate while Pingo closes a drawer at a miniature relay bench.](/blog/lemon-squeezy-sale-push-notifications-1.webp)

Pingo, PingRoom's fictional CMO, is useful here as a visual reminder rather than a participant: one verified token goes into the tray; the duplicate stays out. He has no access to the Room, the store, or anyone's order data. The human team chooses the signal and remains responsible for it.

## Send fixed copy from the worker

The queue worker should build the Ping from reviewed constants, not from the original order payload:

```js
export async function sendSaleSignal({ job, env, fetchImpl = fetch }) {
  const body = JSON.stringify({
    title: "New sale recorded",
    message: "Open Lemon Squeezy to review the order",
    correlation_id: job.eventKey,
  });

  let response;
  try {
    response = await fetchImpl(env.PINGROOM_WEBHOOK_URL, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "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: "permanent_failure" };

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

Validate `PINGROOM_WEBHOOK_URL` as HTTPS before the worker starts. Treat network errors, HTTP 429, 5xx responses, malformed success responses, and `success !== true` as retryable. Treat other 4xx responses as configuration failures that need human attention. Keep the body and `Idempotency-Key` identical across retries.

The stable key handles two replay boundaries:

1. The receiver's atomic insert prevents the same Lemon Squeezy order event from creating several jobs.
2. PingRoom's idempotency handling can return the original successful result when the worker repeats the same request.

This is replay-safe processing, not an exactly-once guarantee. Durable queue semantics, retention, concurrency, and dead-letter handling still belong to the platform you choose.

## Test the failure paths before going live

Use Lemon Squeezy test mode, a test PingRoom Room, and non-sensitive sample data. Keep test and production secrets separate.

1. Send one correctly signed `order_created` fixture for the configured store with `status: paid` and `test_mode: false`. Confirm the queue stores only the derived event key and the Room receives the fixed two-line Ping.
2. Send the exact fixture again. Confirm the receiver says `duplicate`, the queue still has one job, and the Room does not receive a second Ping.
3. Change one raw byte after signing. Confirm the receiver returns 401 and stores nothing.
4. Try the wrong store, resource type, event name, status, and test-mode value. Confirm each validly signed request is acknowledged as ignored.
5. Make durable storage fail. Confirm the receiver returns a non-200 response so the source can retry rather than losing the event.
6. Make the PingRoom call return 429, 500, and a network error. Confirm the same job retries with the same body and key. Then test a permanent 400 response and route it to operational review.

Our recorded test exercises those paths with Lemon Squeezy-shaped fixtures, a stateful fake queue, and a local PingRoom mock. It also asserts that the stored job and outbound body do not contain the fixture's customer name, email, product, amount, currency, or receipt URL. No live store, Room, or phone was used, so the final harmless end-to-end check still belongs in your own setup.

## Read the signal correctly

Lemon Squeezy says [`order_created`](https://docs.lemonsqueezy.com/help/webhooks/event-types) occurs when a new order is successfully placed. The current [order object](https://docs.lemonsqueezy.com/api/orders/the-order-object) exposes several statuses; this recipe additionally requires `paid` and production mode before creating a job.

The Ping means only **a matching paid order was recorded and the relay accepted its event**. It does not prove accounting reconciliation, fulfillment, license delivery, payout settlement, or that every phone displayed a notification. Open Lemon Squeezy for the order and its current state. Use Lemon Squeezy's webhook log to inspect recent deliveries and resend an event when needed.

HTTP 200 from PingRoom means the Ping was accepted and queued. Connectivity, operating-system notification permissions, Focus modes, battery settings, and Room preferences still affect device display. PingRoom's incoming webhook also has a default five-second cooldown; a 429 response belongs on the worker's retry path, not in the source receiver.

Review the [complete PingRoom webhook contract](/documents/webhooks) for current fields, limits, errors, cooldown behavior, and acknowledgements. The [incoming webhook feature page](/features/incoming-webhook) describes the supported Room connection without presenting it as Lemon Squeezy-native.

When the six tests pass, [open PingRoom](/app/rooms), choose the destination Room, and activate only the sale signal that 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._
