# How to Send Shopify Refund Alerts to a Shared Team Phone

> Verify Shopify's refund-created webhook, keep order and customer details in Shopify, and send one fixed signal to a private PingRoom Room.

- Published: 2026-11-10
- Author: PingRoom
- Category: Automation
- Topics: Shopify refund notification, Shopify refunds/create webhook, Shopify refund phone alert, webhook push notifications
- Canonical: https://pingroom.io/blog/shopify-refund-created-push-notifications
- Cover image: https://pingroom.io/blog/shopify-refund-created-push-notifications-cover.webp
- Cover description: Two PingRoom avatars verify a blank refund token at a miniature wooden gate while Pingo watches.

To send a Shopify refund alert to a shared group of phones, subscribe an app you control to Shopify's `refunds/create` webhook, verify the signed raw request, and durably queue one fixed Ping for a private PingRoom Room. Keep the order, customer, item, amount, and reason in Shopify. The lock-screen message should say only that a refund was created and needs review.

This is a custom webhook recipe, not a native Shopify integration. Shopify remains the refund record. PingRoom carries one selected state change to the people who need to notice it.

## Choose the smallest route that fits

Shopify Flow already has a **Refund created** trigger. Start there when the next action can remain inside Shopify or an installed app. Build a relay only when an existing Shopify app already needs to notify a bounded team in one shared Room.

| Need                                                              | Better starting point                                                                                                      |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Continue a workflow inside Shopify                                | [Shopify Flow's Refund created trigger](https://help.shopify.com/en/manual/shopify-flow/reference/triggers/refund-created) |
| Send one minimal signal from an app you control to a shared Room  | The verified relay below                                                                                                   |
| Review the customer, order, amount, items, reason, or transaction | Open the refund in Shopify                                                                                                 |
| You do not operate a Shopify app or durable queue                 | Use Flow or an operations tool your team already trusts                                                                    |

The wording matters. Shopify says `refunds/create` occurs when a refund is created without errors, **independent from the movement of money**. Do not turn that event into “money returned,” “refund settled,” or “customer repaid.” A defensible Ping is:

```json
{
  "action": null,
  "title": "Shopify refund created",
  "message": "Open Shopify to review the refund."
}
```

## Prepare one private Room and one app endpoint

You need:

- a Shopify app you control, installed on the selected store, with the order access required for the refund webhook;
- a public HTTPS endpoint that can read the untouched request body;
- the app client secret stored only on the server;
- a durable database transaction and queue; and
- a private PingRoom Room whose owner has Pro, with one incoming webhook for this signal.

Create the PingRoom incoming webhook under the Room's connection settings. Save the complete URL in your server's secret store as `PINGROOM_WEBHOOK_URL`. The final path segment is a credential. Do not paste it into Shopify configuration, source control, logs, or the article's code.

If your app manages subscriptions in `shopify.app.toml`, the narrow subscription looks like this:

```toml
[webhooks]
api_version = "2026-10"

[[webhooks.subscriptions]]
topics = ["refunds/create"]
uri = "/webhooks/shopify-refund"
```

Shopify also supports shop-specific subscriptions through the GraphQL Admin API. Use one ownership model; do not create both and then treat the two deliveries as separate refunds. Shopify gives each delivery an `X-Shopify-Webhook-Id` and gives deliveries from the same merchant action a shared `X-Shopify-Event-Id`.

## Verify first, then gate the store and topic

Shopify signs the exact raw HTTPS body with HMAC-SHA256 and sends the Base64 digest in `X-Shopify-Hmac-Sha256`. Capture the raw bytes before JSON middleware changes them. Compare the digest in constant time before trusting the payload, topic, or store header.

This dependency-free core shows the boundary. The framework adapter should normalize header names to lowercase. `claimDeliveryAndEnqueue` must atomically claim the Shopify delivery and write the queue job in the same durable transaction.

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

function validShopifyHmac(rawBody, provided, clientSecret) {
  const actual = Buffer.from(String(provided ?? ""), "base64");
  const expected = createHmac("sha256", clientSecret).update(rawBody).digest();
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

export async function receiveShopifyRefund({
  rawBody,
  headers,
  expectedShop,
  clientSecret,
  replaySecret,
  claimDeliveryAndEnqueue,
}) {
  if (
    !validShopifyHmac(rawBody, headers["x-shopify-hmac-sha256"], clientSecret)
  )
    return { status: 401 };

  if (headers["x-shopify-shop-domain"] !== expectedShop) {
    return { status: 204 };
  }
  if (headers["x-shopify-topic"] !== "refunds/create") {
    return { status: 204 };
  }

  JSON.parse(rawBody.toString("utf8")); // validate, then discard commerce fields
  const deliveryId = String(headers["x-shopify-webhook-id"] ?? "").trim();
  if (!/^[a-z0-9._:-]{1,255}$/i.test(deliveryId)) {
    return { status: 400 };
  }

  const digest = createHmac("sha256", replaySecret)
    .update(expectedShop)
    .update("\0")
    .update(deliveryId)
    .digest("hex");
  const key = `shopify-refund-${digest}`;
  const job = {
    headers: {
      "content-type": "application/json",
      "idempotency-key": key,
    },
    body: JSON.stringify({
      action: null,
      title: "Shopify refund created",
      message: "Open Shopify to review the refund.",
      correlation_id: key,
    }),
  };
  const claimed = await claimDeliveryAndEnqueue(
    { shop: expectedShop, deliveryId },
    job,
  );

  return { status: 200, outcome: claimed ? "queued" : "replay" };
}
```

Production code should also handle header names case-insensitively, bound the body size, validate the delivery ID, keep the expected shop in configuration, and derive an opaque PingRoom idempotency key with a separate relay secret. The tested source for this article exercises those cases without storing the refund body.

![Two PingRoom avatars keep a detailed blank parcel token in a locked box while one minimal signal waits beside a face-down phone and Pingo watches a duplicate outside the queue.](/blog/shopify-refund-created-push-notifications-1.webp)

The miniature scene is an example, not Shopify or PingRoom UI. [Pingo the Pigeon](/Pingo), PingRoom's fictional CMO, is watching the duplicate remain outside the queue. He cannot inspect a refund, access a private Room, or send a Ping. People configure and own the workflow.

## Queue before you acknowledge Shopify

Shopify expects an HTTPS response within five seconds. It retries failed deliveries up to eight times over four hours, and a repeatedly failing subscription can be removed. That creates a clear sequence:

1. Verify the HMAC against the raw body.
2. Gate the configured store and exact `refunds/create` topic.
3. Validate the delivery ID and JSON shape.
4. In one database transaction, claim the delivery ID and write the fixed Ping job.
5. Return `200` only after that transaction commits.
6. Let a worker send the queued job to PingRoom.

Do not wait for a phone receipt inside Shopify's five-second window. Do not return `200` and then put the only copy of the work into memory. If the durable transaction fails, return an error so Shopify can retry.

Use the same PingRoom `Idempotency-Key` every time the worker retries the same claimed delivery. The body must stay byte-for-byte equivalent for that key. A changed body with an old key is a conflict, not a new refund.

The queued job needs only:

| Queue field     | Value                                                   |
| --------------- | ------------------------------------------------------- |
| Destination     | Secret PingRoom webhook URL from server configuration   |
| Idempotency key | Opaque HMAC of the allowed shop and Shopify delivery ID |
| Title           | `Shopify refund created`                                |
| Message         | `Open Shopify to review the refund.`                    |
| Commerce fields | None                                                    |

The delivery ID is operational replay metadata. The refund body can be discarded after signature and shape validation because the phone signal deliberately carries no commerce detail.

## Rehearse the boundaries before activation

Use a Shopify development store or controlled test delivery and a test PingRoom Room:

1. Send one correctly signed `refunds/create` fixture for the configured store. Confirm that one fixed Ping job is queued.
2. Replay the exact delivery ID. Confirm that the relay returns success without queueing another Ping.
3. Send a correctly signed delivery for another store or topic. Confirm that it is acknowledged without creating a job.
4. Change one body byte after signing. Confirm that HMAC verification fails before parsing.
5. Make the durable transaction fail. Confirm that the endpoint returns an error and no in-memory job is treated as accepted.
6. Deliver the queued Ping job twice with the same idempotency key. Confirm that the Room receives one Ping.

Our recorded verifier uses signed Shopify-shaped local fixtures, a durable transaction stand-in, and a stateful local PingRoom mock. It checks source gates, privacy, duplicate delivery, queue failure, and idempotent Ping delivery. It does not use a live Shopify store, app secret, refund, customer record, PingRoom Room, phone, or notification receipt.

## Read the result correctly

A `200` from the Shopify endpoint means the relay durably accepted the delivery. A successful PingRoom response means the Ping was accepted and queued. Neither proves that money moved or that every phone displayed the alert.

Shopify remains authoritative for refund state, transaction movement, customer communication, delivery logs, and recovery after downtime. Phone display still depends on connectivity, operating-system notification permission, Focus modes, device state, and Room preferences.

Review Shopify's [webhook delivery verification](https://shopify.dev/docs/apps/build/webhooks/verify-deliveries) and [troubleshooting guidance](https://shopify.dev/docs/apps/build/webhooks/troubleshoot) before shipping. Review PingRoom's [incoming webhook contract](/documents/webhooks) for current fields, cooldowns, idempotency behavior, and errors. The [generic webhook walkthrough](/blog/webhook-to-push-notification) owns the Room-side setup.

When the six rehearsals pass, [open PingRoom](/app/rooms), choose the destination Room, and activate only the refund-created signal that the team is prepared to review.

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