# How to Send Xero Customer Payment Alerts to a Shared Room

> Verify one Xero accounts-receivable payment webhook, forward fixed privacy-minimal copy, and keep Xero authoritative for invoice and reconciliation details.

- Published: 2026-11-08
- Author: PingRoom
- Category: Developers
- Topics: Xero payment notification, Xero payment webhook, Xero phone alert, webhook push notifications
- Canonical: https://pingroom.io/blog/xero-customer-payment-push-notifications
- Cover image: https://pingroom.io/blog/xero-customer-payment-push-notifications-cover.webp
- Cover description: Pingo and two PingRoom avatars at a miniature bookkeeping counter while one blank payment marker passes a brass verification gate.

To send a Xero customer-payment alert to a small group’s phones, receive Xero’s **Payment Create** webhook at a server-side relay, verify the raw-body signature, and accept only an **`ACCRECPAYMENT`** with **`AUTHORISED`** status for one selected Xero organization. Queue fixed copy, acknowledge Xero, then let a worker call a PingRoom incoming webhook with a hashed replay key.

Keep Xero authoritative. The shared Ping should say **Xero customer payment recorded**, not **Invoice paid** or **Money reconciled**. One payment can be partial, and the webhook’s small event object is not bank-reconciliation evidence.

This is a webhook recipe, not a native Xero integration or accounting control.

## Decide whether you need another alert

Xero’s Accounting app can already [notify an authorized user when a customer pays](https://www.xero.com/us/accounting-software/send-invoices/). Start there when the right person uses Xero and needs the invoice, amount, or customer context.

Add a shared Room only when a small, stable group needs the same low-context interruption outside the accounting system.

| Need                                                     | Best owner                               |
| -------------------------------------------------------- | ---------------------------------------- |
| See the customer, invoice, amount, currency, or payment  | Xero                                     |
| Confirm whether an invoice is fully paid                 | Xero’s invoice record                    |
| Confirm bank reconciliation or settlement                | Xero plus the team’s bank evidence       |
| Tell a bounded group that Xero recorded one AR payment   | Signed relay → private PingRoom Room     |
| Trigger bookkeeping, fulfillment, or revenue recognition | The team’s approved operational controls |

Use fixed phone copy:

- **Title:** Xero customer payment recorded
- **Message:** Open Xero for invoice and reconciliation details.

Do not send the customer name, invoice number, amount, currency, bank account, payment reference, Xero URL, or raw resource ID to the Room. The Ping moves attention to the record; it does not copy the record onto shared lock screens.

## Prepare one organization-scoped webhook

You need:

- a Xero developer app connected to at least one organization;
- a Payment webhook endpoint on HTTPS port 443;
- the Xero Webhook Key in protected server-side configuration;
- the selected organization’s tenant ID in protected configuration;
- a durable queue that can create a job once by key; and
- a private PingRoom Room whose owner has [PingRoom Pro and an incoming webhook](/features/incoming-webhook).

Xero configures webhooks per app and can send events for every organization connected to that app. The tenant allowlist in the relay is therefore part of the security boundary.

In PingRoom, open the Room and add an incoming webhook under **Settings → Connections → From another app**. Save its complete URL as `PINGROOM_WEBHOOK_URL`. The URL is a credential; keep it out of browser code, mobile apps, source control, screenshots, logs, and analytics.

The [general incoming-webhook guide](/blog/webhook-to-push-notification) covers the Room setup. PingRoom’s [webhook reference](/documents/webhooks) lists the current request fields, limits, idempotency behavior, cooldown, and error responses.

## Verify the exact body before parsing it

Xero puts a Base64-encoded HMAC-SHA256 digest in `x-xero-signature`. Compute the digest over the unmodified request bytes with the Webhook Key, then compare the bytes in constant time.

The dependency-free Node receiver below shows the full boundary. Its `queue.enqueueOnce(key, digest, job)` adapter must durably persist the first job, return `duplicate` for an exact replay, `conflict` for the same key with different content, and `failed` when it cannot commit.

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

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

const hash = (value) => createHash("sha256").update(value).digest("hex");

function signedByXero(rawBody, supplied, webhookKey) {
  if (!supplied) return false;
  const expected = createHmac("sha256", webhookKey).update(rawBody).digest();
  const actual = Buffer.from(supplied, "base64");
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

function selected(event, tenantId) {
  return (
    event?.eventType === "CREATE" &&
    event?.eventCategory === "PAYMENT" &&
    event?.tenantId === tenantId &&
    event?.tenantType === "ORGANISATION" &&
    event?.data?.PaymentType === "ACCRECPAYMENT" &&
    event?.data?.Status === "AUTHORISED"
  );
}

export async function handleXeroWebhook(request, env) {
  if (request.method !== "POST") {
    return Response.json({ error: "method_not_allowed" }, { status: 405 });
  }

  const rawBody = Buffer.from(await request.arrayBuffer());
  if (rawBody.length === 0 || rawBody.length > 256 * 1024) {
    return Response.json({ error: "invalid_body_size" }, { status: 413 });
  }
  if (
    !signedByXero(
      rawBody,
      request.headers.get("x-xero-signature"),
      env.XERO_WEBHOOK_KEY,
    )
  ) {
    return Response.json({ error: "invalid_signature" }, { status: 401 });
  }

  let payload;
  try {
    payload = JSON.parse(rawBody.toString("utf8"));
  } catch {
    return Response.json({ error: "invalid_json" }, { status: 400 });
  }
  if (!Array.isArray(payload?.events)) {
    return Response.json({ error: "invalid_payload" }, { status: 400 });
  }

  // Xero validates a new endpoint with correctly signed empty event batches.
  if (payload.events.length === 0) {
    return Response.json({ accepted: true, validation: true });
  }

  let created = 0;
  let duplicates = 0;
  let ignored = 0;

  for (const event of payload.events) {
    if (!selected(event, env.XERO_TENANT_ID)) {
      ignored += 1;
      continue;
    }
    if (!UUID.test(event.resourceId || "")) {
      return Response.json({ error: "invalid_resource_id" }, { status: 400 });
    }

    const eventKey = `xero-payment:${hash(
      `${env.XERO_TENANT_ID}:${event.resourceId}`,
    )}`;
    const requestBody = {
      action: null,
      title: "Xero customer payment recorded",
      message: "Open Xero for invoice and reconciliation details.",
      correlation_id: eventKey,
    };
    const job = { eventKey, request: requestBody };
    const result = await env.queue.enqueueOnce(
      eventKey,
      hash(JSON.stringify(job)),
      job,
    );

    if (result.status === "created") created += 1;
    else if (result.status === "duplicate") duplicates += 1;
    else if (result.status === "conflict") {
      return Response.json({ error: "event_conflict" }, { status: 409 });
    } else {
      return Response.json({ error: "queue_unavailable" }, { status: 503 });
    }
  }

  return Response.json(
    { accepted: true, created, duplicates, ignored },
    { status: 202 },
  );
}
```

Adapt the exported function to your framework’s raw-body route. Do not place a JSON body parser in front of it. Store `XERO_WEBHOOK_KEY` and `XERO_TENANT_ID` in protected server configuration.

Xero’s [webhook setup and security guide](https://developer.xero.com/documentation/guides/webhooks/overview) documents the signature, intent-to-receive validation, HTTPS requirement, five-second response limit, retry behavior, and need for idempotency. Its [Payment webhook reference](https://developer.xero.com/documentation/guides/webhooks/payments) documents the small event object used by the allowlist.

## Deliver fixed copy from the worker

The receiver returns only after the matching job is durably stored. A separate worker posts that job to PingRoom and retries non-success responses with the same key:

```js
export async function deliver(job, env) {
  const response = await fetch(env.PINGROOM_WEBHOOK_URL, {
    method: "POST",
    headers: {
      accept: "application/json",
      "content-type": "application/json",
      "idempotency-key": job.eventKey,
    },
    body: JSON.stringify(job.request),
    signal: AbortSignal.timeout(8000),
  });

  const body = await response.json().catch(() => null);
  if (!response.ok || body?.success !== true) throw new Error("retry");
}
```

The queue job contains a hashed event key and fixed PingRoom request. It does not retain the Xero tenant ID, payment resource ID, resource URL, invoice, customer, amount, currency, bank, or reference.

Xero expects a valid request to receive a `2xx` response within five seconds. A durable enqueue lets the receiver acknowledge quickly without pretending the phone already displayed anything. If the queue cannot commit, return a failure so Xero can retry instead of silently losing the event.

## Why the receiver checks six fields

Xero’s payment event is deliberately small. The relay still needs an exact allowlist:

| Check                        | Purpose                                                                 |
| ---------------------------- | ----------------------------------------------------------------------- |
| Valid HMAC over the raw body | Reject altered or unauthenticated requests                              |
| Selected `tenantId`          | Keep other organizations connected to the app out of this Room          |
| `eventType: CREATE`          | Ignore later payment updates, including deletion updates                |
| `eventCategory: PAYMENT`     | Ignore other Xero resource categories                                   |
| `PaymentType: ACCRECPAYMENT` | Select a payment on a sales invoice, not a bill or another payment type |
| `Status: AUTHORISED`         | Reject a malformed or mismatched create event                           |

The event does not say that the invoice’s remaining balance is zero. Do not fetch the full payment merely to make the lock-screen copy more detailed. If the team needs amount, invoice status, customer identity, or reconciliation evidence, send them to Xero through their normal authorized path.

![Pingo and two PingRoom avatars verify one blank payment marker while an identical replay waits behind a small wooden stop.](/blog/xero-customer-payment-push-notifications-1.webp)

_[Pingo the Pigeon](/Pingo), PingRoom’s fictional Chief Marketing Officer, is watching the duplicate marker. He cannot open Xero, inspect a payment, enter a private Room, or send a Ping. The people in the workflow verify every financial state._

_These AI-generated editorial illustrations use approved Pingo and PingRoom avatar references. The people and tabletop workflow are illustrative, not customers or evidence of a payment._

## Rehearse ten boundaries before live traffic

Use a Xero test organization and a separate non-production PingRoom Room.

1. A correctly signed empty validation batch returns `2xx` and creates no job.
2. A signed `CREATE` `PAYMENT` for the selected organization, with `ACCRECPAYMENT` and `AUTHORISED`, queues one fixed-copy job.
3. The exact same payment replay does not queue a second job.
4. Two matching payments in one batch create two independently keyed jobs.
5. A payment for another organization is acknowledged and ignored.
6. An update, non-payment category, accounts-payable payment, or deleted payment is ignored.
7. A bad or missing signature returns `401`.
8. Malformed JSON or a malformed resource ID is rejected.
9. A queue failure returns a retryable failure instead of acknowledging lost work.
10. PingRoom `429`, `5xx`, or a non-success body keeps the worker job retryable with the same idempotency key.

We ran these boundaries against the exact receiver and worker with locally signed Xero-shaped batches, an in-memory durable-queue stand-in, and a local PingRoom contract mock. Fifty-eight deterministic assertions passed. The mock also checked that the raw tenant ID, payment ID, and synthetic customer, invoice, amount, currency, bank, reference, and resource-URL values never entered the queued job.

No live Xero organization, developer app, public relay, PingRoom Room, payment, invoice, customer data, bank data, network request, or phone delivery was used. A passing fixture verifies the relay contract, not a real accounting lifecycle.

## Keep the follow-up inside Xero

Agree on a small, non-urgent procedure before enabling the Room:

1. An authorized person opens Xero through the team’s normal access path.
2. They inspect the current payment and invoice state.
3. They confirm whether the payment is partial or completes the invoice.
4. They follow the team’s existing reconciliation and operational controls.
5. They use the accounting record—not the Ping—as evidence for the next action.

PingRoom’s incoming webhook accepts one new Ping every five seconds by default and can return HTTP `429`. An exact same-key replay is resolved before that cooldown because it does not create a new Ping. If several distinct payments can arrive together, keep them in the durable queue and decide whether the group needs each one, a digest elsewhere, or no phone interruption at all.

A successful PingRoom response means the Ping was accepted and entered PingRoom’s normal delivery path. What a phone displays still depends on connectivity, operating-system policy, notification permission, Focus settings, and Room mutes.

When the test-organization rehearsal passes, [open PingRoom](/app/rooms), create a private payment-update Room, and connect only the signal your group is prepared to verify in Xero.

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