Skip to main content
All articles
· Updated April 19, 2026

Send Files via Discord Webhook (Images, Attachments & Multipart)

Upload files through Discord webhooks using multipart/form-data. Working examples in Python, JavaScript, curl and PHP — covers images, PDFs, logs and embed attachments.

file uploadattachmentsmultipartwebhookimagesdiscord apitutorial
Send Files via Discord Webhook (Images, Attachments & Multipart)

Discord webhooks aren’t just for text. You can upload screenshots, log files, PDFs, archives — anything up to 25 MB (Discord’s 2026 free-tier file limit). The catch: you must switch from JSON to multipart/form-data. Get the format wrong and Discord rejects the upload silently with a generic 400.

This guide shows the exact request shape and working code for Python, JavaScript, curl and PHP — including how to attach a file inside an embed image.

How File Uploads Work

Normally you POST application/json:

{ "content": "Hello" }

For files, you POST multipart/form-data with two parts:

  1. payload_json — your usual webhook JSON (content, embeds, username, etc.)
  2. files[N] — the binary file content, indexed from 0

That’s it. The same JSON you’d send normally goes inside payload_json; the file rides alongside it.

File Limits (2026)

TierMax single fileMax files per message
Free25 MB10
Nitro Basic50 MB10
Nitro500 MB10
Boost (Server)50–100 MB based on level10

Webhooks use the server’s boost tier when sending. So a webhook in a Level 3 server can send 100 MB files even from a free account.

Python — requests

import requests

WEBHOOK_URL = "https://discord.com/api/webhooks/ID/TOKEN"

with open("screenshot.png", "rb") as f:
    files = {
        "file": ("screenshot.png", f, "image/png"),
    }
    payload = {
        "payload_json": '{"content": "Build artifact:", "username": "CI Bot"}'
    }
    r = requests.post(WEBHOOK_URL, data=payload, files=files)
    r.raise_for_status()

Key points:

  • payload_json is a string (not a dict) inside data
  • The MIME type matters — use image/png, application/pdf, text/plain, etc.
  • requests auto-builds the multipart boundary

Multiple Files

files = {
    "files[0]": ("error.log", open("error.log", "rb"), "text/plain"),
    "files[1]": ("trace.txt", open("trace.txt", "rb"), "text/plain"),
    "files[2]": ("dump.bin", open("dump.bin", "rb"), "application/octet-stream"),
}
requests.post(WEBHOOK_URL, data={"payload_json": '{"content": "Crash report"}'}, files=files)

The keys must be files[0], files[1], … in order.

JavaScript — Node.js FormData

Modern Node 18+ has built-in FormData and fetch:

import fs from 'node:fs';

const form = new FormData();
form.append('payload_json', JSON.stringify({
  content: 'Daily report attached',
  username: 'Reports Bot',
}));

const buffer = fs.readFileSync('./report.pdf');
form.append('file', new Blob([buffer], { type: 'application/pdf' }), 'report.pdf');

const res = await fetch(process.env.WEBHOOK_URL, {
  method: 'POST',
  body: form,
});
console.log(res.status);

Don’t set Content-Type manually — fetch derives the multipart boundary from the FormData instance.

Browser Upload (No Backend)

If you’re letting users upload directly from a <input type="file">:

async function uploadToDiscord(file, message) {
  const form = new FormData();
  form.append('payload_json', JSON.stringify({ content: message }));
  form.append('file', file, file.name);

  const res = await fetch(WEBHOOK_URL, { method: 'POST', body: form });
  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
}

document.querySelector('input[type=file]').addEventListener('change', async (e) => {
  await uploadToDiscord(e.target.files[0], 'New upload from website');
});

Warning: never expose your webhook URL in client-side code unless you’re fine with anyone using it. Proxy through your backend in production.

curl — Command Line

curl -X POST \
  -F "payload_json={\"content\":\"Log file attached\"}" \
  -F "file=@./server.log;type=text/plain" \
  https://discord.com/api/webhooks/ID/TOKEN

Quoting matters: the JSON inside payload_json must escape quotes if you’re in a shell that doesn’t strip them. If your JSON is complex, write it to a temp file:

echo '{"content":"Log","embeds":[{"title":"Build #42","color":3066993}]}' > /tmp/p.json
curl -X POST \
  -F "payload_json=<@/tmp/p.json" \
  -F "file=@./build.log" \
  https://discord.com/api/webhooks/ID/TOKEN

PHP — CURLFile

<?php
$webhook = 'https://discord.com/api/webhooks/ID/TOKEN';

$payload = json_encode([
    'content' => 'Daily backup attached',
    'username' => 'Backup Bot',
]);

$ch = curl_init($webhook);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    'payload_json' => $payload,
    'file' => new CURLFile('/srv/backups/db.sql.gz', 'application/gzip', 'db.sql.gz'),
]);

$response = curl_exec($ch);
curl_close($ch);

CURLFile automatically sets the right MIME boundary; never use @filename strings (deprecated and insecure since PHP 5.6).

Embed Images via Attachment

Want the file to appear inside an embed instead of as a separate attachment? Reference it with attachment://:

import requests

with open("chart.png", "rb") as f:
    files = {"file": ("chart.png", f, "image/png")}
    payload_json = '''
    {
      "embeds": [{
        "title": "Q4 Revenue",
        "image": { "url": "attachment://chart.png" }
      }]
    }
    '''
    requests.post(WEBHOOK_URL, data={"payload_json": payload_json}, files=files)

The filename in attachment://chart.png must match the filename in the multipart part exactly. This works for image, thumbnail, author.icon_url, and footer.icon_url.

Streaming Large Files

For files near the 25 MB cap, stream from disk to avoid loading the whole thing into memory:

import requests

def upload_large(path: str, message: str):
    with open(path, "rb") as f:
        # requests streams automatically when given a file object
        files = {"file": (path.split("/")[-1], f)}
        return requests.post(
            WEBHOOK_URL,
            data={"payload_json": f'{{"content": "{message}"}}'},
            files=files,
            timeout=60,  # bigger timeout for big uploads
        )

upload_large("/var/log/app/2026-04.tar.gz", "Monthly logs")

Spoiler-Tagged Files

Prefix the filename with SPOILER_ to mark a file as a spoiler (Discord blurs it):

files = {"file": ("SPOILER_screenshot.png", open("screenshot.png", "rb"), "image/png")}

Useful for security-sensitive screenshots that should still be inspectable but not auto-displayed.

Common Errors

StatusCauseFix
400 + Request entity too largeFile over tier limitCompress, split, or use server boost
400 + payload_json errorInvalid JSON inside payload_jsonValidate with json.dumps() first
413Server-level size limitSame as 400 — reduce file size
415Wrong content typeDon’t set Content-Type: application/json for file uploads
200 but no file appearsFilename mismatch with attachment://Match filename exactly, including extension

Try It Live

The Discord Webhook builder lets you drag-drop files into a message and previews exactly how the upload will look in Discord — same multipart format described here. For other webhook recipes, see the Python guide and JavaScript guide.

References