Skip to main content
All posts
Developers6 min read

Send Amazon S3 Upload Alerts to Your Team's Phones

Filter one S3 object-created event through Lambda, send a privacy-minimal shared Room Ping, and keep S3 as the source of truth.

Two PingRoom avatars place a sealed clay parcel into a storage alcove while Pingo watches a separate signal token move toward a tray.

To send a shared phone alert when a file lands in Amazon S3, configure an S3 ObjectCreated notification for one bucket, prefix, and suffix; invoke a same-Region Lambda function; and let that function send fixed, non-sensitive copy to a PingRoom incoming webhook. Keep the object key, uploader, metadata, and file contents in AWS.

This is a webhook recipe, not a native Amazon S3 integration. The Room owner needs PingRoom Pro. Amazon S3 remains the source of truth, and an object-created event does not prove that a later import, scan, or business process succeeded.

First, choose the event that means the work is ready

“A file was uploaded” can describe three different moments. Pick the one your team can act on.

Human momentBetter signalWhy
A normal PUT, POST, COPY, or completed multipart upload created an objectS3 s3:ObjectCreated:*, narrowed by prefix and suffixThe object now exists in S3.
An S3 File Gateway finished uploading a fileFile Gateway upload notificationAWS notes that an S3 object-created notification can refer to an intermediate upload in this workflow.
A scanner, converter, import, or data pipeline finishedAn explicit completion event or marker from that processorObject creation says nothing about downstream success.

AWS documents that S3 Event Notifications are delivered at least once. Its event-types reference says they can arrive out of order and can rarely be duplicated. AWS also warns that writing back to the triggering bucket can create a loop. Use two buckets or a narrow incoming prefix if the function writes any S3 output.

For this worked example, imagine a small operations team that reviews low-volume CSV arrivals under incoming/approved/. Pingo, PingRoom's fictional CMO, is the illustrated companion: he watches sealed parcels reach the right sorting gate, but he cannot inspect the files, access the private Room, or send a Ping. The human team chooses the event and owns the alert copy.

Create the Room connection

Create or choose a private Room for the people who need the signal. Open Settings → Connections → From another app, add an incoming webhook, leave its Quick Ping on Custom message, and copy the complete webhook URL.

Treat that URL as a credential. Do not put it in source control, object metadata, logs, or browser code. The incoming-webhook guide covers the Room-side setup, and the webhook reference lists the current request fields, idempotency behavior, cooldown, and rate limits.

The fixed phone copy in this recipe is deliberately small:

{
  "action": null,
  "title": "New S3 files arrived",
  "message": "Open Amazon S3 to review the new files"
}

The title fits PingRoom's 40-character limit, and the message fits the private-Room limit. It tells the group where to verify the event without exposing a bucket, object key, presigned URL, uploader, customer, or file content on a lock screen.

Configure one narrow S3 trigger

Create a Node.js Lambda function in the same AWS Region as the bucket. Give S3 permission to invoke it, then add an S3 trigger. The S3 and Lambda guide documents the asynchronous invocation, event shape, and resource-policy requirement.

  • Event type: All object create events (s3:ObjectCreated:*).
  • Prefix: incoming/approved/.
  • Suffix: .csv.

Use your real prefix and suffix. If only one creation operation is meaningful, choose that narrower event instead of the wildcard.

Set these function variables:

EXPECTED_BUCKET=selected-upload-bucket
EXPECTED_PREFIX=incoming/approved/
EXPECTED_SUFFIX=.csv
PINGROOM_WEBHOOK_URL=<complete secret URL>

The sample uses an environment variable so the handler is reproducible. For production, load the webhook URL from AWS Secrets Manager, cache it during the Lambda execution environment's lifetime, and give the function permission to read only that secret. Anyone who can read the resolved URL can try to fire the Room.

Filter again in code and send fixed copy

Trigger filters reduce invocations. The handler should still verify the event source, event name, bucket, decoded key, prefix, suffix, and S3 sequencer before it sends anything.

This dependency-free Node handler combines every matching record in one Lambda invocation into one Ping. It hashes the sorted event facts into a stable request identity but never forwards those facts to PingRoom.

import { createHash } from "node:crypto";

const fixedPing = {
  action: null,
  title: "New S3 files arrived",
  message: "Open Amazon S3 to review the new files",
};

function decodeKey(rawKey) {
  try {
    return decodeURIComponent(String(rawKey ?? "").replace(/\+/g, " "));
  } catch {
    return null;
  }
}

function prepareSignal(event) {
  const selected = (Array.isArray(event?.Records) ? event.Records : [])
    .flatMap((record) => {
      const rawKey = record?.s3?.object?.key;
      const key = decodeKey(rawKey);
      const sequencer = record?.s3?.object?.sequencer;
      const matches =
        record?.eventSource === "aws:s3" &&
        String(record?.eventName ?? "").startsWith("ObjectCreated:") &&
        record?.s3?.bucket?.name === process.env.EXPECTED_BUCKET &&
        key !== null &&
        key.startsWith(process.env.EXPECTED_PREFIX ?? "") &&
        key.endsWith(process.env.EXPECTED_SUFFIX ?? "") &&
        typeof sequencer === "string" &&
        /^[a-f0-9]+$/i.test(sequencer);

      return matches
        ? [
            {
              eventName: record.eventName,
              bucket: record.s3.bucket.name,
              rawKey: String(rawKey),
              sequencer: sequencer.toUpperCase(),
            },
          ]
        : [];
    })
    .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));

  if (selected.length === 0) return null;
  const digest = createHash("sha256")
    .update(JSON.stringify(selected), "utf8")
    .digest("hex");
  const eventKey = `s3:${digest}`;
  return {
    eventKey,
    body: { ...fixedPing, correlation_id: eventKey },
  };
}

export async function handler(event) {
  const signal = prepareSignal(event);
  if (!signal) return { delivered: false, reason: "no_matching_records" };

  const response = await fetch(process.env.PINGROOM_WEBHOOK_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": signal.eventKey,
    },
    body: JSON.stringify(signal.body),
  });
  const payload = await response.json().catch(() => null);
  if (!response.ok || payload?.success !== true) {
    throw new Error(`PingRoom delivery failed: HTTP ${response.status}`);
  }
  return { delivered: true };
}

The production fixture for this article also rejects malformed sequencers and non-HTTPS destinations. It was tested with mocked S3 events and mocked PingRoom responses; no live AWS account or Room was used.

Two PingRoom avatars sort matching event tokens at a small filter gate while Pingo observes duplicate tokens behind a divider.

Know what the idempotency key does—and does not do

For the same set of S3 records, sorting before hashing produces the same key even if their array order changes. An exact Lambda retry can therefore reuse the same PingRoom Idempotency-Key and body.

PingRoom stores incoming-webhook idempotency receipts for at least one hour. That protects the normal immediate retry case; it is not a permanent deduplication ledger. S3 can retry later, and concurrent Lambda invocations can also hit the webhook's cooldown. If a duplicate phone signal would cause harmful action, put the event behind a durable queue and store processed event identities before sending. If the source is high-volume, aggregate deliberately or route through SNS, SQS, or EventBridge rather than treating each object as a lock-screen event.

Also keep the payload identical on retry. Adding timestamps, object counts, or changing copy while reusing a key can produce an idempotency conflict.

Test the boundary, not only the happy path

Before connecting an important bucket, use a non-sensitive test object and check:

  1. One matching CSV produces one fixed Room Ping.
  2. A wrong bucket, prefix, suffix, or non-create event produces no Ping.
  3. A key containing spaces or URL-encoded characters is filtered correctly but never appears in the Ping.
  4. Replaying the same S3 fixture reuses the same request identity and body.
  5. Two records in a different array order produce the same identity.
  6. A PingRoom error makes the Lambda invocation fail so AWS can apply the function's configured retry and dead-letter policy.
  7. A locked test phone reveals only the approved fixed copy.

Keep CloudWatch logs free of the webhook URL and unnecessary object details. Set a bounded asynchronous invocation retry policy and failure destination so a broken credential is visible without retrying forever.

Read the alert conservatively

The useful claim is: Amazon S3 emitted a matching object-created event, and PingRoom accepted a bounded shared signal. It does not mean a particular phone displayed the alert, the object is safe, a File Gateway upload finished, or downstream work succeeded.

For a build-specific AWS workflow, use the separate CodeBuild success-alert recipe. For this S3 workflow, open PingRoom Rooms, create the private destination Room, store its incoming-webhook URL as a secret, and run the seven boundary checks with one harmless test object.

PingRoom

Short-form communication.

View all posts