Handling Email Attachments in Your Webhook

When an email arrives with attachments, they are included inline in the JSON payload โ€” base64-encoded, ready to decode. No separate request, no pre-uploaded URLs, no storage on our side. This guide shows how to work with them.

The attachment object

Each attachment appears as an object in the attachments array:

{
  "from": "alice@example.com",
  "to": "abc123@email-webhook.com",
  "subject": "Q1 report",
  "message": "See attached.",
  "attachments": [
    {
      "filename": "q1-report.pdf",
      "content": "JVBERi0xLjQKJeLjz9MKNiAwIG9iag..."
    },
    {
      "filename": "summary.xlsx",
      "content": "UEsDBBQABgAIAAAAIQDc..."
    }
  ]
}

content is the raw file data, base64-encoded. filename is taken from the email's Content-Disposition header. If the sender's mail client did not set a filename, it defaults to "unknown".

When no attachments are present, attachments is an empty array โ€” always safe to iterate without a null check.


Decoding attachments

Node.js

Save to disk:

import fs from "fs/promises";
import path from "path";

app.post("/webhook", express.json(), async (req, res) => {
  const { attachments } = req.body;

  for (const attachment of attachments) {
    const buffer = Buffer.from(attachment.content, "base64");
    const safeName = path.basename(attachment.filename);
    await fs.writeFile(`/tmp/${safeName}`, buffer);
  }

  res.sendStatus(200);
});

Upload directly to S3:

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "us-east-1" });

app.post("/webhook", express.json(), async (req, res) => {
  const { attachments } = req.body;

  for (const attachment of attachments) {
    const buffer = Buffer.from(attachment.content, "base64");
    await s3.send(new PutObjectCommand({
      Bucket: "my-bucket",
      Key: `attachments/${attachment.filename}`,
      Body: buffer,
    }));
  }

  res.sendStatus(200);
});

Python

Save to disk:

import base64
import os
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhook")
def handle_email():
    data = request.get_json()

    for attachment in data.get("attachments", []):
        content = base64.b64decode(attachment["content"])
        safe_name = os.path.basename(attachment["filename"])
        with open(f"/tmp/{safe_name}", "wb") as f:
            f.write(content)

    return "", 200

Upload to S3 with boto3:

import base64
import boto3

s3 = boto3.client("s3")

@app.post("/webhook")
def handle_email():
    data = request.get_json()

    for attachment in data.get("attachments", []):
        content = base64.b64decode(attachment["content"])
        s3.put_object(
            Bucket="my-bucket",
            Key=f"attachments/{attachment['filename']}",
            Body=content,
        )

    return "", 200

Go

package main

import (
    "encoding/base64"
    "encoding/json"
    "net/http"
    "os"
    "path/filepath"
)

type Attachment struct {
    Filename string `json:"filename"`
    Content  string `json:"content"`
}

type Payload struct {
    From        string       `json:"from"`
    Subject     string       `json:"subject"`
    Message     string       `json:"message"`
    Attachments []Attachment `json:"attachments"`
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    var payload Payload
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    for _, a := range payload.Attachments {
        data, err := base64.StdEncoding.DecodeString(a.Content)
        if err != nil {
            continue
        }
        safeName := filepath.Base(a.Filename)
        os.WriteFile("/tmp/"+safeName, data, 0644)
    }

    w.WriteHeader(http.StatusOK)
}

Determining the file type

The payload does not include a content-type field โ€” only the filename. If your handler needs to branch on file type, derive it from the extension:

import path from "path";
import mime from "mime-types"; // npm install mime-types

const ext = path.extname(attachment.filename).toLowerCase();
const contentType = mime.lookup(ext) || "application/octet-stream";
import mimetypes

content_type, _ = mimetypes.guess_type(attachment["filename"])
content_type = content_type or "application/octet-stream"

Size considerations

SMTP allows messages up to around 30 MB. Base64 encoding inflates binary data by roughly 33%, so a 10 MB file becomes approximately 13 MB in the JSON payload, and a 22 MB file approaches the practical ceiling.

For handlers that process all attachments synchronously:

  • Small files (under ~5 MB each): decoding into memory and writing to disk or uploading to S3 is fine.
  • Larger files: return 200 immediately and do the heavy work asynchronously:
app.post("/webhook", express.json(), async (req, res) => {
  res.sendStatus(200); // acknowledge before processing

  setImmediate(() => processAttachments(req.body.attachments));
});

For serverless functions with strict timeout limits (Cloudflare Workers at 10 s, Vercel at 30 s), this pattern is especially important. Decode, upload to cloud storage, and enqueue a job rather than processing the file inline.


Validating filenames

Filenames come from the sender's mail client and should be treated as untrusted input. Before writing to disk, always sanitise the name:

import path from "path";

// Strip directory components โ€” prevents path traversal
const safeName = path.basename(attachment.filename);
import os

safe_name = os.path.basename(attachment["filename"])

Never use the raw filename as a filesystem path without stripping directory separators first.


Next steps

  • To understand the full payload structure, see Getting Started.
  • To process attachments from AI-readable files (PDFs, images), see AI and LLM Pipelines.
  • To debug whether attachments arrived correctly, enable Message Logs โ€” they record the attachment count and total size per delivery without storing the content.