Send a Discord Webhook with cURL
One cURL command is enough to post a rich Discord message from a terminal, a CI pipeline or a cron job. The command itself is short; what goes wrong is quoting and one missing query parameter. This guide gives you a command that avoids both.
A working command
Replace the URL with your own and run it in bash or zsh. If you do not have a webhook URL yet, the webhook setup guide takes about a minute.
# Components V2 webhook message — designed in DWEEB (https://dweeb.faizo.net).
# Replace the URL with your webhook's. with_components=true is required:
# without it Discord accepts the request and silently drops the components.
curl -X POST "https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN?with_components=true&wait=true" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"components": [
{
"type": 17,
"accent_color": 5793266,
"components": [
{
"type": 10,
"content": "# Server update\nEverything you need in one place."
},
{
"type": 14,
"divider": true,
"spacing": 1
},
{
"type": 1,
"components": [
{
"type": 2,
"style": 5,
"label": "Read the guide",
"url": "https://example.com/update"
}
]
}
]
}
],
"flags": 32768
}
JSONWhy the JSON is passed on standard input
Most examples put the payload in a single-quoted -d argument. That breaks on the first apostrophe in your message — "Don't miss it" ends the argument halfway through — and the usual fix of escaping quotes inside quotes is exactly where shell scripts go wrong.
Here --data-binary @- reads the body from standard input, and the quoted heredoc marker <<'JSON' tells the shell to pass everything up to the closing JSON line through untouched: no variable expansion, no quote handling, no escaping. Whatever the message says, the bytes Discord receives are the bytes you wrote.
The two parameters in the URL
The payload also carries flags: 32768, the Components V2 flag. With it set, content and embeds are not accepted in the same payload; visible text lives in type 10 Text Display components.
- with_components=true is required for a webhook created in Server Settings. Without it Discord accepts the request, answers with a success status and discards the components array — the message arrives empty or not at all.
- wait=true makes Discord answer with the created message as JSON, which is how a script learns the message id it needs to edit that message later. Without it the answer is 204 with no body.
In CI and cron
- Store the URL as a secret and read it from an environment variable. A webhook URL is a credential: anyone who has it can post to your channel.
- Add --fail-with-body so a rejected request fails the job and still prints Discord's explanation. Plain --fail hides the response body, which is where the reason is.
- Add --retry 3 for transient network failures. For an HTTP 429, read retry_after from the JSON body and wait that long; the webhook limits guide has the numbers.
- On Windows, run the command in Git Bash or WSL. PowerShell's curl is an alias with different quoting rules, and cmd.exe has no heredoc.
Uploading a file with the message
To show a local image or attach a document, switch from a JSON body to multipart form fields: -F 'payload_json=<-;type=application/json' still reads the payload from standard input, and each file is added with -F 'files[0]=@./report.pdf'. The payload gains an attachments array that maps each part to the filename a component refers to with attachment://.
Open a message that uses an uploaded image or a File component in DWEEB and the Code tab writes this variant for you, with one -F line per upload.
When the request is rejected
You do not have to write the payload by hand. Build the message in DWEEB's visual editor, check it in the live preview, and the code generator exports it as this exact command. The same message is also available as a Python script or JavaScript.
- 400 with a field path such as components.0.components.2: the payload broke one of Discord's rules. The error guide explains how to read it.
- 401 or 404: the URL is wrong, or the webhook was deleted.
- A success status but nothing in the channel: with_components=true is missing.
- Buttons with a custom_id are refused on a webhook your own application does not own. Link buttons work everywhere.
Put the guide into practice
Open the exact workflow in DWEEB. Nothing posts until you review and confirm it.
Build the message and export the cURL command →