Coding Projects

Send Push Notifications From PowerShell

One Invoke-RestMethod call sends a push notification from PowerShell. Wire it into a scheduled task and Windows jobs can finally reach your phone.

Red and black halftone illustration: a large industrial power lever thrown to the on position on a grey steel panel (TheNotificationApp)

Windows Task Scheduler has a "last run result" column. It contains a hexadecimal number. It is the only feedback a scheduled task gives you, and you have to go and look at it to find out that last night's backup returned 0x80070005 for the fourth night running.

PowerShell can close that loop in one line, and the Windows side of this is genuinely underserved: most notification tutorials assume you are on a Mac or a Linux box with curl.

What you need

  • PowerShell 5.1 or later, which means any current Windows install
  • An app key from TheNotificationApp
  • The iOS app installed and signed in with the same Apple ID

No module to install. Invoke-RestMethod has shipped with Windows for years.

Step 1: The one-liner

Create an application in the app, call it Windows, and copy the app_key. Then this is the whole integration:

Invoke-RestMethod -Uri "https://thenotification.app/api/sendNotification" `
  -Method Post `
  -Headers @{ "app_key" = "your_app_key_here" } `
  -ContentType "application/json" `
  -Body (@{ title = "Hello"; body = "From PowerShell" } | ConvertTo-Json)

Run it. Your phone buzzes. ConvertTo-Json handles the escaping, which matters because Windows paths are full of backslashes and error messages are full of quotes, and hand-built JSON breaks on both.

Step 2: Make it a function

function Send-PhoneNotification {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Title,
        [Parameter(Mandatory)][string]$Body,
        [string]$Link
    )

    $key = $env:TNA_APP_KEY
    if (-not $key) {
        Write-Warning "TNA_APP_KEY is not set; skipping notification."
        return
    }

    $payload = @{
        title = $Title
        body  = if ($Body.Length -gt 180) { $Body.Substring(0, 180) } else { $Body }
    }
    if ($Link) { $payload.link = $Link }

    try {
        Invoke-RestMethod -Uri "https://thenotification.app/api/sendNotification" `
            -Method Post `
            -Headers @{ "app_key" = $key } `
            -ContentType "application/json" `
            -Body ($payload | ConvertTo-Json -Compress) `
            -TimeoutSec 10 | Out-Null
    }
    catch {
        Write-Warning "Notification failed: $($_.Exception.Message)"
    }
}

The try/catch is not optional in practice. This runs at the end of jobs, and a notification that throws inside a cleanup block can mask the real error you were trying to report.

Put it in your profile so every session has it:

notepad $PROFILE
# paste the function, save, then:
. $PROFILE

Step 3: Store the key properly

An environment variable at machine scope, so scheduled tasks running as SYSTEM can see it:

# run as administrator, once
[Environment]::SetEnvironmentVariable("TNA_APP_KEY", "your_app_key_here", "Machine")

Machine scope is the important part. Setting it at User scope works in your shell and then silently does nothing when Task Scheduler runs the job as a different account, which is a very annoying afternoon to spend.

Open a new shell afterwards. Environment changes do not reach sessions that are already running.

Step 4: Wire it to a scheduled task

Do not put the notification logic in the task itself. Wrap the work in a script that reports whatever happened:

# C:\Scripts\Run-NightlyBackup.ps1
. "C:\Scripts\Notify.ps1"    # the function from step 2

$started = Get-Date

try {
    & "C:\Scripts\backup.ps1"
    if ($LASTEXITCODE -ne 0) { throw "backup.ps1 exited with $LASTEXITCODE" }

    $elapsed = [int]((Get-Date) - $started).TotalSeconds
    Send-PhoneNotification -Title "Backup finished" `
        -Body "Completed in ${elapsed}s on $env:COMPUTERNAME"
}
catch {
    Send-PhoneNotification -Title "Backup FAILED on $env:COMPUTERNAME" `
        -Body $_.Exception.Message
    exit 1
}

Then point Task Scheduler at the wrapper:

Program:   powershell.exe
Arguments: -NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Run-NightlyBackup.ps1"

-NoProfile is why the wrapper dot-sources the function from a file rather than relying on $PROFILE. Scheduled tasks should not depend on a profile that may not load, and -NoProfile also makes the task start faster and behave the same regardless of who is logged in.

The machine name in the notification is not decoration either. The first time you deploy this to a second server, an alert that does not say which box is an alert that makes you go and check both.

Watching a Windows service

The other thing Windows boxes do silently is stop a service. A scheduled check every few minutes covers it, with the same state-file trick that keeps a persistent problem from notifying you forever:

# C:\Scripts\Watch-Services.ps1
. "C:\Scripts\Notify.ps1"

$watch = @("MSSQLSERVER", "W3SVC", "MyAppService")
$stateFile = "C:\Scripts\.service-state"
$down = if (Test-Path $stateFile) { Get-Content $stateFile } else { @() }

foreach ($name in $watch) {
    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue

    if (-not $svc -or $svc.Status -ne "Running") {
        if ($down -notcontains $name) {
            Send-PhoneNotification -Title "$name is not running" `
                -Body "On $env:COMPUTERNAME. Status: $($svc.Status)"
            $down += $name
        }
    }
    elseif ($down -contains $name) {
        Send-PhoneNotification -Title "$name recovered" -Body "Back up on $env:COMPUTERNAME"
        $down = $down | Where-Object { $_ -ne $name }
    }
}

Set-Content -Path $stateFile -Value $down

Two notifications per incident, one when it stops and one when it comes back, and nothing in between. Without the state file a service that has been down since Tuesday notifies you every five minutes, which is roughly six hundred notifications and a muted app.

The gotcha nobody warns you about

On PowerShell 5.1, Invoke-RestMethod can fail against modern TLS with an unhelpful "underlying connection was closed" error, because the default security protocol is older than the endpoint requires. If you hit it, force it:

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

Put that at the top of the wrapper script. PowerShell 7 and later do not need it, and it is harmless there.

Adding a tap-through

The optional link makes the notification open something when tapped. For a backup job, point it at your monitoring page or a network share:

Send-PhoneNotification -Title "Backup finished" `
    -Body "Completed in ${elapsed}s" `
    -Link "https://monitoring.example.com/backups"

The full field list is in the API reference.

The honest part

The free tier is 100 notifications for the lifetime of the account, not per month. One per scheduled task run is fine for a nightly job and gone in a week for a task that runs every five minutes. Notify on failure and on the runs that finish something meaningful, not on every execution.

Pro is $2.99 a month for 1,000. And this reaches iPhone only, since it rides Apple's push service.

Where this fits

Same endpoint and same two headers as everywhere else. The Unix counterpart of this post is sending push notifications from a Bash script, which covers the cron side of the same problem.

Grab a free key at thenotification.app and stop reading hex codes in Task Scheduler.

New to this? Start with what a push notification API actually is.