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
200immediately 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.