Discord Webhook from PowerShell: Invoke-RestMethod Guide
Discord webhook PowerShell guide: Invoke-RestMethod with a hashtable and ConvertTo-Json, embeds, file uploads on 7 and 5.1, Task Scheduler and 429 retries.
On this page
- Prerequisites
- Invoke-RestMethod: the minimal request
- Embeds from a hashtable
- Sending files
- PowerShell 7: -Form
- Windows PowerShell 5.1: HttpClient
- Error handling: try/catch on 4xx and 429
- Automating with Task Scheduler
- Example: Windows event log to Discord
- Common errors
- FAQ
- Should I use Invoke-RestMethod or Invoke-WebRequest?
- Why does ConvertTo-Json need -Depth 10?
- How do I edit or delete a message sent from PowerShell?
- Can I send buttons from PowerShell?
- Wrapping up
PowerShell is on every Windows machine, and Windows is where a lot of boring-but-important work happens: backup jobs, AD scripts, event logs, Task Scheduler. Reporting any of it to Discord takes one cmdlet, Invoke-RestMethod, plus a handful of gotchas: ConvertTo-Json -Depth, encoding on Windows PowerShell 5.1, file uploads with and without -Form, and what to do when Discord answers 429.
Prerequisites
Check which PowerShell you have:
$PSVersionTable.PSVersion
- Windows PowerShell 5.1 ships with Windows on .NET Framework. Everything here works on it, but files and encoding need extra care.
- PowerShell 7 (
winget install Microsoft.PowerShell) has-Formfor uploads and-SkipHttpErrorCheckfor cleaner error handling. Use it when you can.
You also need a webhook URL from Server Settings → Integrations → Webhooks (how to get one). Treat it like a password: anyone who has it can post to your channel. Keep it out of the script and read it from an environment variable:
$WebhookUrl = $env:DISCORD_WEBHOOK_URL
if (-not $WebhookUrl) { throw 'DISCORD_WEBHOOK_URL is not set' }
Invoke-RestMethod: the minimal request
Build the payload as a hashtable, convert to JSON, post:
$payload = @{
content = 'Backup finished on **SRV-01** at ' + (Get-Date -Format 'HH:mm')
username = 'Backup Bot'
}
Invoke-RestMethod -Uri $WebhookUrl -Method Post `
-ContentType 'application/json' `
-Body ($payload | ConvertTo-Json -Depth 10)
Three details:
-Depth 10.ConvertTo-Jsondefaults to a depth of 2. An embed’sfieldssit three levels deep, and anything past the limit is flattened to the stringSystem.Collections.Hashtable(PowerShell 7 at least warns, 5.1 is silent), so Discord replies 400.- A successful post returns 204 No Content, so
Invoke-RestMethodprints nothing. Append?wait=trueto the URL when you want the created message back; you need itsidto edit or delete it later. - Encoding on 5.1. Windows PowerShell encodes a string body as ISO-8859-1 unless the content type says otherwise, so Cyrillic and emoji arrive as
????. Either use-ContentType 'application/json; charset=utf-8'or pass bytes, which works the same on both versions:
$json = $payload | ConvertTo-Json -Depth 10
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
Invoke-RestMethod -Uri $WebhookUrl -Method Post -ContentType 'application/json' -Body $bytes
The rest of the guide uses the bytes form; it runs unchanged on 5.1 and 7.
Embeds from a hashtable
An embed is a nested hashtable. embeds must be an array even for a single embed, color is a decimal integer and timestamp is ISO 8601:
$disk = Get-PSDrive C
$freeGb = [math]::Round($disk.Free / 1GB, 1)
$usedPct = [math]::Round($disk.Used / ($disk.Used + $disk.Free) * 100)
$color = if ($usedPct -gt 90) { 15548997 } else { 5763719 } # red / green
$embed = [ordered]@{
title = "Disk report: $env:COMPUTERNAME"
description = "Drive C: is **$usedPct%** full."
color = $color
fields = @(
@{ name = 'Free'; value = "$freeGb GB"; inline = $true }
@{ name = 'Used'; value = "$usedPct%"; inline = $true }
)
footer = @{ text = 'PowerShell monitor' }
timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
}
$payload = @{ username = 'Disk Monitor'; embeds = @($embed) }
$bytes = [Text.Encoding]::UTF8.GetBytes(($payload | ConvertTo-Json -Depth 10))
Invoke-RestMethod -Uri $WebhookUrl -Method Post -ContentType 'application/json' -Body $bytes
[ordered] keeps the keys in the order you wrote them, which helps when debugging; Discord itself doesn’t care. Limits: title 256 characters, description 4096, up to 25 fields, 6000 characters across all embeds of one message (full list in embed limits).
If you’d rather design the embed visually, build it in the Discord Webhook builder, export the JSON and paste it into a single-quoted here-string (@' ... '@). PowerShell expands nothing inside it, so the JSON stays exactly as exported.
Sending files
Files go as multipart/form-data: parts files[0], files[1] and so on, plus a payload_json part with the message. Up to 10 files, 10 MB each on a server without boosts.
PowerShell 7: -Form
$payload = @{ content = 'Last 200 lines of the app log 📎' } | ConvertTo-Json -Compress
Invoke-RestMethod -Uri $WebhookUrl -Method Post -Form @{
payload_json = $payload
'files[0]' = Get-Item 'C:\logs\app.log'
}
-Form sets the multipart content type itself; any FileInfo value (what Get-Item returns) is streamed as a file part. Don’t combine it with -ContentType or -Body.
Windows PowerShell 5.1: HttpClient
There is no -Form in 5.1, and hand-rolling multipart boundaries is fragile. System.Net.Http.HttpClient ships with .NET Framework and builds the body correctly:
Add-Type -AssemblyName System.Net.Http
$payload = @{ content = 'Last 200 lines of the app log' } | ConvertTo-Json -Compress
$path = 'C:\logs\app.log'
$form = [System.Net.Http.MultipartFormDataContent]::new()
$form.Add([System.Net.Http.StringContent]::new($payload, [Text.Encoding]::UTF8, 'application/json'), 'payload_json')
$file = [System.Net.Http.ByteArrayContent]::new([IO.File]::ReadAllBytes($path))
$form.Add($file, 'files[0]', [IO.Path]::GetFileName($path))
$client = [System.Net.Http.HttpClient]::new()
$response = $client.PostAsync($WebhookUrl, $form).Result
"$([int]$response.StatusCode) $($response.Content.ReadAsStringAsync().Result)"
To show an uploaded image inside an embed, reference it as attachment://filename.png in image.url. More patterns are in sending files via webhook.
Error handling: try/catch on 4xx and 429
Invoke-RestMethod throws on any non-2xx status. The code is on $_.Exception.Response.StatusCode in both versions, and Discord’s JSON body lands in $_.ErrorDetails.Message. Enough for a reusable function that retries on 429 and gives up on everything else:
function Send-DiscordMessage {
param(
[Parameter(Mandatory)] [string] $Url,
[Parameter(Mandatory)] [hashtable] $Payload,
[int] $MaxAttempts = 3
)
$body = [Text.Encoding]::UTF8.GetBytes(($Payload | ConvertTo-Json -Depth 10))
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
try {
Invoke-RestMethod -Uri $Url -Method Post -ContentType 'application/json' -Body $body
return $true
}
catch {
$status = [int]$_.Exception.Response.StatusCode
$detail = $_.ErrorDetails.Message
if ($status -eq 429) {
$retry = 1.0
try { $retry = [double]($detail | ConvertFrom-Json).retry_after } catch {}
Write-Warning "Rate limited, retrying in $retry s (attempt $attempt/$MaxAttempts)"
Start-Sleep -Milliseconds ([int]($retry * 1000) + 100)
continue
}
if ($status -in 401, 404) { throw "Webhook not found or token rotated: $detail" }
if ($status -eq 400) { throw "Discord rejected the payload: $detail" }
throw
}
}
return $false
}
Send-DiscordMessage -Url $WebhookUrl -Payload @{ content = 'Hello from PowerShell' }
Log the 400 body: it names the offending field, for example embeds.0.description when a description passes 4096 characters. A 404 with {"message":"Unknown Webhook","code":10015} means the URL is wrong or someone deleted the webhook. On 429 the body carries retry_after in seconds; sleep at least that long. Discord doesn’t publish one fixed number for webhooks, so treat the figures in Discord webhook rate limits as guidance and always honour retry_after.
On PowerShell 7 you can skip exceptions entirely: add -SkipHttpErrorCheck -StatusCodeVariable status to the call, and the code lands in $status while the body comes back as the return value.
Automating with Task Scheduler
Save the script as C:\Scripts\Send-DiskReport.ps1 and register a daily task. Put the webhook URL in a machine-level environment variable so the task sees it whatever account it runs as (user-level variables are invisible to a task running as SYSTEM):
# Run once in an elevated console
[Environment]::SetEnvironmentVariable('DISCORD_WEBHOOK_URL', 'https://discord.com/api/webhooks/ID/TOKEN', 'Machine')
$action = New-ScheduledTaskAction -Execute 'pwsh.exe' `
-Argument '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\Send-DiskReport.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At '09:00'
Register-ScheduledTask -TaskName 'Discord disk report' -Action $action -Trigger $trigger `
-User 'NT AUTHORITY\SYSTEM' -RunLevel Highest
Swap pwsh.exe for powershell.exe on 5.1. For polling every 15 minutes use New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15). A machine-level variable is readable by every local user; on shared hosts keep the URL in a file with a tight ACL and read it with Get-Content instead. Test with pwsh -NoProfile -File ... from a plain console before registering to catch dependencies on your profile or mapped drives.
Example: Windows event log to Discord
A complete script: collect critical and error events from the last 15 minutes, one embed field per event, and send only when there is something to report.
$WebhookUrl = $env:DISCORD_WEBHOOK_URL
. "$PSScriptRoot\Send-DiscordMessage.ps1" # the retry helper from above
$events = Get-WinEvent -FilterHashtable @{
LogName = 'System', 'Application'
Level = 1, 2 # 1 = Critical, 2 = Error
StartTime = (Get-Date).AddMinutes(-15)
} -ErrorAction SilentlyContinue | Select-Object -First 10
if (-not $events) { return }
$fields = foreach ($e in $events) {
$text = ("$($e.Message)" -split "`n")[0].Trim()
if (-not $text) { $text = '(no message text)' }
if ($text.Length -gt 900) { $text = $text.Substring(0, 900) + '…' }
$unix = ([DateTimeOffset]$e.TimeCreated).ToUnixTimeSeconds()
@{
name = "$($e.LevelDisplayName) · $($e.ProviderName) · ID $($e.Id)"
value = "<t:${unix}:R>`n$text"
}
}
$payload = @{
username = "$env:COMPUTERNAME events"
embeds = @(@{
title = "$($events.Count) error(s) in the last 15 minutes"
color = 15548997
fields = @($fields)
timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
})
}
Send-DiscordMessage -Url $WebhookUrl -Payload $payload
<t:UNIX:R> renders as “5 minutes ago” in each reader’s local time. Register the script with the 15-minute trigger from the previous section and the channel becomes a live feed of server failures.
Common errors
- 400 Bad Request and the body mentions
embeds:-Depthwas left at the default, so nested hashtables became strings, orembedswas a single hashtable instead of@( ... ). Dump$payload | ConvertTo-Json -Depth 10and read it before sending. - 400 on an empty message: a payload with no
content, noembedsand no files is rejected. Guard against empty strings from your own logic. - Cyrillic or emoji show up as
????: Windows PowerShell 5.1 sent the body as ISO-8859-1. Pass UTF-8 bytes or add; charset=utf-8to-ContentType. - “The underlying connection was closed” or “Could not create SSL/TLS secure channel” on 5.1: .NET Framework on that machine negotiates an old TLS version. Put
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12at the top of the script. - 404 “Unknown Webhook” (code 10015): wrong URL, or the webhook was deleted and recreated, which gives it a new token. Update the environment variable.
usernamerejected: it can’t exceed 80 characters or contain “clyde” or “discord” in any casing.- Works in the console, the scheduled task sends nothing: the task runs as another user without your variables and profile. “Last Run Result 0x1” means the script threw; add
Start-Transcript -Path C:\Scripts\report.logat the top and read the log.
FAQ
Should I use Invoke-RestMethod or Invoke-WebRequest?
Both send the same request. Invoke-RestMethod parses a JSON response into objects, handy with ?wait=true. Invoke-WebRequest returns the raw response with StatusCode and Headers, useful when you want the X-RateLimit-* headers without a try/catch.
Why does ConvertTo-Json need -Depth 10?
Its default depth is 2, and an embed’s fields array sits at depth 3 (embeds → embed → fields → field). Deeper levels are serialised as System.Collections.Hashtable, which Discord rejects with 400. Ten covers any webhook payload.
How do I edit or delete a message sent from PowerShell?
Post with ?wait=true, keep the returned id, then call Invoke-RestMethod -Method Patch (or Delete) on $WebhookUrl/messages/$id with the same JSON shape. See edit and delete webhook messages.
Can I send buttons from PowerShell?
Link buttons and other non-interactive components, yes: append ?with_components=true to the URL and send a components array. Buttons that do something (assign a role, run an action) need a bot, which the Discord Webhook site’s bot integration provides.
Wrapping up
Invoke-RestMethod, a hashtable, -Depth 10 and a try/catch that respects retry_after cover almost every PowerShell-to-Discord job. For the design side, prototype embeds in the free builder at discord-webhook.com/app and paste the exported JSON into a here-string.
Related reading:
- Send Discord webhooks with curl — the same requests from bash, for WSL and Linux hosts
- Discord webhooks in C# — when the script grows into a service
- Send files through a Discord webhook — multipart details, attachment URLs, size limits