# Send PayPal Payment Capture Alerts to Your Team's Phones

> Verify PayPal's completed-capture webhook, keep payment details out of the alert, and send one fixed signal to a shared PingRoom Room.

- Published: 2026-10-31
- Author: PingRoom
- Category: Developers
- Topics: PayPal webhook, PayPal payment capture alert, webhook push notification, team payment notification
- Canonical: https://pingroom.io/blog/paypal-payment-capture-push-notifications
- Cover image: https://pingroom.io/blog/paypal-payment-capture-push-notifications-cover.webp
- Cover description: Two PingRoom avatars guide a plain clay token through a brass verification frame while Pingo watches beside a closed wooden box.

To send a PayPal payment-capture alert to a group of phones, subscribe one REST app to `PAYMENT.CAPTURE.COMPLETED`, verify every delivery with PayPal before trusting it, and save one derived event key before returning HTTP 200. A worker can then send fixed, privacy-minimal copy to a PingRoom incoming webhook.

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

## Decide what the alert should mean

A capture-completed event has a narrow meaning: PayPal says a payment capture completed. It is not proof that your team reconciled the payment, fulfilled an order, shipped anything, or closed its books. Refund, reversal, pending, and declined states have separate events.

| Need                                                                       | Better starting point                                      |
| -------------------------------------------------------------------------- | ---------------------------------------------------------- |
| One operator is well served by PayPal's own business notifications         | Keep the native notification path                          |
| A bounded team needs one shared “capture completed” signal                 | Use the verified relay below                               |
| The alert needs payer, amount, currency, order, invoice, or receipt detail | Keep that detail in PayPal and open the payment there      |
| The event changes fulfillment, access, or accounting state                 | Use your application's durable payment-processing workflow |

PayPal's webhook object can include an email address, amount, currency, order identity, fee breakdown, and API links. None of those fields needs to appear on several lock screens. This recipe verifies the complete event, keeps only PayPal's event ID for replay control, and discards the rest before the PingRoom job is stored.

## Prepare one Room and one PayPal 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 receiver on port 443, backed by a durable queue or database;
- one PayPal REST app, its webhook ID, client ID, and client secret; and
- the merchant ID that 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; do not put it in browser code, source control, screenshots, or logs. The [incoming-webhook walkthrough](/blog/webhook-to-push-notification) covers the Room-side setup.

In the PayPal developer dashboard, open the intended REST app, add the public receiver URL, and subscribe only to `PAYMENT.CAPTURE.COMPLETED`. Save the webhook ID shown for that listener. PayPal webhooks belong to a specific app: payments processed through another REST app do not generate events for this listener.

Keep sandbox and live credentials separate. PayPal's webhook simulator can exercise a listener, but its mock events do not support the postback verification method used below. For the final sandbox test, produce an actual sandbox event associated with the sandbox app.

## Verify the exact event before filtering it

PayPal's postback method sends the transmission headers, webhook ID, and webhook event to its `verify-webhook-signature` endpoint. The `webhook_event` must preserve the received event content. Capture the raw body before a framework parses or transforms it.

The core receiver below accepts an injected access-token function and an atomic durable queue. It restricts the PayPal API base to the documented live or sandbox host, verifies before acting on event fields, and stores only `{ eventKey }`.

```js
const MAX_BODY_BYTES = 128 * 1024;

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

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

  const read = (name) => headers.get(name) ?? "";
  const fields = {
    transmission_id: read("paypal-transmission-id"),
    transmission_time: read("paypal-transmission-time"),
    cert_url: read("paypal-cert-url"),
    auth_algo: read("paypal-auth-algo"),
    transmission_sig: read("paypal-transmission-sig"),
  };
  if (Object.values(fields).some((value) => value.length === 0)) {
    return { status: 401 };
  }

  const verificationBody =
    `{\"transmission_id\":${JSON.stringify(fields.transmission_id)},` +
    `\"transmission_time\":${JSON.stringify(fields.transmission_time)},` +
    `\"cert_url\":${JSON.stringify(fields.cert_url)},` +
    `\"auth_algo\":${JSON.stringify(fields.auth_algo)},` +
    `\"transmission_sig\":${JSON.stringify(fields.transmission_sig)},` +
    `\"webhook_id\":${JSON.stringify(env.PAYPAL_WEBHOOK_ID)},` +
    `\"webhook_event\":${rawJson}}`;

  let verification;
  try {
    verification = await fetchImpl(
      `${env.PAYPAL_API_BASE}/v1/notifications/verify-webhook-signature`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${await getAccessToken()}`,
          "Content-Type": "application/json",
        },
        body: verificationBody,
      },
    );
  } catch {
    return { status: 503 };
  }

  if (!verification.ok) return { status: 503 };
  const result = await verification.json().catch(() => null);
  if (result?.verification_status !== "SUCCESS") return { status: 401 };

  if (
    event?.event_type !== "PAYMENT.CAPTURE.COMPLETED" ||
    event?.resource_type !== "capture"
  ) {
    return { status: 200, code: "ignored_event" };
  }
  if (event?.resource?.payee?.merchant_id !== env.PAYPAL_MERCHANT_ID) {
    return { status: 200, code: "ignored_merchant" };
  }
  if (event?.resource?.status !== "COMPLETED") {
    return { status: 200, code: "ignored_status" };
  }
  if (!/^[A-Za-z0-9-]{8,128}$/.test(event?.id ?? "")) {
    return { status: 400, code: "invalid_event_id" };
  }

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

Validate `PAYPAL_API_BASE` once at startup against exactly `https://api-m.paypal.com` or `https://api-m.sandbox.paypal.com`. Obtain the OAuth access token server-side with the client-credentials grant and cache it until shortly before expiry; never expose the client secret to the webhook caller or a browser.

Do not return 2xx until the minimal job exists durably or was already present. PayPal retries non-2xx deliveries up to 25 times over three days. A valid but irrelevant event gets 200 because repeating it cannot make it relevant; a verification, configuration, identity, or storage failure stays non-2xx so it remains visible as unfinished work.

![Two PingRoom avatars separate one verified clay token from its duplicate at a wooden queue drawer while Pingo watches the boundary.](/blog/paypal-payment-capture-push-notifications-1.webp)

Pingo, PingRoom's fictional CMO, has a modest role in this illustrated example: he watches the duplicate stay outside the queue. He cannot inspect PayPal 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 from the PayPal event:

```js
export async function sendCaptureSignal({
  job,
  webhookUrl,
  fetchImpl = fetch,
}) {
  const body = JSON.stringify({
    title: "PayPal payment captured",
    message: "Open PayPal to review the payment",
    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" };
}
```

Validate the PingRoom destination as HTTPS before the worker starts. Keep the body and `Idempotency-Key` unchanged across retries. The atomic insert stops repeated PayPal deliveries from creating several jobs, while PingRoom's idempotency handling lets a worker retry one request without intentionally creating another Ping.

That is replay-safe processing, not an exactly-once guarantee. Queue retention, worker concurrency, dead-letter handling, and operational review still belong to your infrastructure.

## Test the boundaries, not just the happy path

Use a sandbox app, a test Room, and non-sensitive sample data.

1. Produce one actual sandbox `PAYMENT.CAPTURE.COMPLETED` event for the configured app and merchant. Confirm verification succeeds, the queue stores only the event key, and the Room receives the fixed two-line Ping.
2. Replay the same event. Confirm the receiver reports `duplicate`, the queue still has one job, and the worker does not create a second alert.
3. Remove or change a transmission header. Confirm the receiver returns a non-2xx status and stores nothing.
4. Try a pending capture, a different event type, and the wrong merchant. Confirm each successfully verified but irrelevant event is acknowledged and ignored.
5. Make PayPal verification or durable storage unavailable. Confirm the receiver returns non-2xx so PayPal can retry.
6. Make the PingRoom call return 429, 500, and a network error. Confirm the worker retries with the same body and key. Treat other 4xx responses as configuration failures for human review.

Our recorded check exercises these paths with PayPal-shaped fixtures, a mocked OAuth/verification service, a stateful in-memory queue, and a mocked PingRoom receiver. It also asserts that payer name, email, amount, currency, order ID, and invoice ID never enter the stored job or outbound Ping. No live PayPal account, live PingRoom Room, or physical phone was used, so the final harmless end-to-end test remains part of your own rollout.

## Read the result conservatively

The Ping means only **PayPal reported a matching capture as completed, the relay verified that delivery, and the minimal job reached PingRoom**. Open PayPal to review the payment and its current state. Use PayPal's Webhook Events dashboard to inspect delivery status or 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 whether a phone displays it. The incoming webhook also has a default five-second cooldown; a 429 response belongs on the worker's retry path.

PayPal documents the [app-specific webhook model and retry behavior](https://developer.paypal.com/api/rest/webhooks), the [verification methods and required headers](https://developer.paypal.com/api/rest/webhooks/rest/), and the distinct [`PAYMENT.CAPTURE.*` event meanings](https://developer.paypal.com/api/rest/webhooks/event-names). Review PingRoom's [complete webhook contract](/documents/webhooks) for current fields, limits, errors, cooldown behavior, and acknowledgements. The [incoming webhook feature page](/features/incoming-webhook) describes the Room connection without presenting it as PayPal-native.

When the six tests pass, [open PingRoom](/app/rooms), choose the destination Room, and activate only the capture signal its members are ready to act on.

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