· 4 min · TextMeFlow Team

Handling Incoming Photos, Documents and Voice Notes from WhatsApp Webhooks

Most webhook guides only cover the easy case: a customer types a reply, you get a string, you store it. But a real conversation includes a photo of a damaged part, a signed PDF, a voice note instead of typing an answer. If your webhook handler only expects message to be text, that traffic either crashes it or gets silently dropped. Here's how incoming media actually arrives and how to handle it.

What arrives at your endpoint

Every inbound message — text or media — hits your webhook URL as the same POST with the same JSON shape. The field that changes is type:

{
  "type": "image",
  "from": "32472932208",
  "from_name": "Jan Janssens",
  "to": "32499123456",
  "file": "https://cdn.textmeflow.eu/media/a1b2c3d4.jpg",
  "message": "here's the damage on the bumper",
  "from_me": false,
  "wa_message_id": "3EB0A1B2C3D4E5F6",
  "message_id": null
}

type is one of text, image, audio, video, document or location. For a plain text message, file is the literal string "null" (kept that way for drop-in compatibility with TextMeBot-style integrations). For image, video, audio and document, file is a URL to the media file, hosted by TextMeFlow — fetch it before you need it, since you shouldn't assume it stays available indefinitely. A photo sent without a caption arrives with an empty message; a document carries the original filename in message instead of caption text. Full field reference is in the inbound webhooks documentation.

Branch on type before you touch message

The bug we see most often: code that does db.save(body.message) and never checks type. That's fine for text, but for a document you'll store a filename instead of content, and for image/audio/video you'll store an empty string and silently lose the file. Branch first:

app.post('/textmeflow-inbound', async (req, res) => {
  // ...signature check first, see /docs/webhooks...
  const body = JSON.parse(raw.toString('utf8'));

  switch (body.type) {
    case 'text':
      await saveReply(body.from, body.message);
      break;
    case 'image':
    case 'video':
    case 'audio':
    case 'document':
      await downloadAndStore(body.from, body.file, body.message, body.type);
      break;
    case 'location':
      const [lat, lng] = body.message.split(',');
      await saveLocation(body.from, lat, lng);
      break;
  }

  res.sendStatus(200);
});

location is the odd one out: there's no file to fetch, message is just the string "lat,lng" — useful for a service call confirming "I'm at the address" or a courier pickup request.

Downloading the file

Treat the file URL like any other remote fetch — download it server-side, don't hand a hotlinked TextMeFlow URL to your frontend or a third party. A minimal download-and-store step:

async function downloadAndStore(from, url, caption, type) {
  const res = await fetch(url);
  const buffer = Buffer.from(await res.arrayBuffer());
  const ext = { image: 'jpg', video: 'mp4', audio: 'ogg', document: 'pdf' }[type];
  const key = `inbound/${from}/${Date.now()}.${ext}`;
  await storage.put(key, buffer);
  await db.saveAttachment({ from, key, caption, type });
}

A garage receiving a photo of a dented panel, a moving company getting a signed inventory PDF back, or a clinic receiving a voice note from a patient who'd rather talk than type — all three go through this exact same path. If your business needs the actual MIME type or extension rather than a guess, read it off the downloaded bytes or the response's Content-Type header instead of trusting the URL's suffix.

Don't double-save your own outgoing messages

from_me: true means the message in the payload was sent by your number — through the API or typed by hand in WhatsApp itself. That's how you reconstruct a full conversation thread. When from_me is true and the message was sent via the API, message_id carries the id you got back when you sent it, so you can match it against your own outbox and avoid storing it twice. message_id is null for anything typed by hand and for everything that comes in from the customer — use wa_message_id as your general dedupe key instead, since retries reuse the same value.

Verify the signature before any of this

None of the above should run before you've checked X-TextMeFlow-Signature against the raw request body — see verifying WhatsApp webhook signatures for the HMAC check in PHP, Node and Python. Skipping it means anyone who finds your endpoint URL can post fake "incoming photo" events into your system.

Incoming media is where a lot of genuinely useful automation lives — proof-of-delivery photos, signed contracts, voice-note support tickets — and it only takes a few extra lines of branching to handle it correctly instead of dropping it on the floor. Start free and wire up a webhook endpoint against your own number to see the payloads for yourself.

Zelf WhatsApp-berichten versturen via API?

Gratis voor altijd tot 50 berichten/maand. QR scannen en binnen 5 minuten verstuur je je eerste bericht.

Gratis voor altijd