Skip to main content

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.

6 min read Also available in Русский
On this page

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 -Form for uploads and -SkipHttpErrorCheck for 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-Json defaults to a depth of 2. An embed’s fields sit three levels deep, and anything past the limit is flattened to the string System.Collections.Hashtable (PowerShell 7 at least warns, 5.1 is silent), so Discord replies 400.
  • A successful post returns 204 No Content, so Invoke-RestMethod prints nothing. Append ?wait=true to the URL when you want the created message back; you need its id to 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: -Depth was left at the default, so nested hashtables became strings, or embeds was a single hashtable instead of @( ... ). Dump $payload | ConvertTo-Json -Depth 10 and read it before sending.
  • 400 on an empty message: a payload with no content, no embeds and 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-8 to -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]::Tls12 at 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.
  • username rejected: 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.log at 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:

Tags: powershellinvoke-restmethoddiscord webhookwindowstask schedulerautomationtutorial

Related articles

All articles

Build it in the visual editor

Embeds, Components V2, buttons and polls with a live Discord preview. Free, no signup.