Inbound attachments

Handle photos and other media on inbound MMS: read the attachments array on your reply webhook, then fetch bytes with a short-lived signed URL.

When a recipient replies with a photo or other media, the inbound reply webhook tells you the media is there — and nothing more. The webhook carries metadata only. Bytes are fetched separately, on demand, through a short-lived signed URL.

That split is deliberate, and it is what the rest of this guide is about.

The recipe in one picture

  1. Your webhook receives message.received with an attachments array.
  2. For each attachment you want to download in the background, call GET /api/v1/attachments/{id}/url?reveal=false.
  3. You get back a 60-second signed URL. Fetch the bytes and store or attach them on your side — or, if your users are in the Beep inbox anyway, just deep-link there and skip the download.

Step 1 — the attachments array on the webhook

An MMS reply with stored media includes an attachments array in the standard message.received body, appended last:

{
  "event": "message.received",
  "event_id": "e1000000-0000-0000-0000-000000000001",
  "workspace_id": "11111111-1111-1111-1111-111111111111",
  "conversation_id": "cc000000-0000-0000-0000-000000000001",
  "from": "+15555550100",
  "to": "+15555550199",
  "body": "Here's the photo you asked for",
  "received_at": "2026-01-01T12:00:00.000Z",
  "reply_destination_id": "bb000000-0000-0000-0000-000000000001",
  "attachments": [
    {
      "id": "aa000000-0000-0000-0000-000000000001",
      "content_type": "image/jpeg",
      "byte_size": 148230,
      "moderation_status": "visible",
      "created_at": "2026-01-01T12:00:00.100Z"
    }
  ]
}
FieldTypeWhat it means
idstringThe attachment id. This is what you pass to the signed-URL endpoint.
content_typestring or nullDetected from the stored bytes — not the carrier's declared type, which is not trustworthy. Populated for stored parts.
byte_sizenumber or nullStored size in bytes. Populated for stored parts.
moderation_statusstringvisible, reported, or removed. Point-in-time — see below.
created_atstring or nullISO-8601, per part. One MMS can carry several parts; use id for deduplication, and created_at with id as a sort tie-breaker.

The key is absent, not empty

For a text-only reply — the vast majority of traffic — there is no attachments key at all. Not [], not null. If you integrated before attachments existed, your webhook body is byte-for-byte what it always was, and your signature verification and parser keep working with no change.

Beep waits for the image before sending the webhook

For an MMS, Beep waits for its media to finish storing before sending message.received. The attachments array contains stored parts, with their detected content_type and byte_size. Request the signed URL and download the bytes; you do not need to poll for normal media ingestion. Text-only replies are forwarded immediately.

If an attachment is unavailable

If a part cannot be stored, Beep sends the reply with attachments_unavailable: true. Any successfully stored parts still appear in attachments; unavailable parts do not. Show an attachment-unavailable notice instead of polling. The flag is omitted when all media is available and on text-only replies.

Beep waits at most five minutes from receipt for processing or missing attachment metadata. If that deadline is reached, it sends the same unavailable result with any parts already stored. There is no second media-ready webhook if a delayed part recovers later. MMS layout descriptors (SMIL) are ignored and do not produce an unavailable notice.

A metadata-read failure on an MMS follows that same bounded wait, rather than forwarding an apparently text-only reply. The webhook still does not include the Beep message ID: event_id identifies a forwarding attempt and conversation_id identifies a thread. Neither is a message lookup ID. Deduplicate attachments by their attachment IDs.

There is no URL in the webhook — on purpose

You will not find a link, a path, or base64 bytes anywhere in the body. Every byte fetch goes through the signed-URL endpoint so that it can be checked against moderation state and recorded in an audit trail. A URL in a pushed body would bypass both — and it would sit in your request logs, your proxy logs, and your error tracker indefinitely.

Step 2 — get a signed URL

curl "https://app.beepmessaging.com/api/v1/attachments/aa000000-0000-0000-0000-000000000001/url?reveal=false" \
  -H "Authorization: Bearer beep_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "X-Workspace-Id: 11111111-1111-1111-1111-111111111111"
{
  "data": {
    "url": "https://storage.example.com/...&token=...",
    "expires_in": 60,
    "revealed": false
  }
}

This unattended-fetch example explicitly opts out of revealing the image. revealed reports the existing workspace state: it may still be true if someone previously revealed it; reveal=false does not hide it again.

The URL is valid for 60 seconds. That is short by design: it is a bearer credential for a private object, so it is meant to be used immediately by the process that asked for it, never stored, logged, emailed, or embedded in a page a browser might cache. Ask for a new one each time you need bytes.

Fetching the URL reveals the image for the workspace — pass reveal=false to avoid that

Every inbound image starts behind a cover in the Beep inbox. The first person to open it reveals it: a workspace-wide state change, and from then on everyone on the team sees the image instead of the cover.

Requesting a URL from this endpoint counts as opening it. reveal defaults to true, so a plain GET stamps that workspace-wide reveal, exactly as if someone had clicked the cover in the inbox. That is the right behaviour for the common case: a helpdesk or CRM pulling the image because a person is about to look at it.

It is the wrong behaviour for anything that fetches without a person behind it. A GET gets retried on timeout, prefetched by a client library, replayed from a queue, and hit by a staging environment pointed at production data — and each of those would silently reveal the image to the customer's team. If your integration is one of those, pass ?reveal=false explicitly: you get the same 60-second URL and nothing the workspace sees changes.

Either way, the fetch itself is recorded in the audit trail; only the reveal is team-visible.

Moderation responses are answers, not errors to retry

StatusWhenWhat to do
403The attachment is under review (moderation_status: reported)Do not retry. Show your users a placeholder. It may become available later, or never.
410The attachment was removed (moderation_status: removed)Terminal. Stop asking, and delete any copy you already took.
409Stored bytes are unavailableNot expected for normal newly delivered MMS. Show an unavailable notice; independently queried pending attachments may still return this status.
404Unknown id, or an id outside your workspaceA cross-workspace id is deliberately indistinguishable from a nonexistent one.

403 and 410 are by design, not failures of your integration. Media that arrives from the public phone network sometimes has to be withheld, and when it does, this API withholds it consistently — from your integration and from the web app alike. Build for it: a 403 should render a neutral placeholder in your UI, not an error toast and not a retry loop.

For the same reason, moderation_status has exactly three values — visible, reported, removed — on every surface. Do not write a fourth branch.

Also note that moderation_status in a webhook body is point-in-time. It was true when the body was signed. An attachment can be reported or removed afterwards, and the endpoint is the authority at the moment you fetch. Never cache a visible from a webhook as permission to serve bytes later.

Step 3 — fetch and attach, or deep-link

Fetch and attach. If your system stores its own copy — a helpdesk ticket, a case file, an object store — fetch inside the 60-second window and upload from your own backend:

const meta = await fetch(
  `https://app.beepmessaging.com/api/v1/attachments/${attachment.id}/url?reveal=false`,
  {
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'X-Workspace-Id': workspaceId,
    },
  }
);

if ([403, 409, 410].includes(meta.status)) {
  // Unavailable or withheld. Render a placeholder; normal MMS ingestion
  // already finished before the webhook was sent, so do not poll here.
  return null;
}

if (!meta.ok) throw new Error(`Attachment URL request failed: ${meta.status}`);
const { data } = await meta.json();
const download = await fetch(data.url);
if (!download.ok)
  throw new Error(`Attachment download failed: ${download.status}`);
const bytes = await download.arrayBuffer();
const contentType =
  download.headers.get('content-type') ||
  attachment.content_type ||
  'application/octet-stream';
// ...upload `bytes` to your own storage using `contentType`.
// Prefer the download response header when saving the file.

Deep-link instead. If the people who need to see the media already work in Beep, the simpler integration is to store nothing: put the conversation_id from the webhook into your ticket as a link back to the Beep inbox thread, and let Beep serve the image with moderation and audit already applied. Every copy you take is a copy you have to moderate, retain, and delete yourself.

The thread link is:

https://app.beepmessaging.com/workspace/{workspace_id}/inbox?conv={conversation_id}

Both values are already in the webhook body, so the link needs no API call to build.

A helpdesk connector is the common case for both approaches: the ticket comment carries the reply text, and the media is either attached by your connector (fetch-and-attach) or referenced by a link (deep-link).

Consent applies to attachments too

An attachment is not a standalone object. It belongs to a real message, in a real conversation, in one workspace — and it inherits that context entirely:

  • Workspace scoping is absolute. An API key can only resolve attachments in its own workspace. Anything else is a 404 that never confirms the id exists.
  • The same opt-out and consent rules that govern the conversation govern its media. Receiving a photo is not consent to be contacted, and it does not re-open a conversation with someone who has sent STOP.
  • Retention is yours once you copy it. Bytes you fetch and store leave Beep's moderation and deletion path. If an attachment is later removed, your copy is not — that obligation moves to you the moment you download.

Test-mode behaviour

Use a test API key and send to these reserved numbers:

  • +15005550107: a simulated inbound MMS with a downloadable sample PNG.
  • +15005550108: a simulated inbound MMS with attachments_unavailable: true.

The simulator stores a fixed sample in the private media bucket before forwarding. The signed webhook, workspace permissions, attachment URL API, reveal behavior and moderation checks are the same as for live images. No carrier call or billing occurs. The test numbers exercise the ready and unavailable results deterministically; they do not reproduce carrier download timing. The pending-to-ready transition is covered by the forwarding tests.

Test keys can still retrieve real attachments within their authorized workspace, and API retrieval reveals by default. Use ?reveal=false for background checks.


Did this page help you?