Home/Guides/Send from Go
Guide · Go

How to send a push notification from Go

A worker drains a queue, a batch job wraps up, a healthcheck flips red — and your phone buzzes. In Go it's a net/http POST to your Lert URL using nothing but the standard library. Here's the idiomatic, close-the-body version.

What you'll need

The Lert app on your iPhone and any recent Go toolchain. Copy your endpoint from the app — it looks like https://lert.dev/p/your-id. That URL is the authentication; there's no key or token to manage. Keep it out of committed source and regenerate it in the app if it leaks.

Because this is nothing but net/http, it slots into whatever you're building — a background worker, a CLI, an HTTP handler, or a long-running daemon. Read the endpoint from the environment with os.Getenv so the URL never lands in source control and you can point a dev binary at a separate channel.

The minimal example

Marshal a struct with encoding/json and POST it with http.Post. The title, body, and tag fields are all optional; omitempty keeps unset ones out of the payload.

Go · net/http
package main

import (
    "bytes"
    "encoding/json"
    "net/http"
)

func main() {
    payload, _ := json.Marshal(map[string]string{
        "title": "Job done",
        "body":  "queue drained · 0 left",
    })
    resp, err := http.Post(
        "https://lert.dev/p/your-id",
        "application/json",
        bytes.NewReader(payload),
    )
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
}

That defer resp.Body.Close() isn't optional etiquette — Go holds the connection open until the body is closed, so skipping it leaks connections over time.

A reusable Notify function

Wrap it into something you can call from anywhere. Read the endpoint from an environment variable, use a struct with JSON tags, give the client a timeout, and return an error the caller can handle.

Go · Notify()
package lert

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "time"
)

type msg struct {
    Title string `json:"title,omitempty"`
    Body  string `json:"body,omitempty"`
    Tag   string `json:"tag,omitempty"`
}

var client = &http.Client{Timeout: 10 * time.Second}

func Notify(title, body, tag string) error {
    payload, err := json.Marshal(msg{title, body, tag})
    if err != nil {
        return err
    }
    resp, err := client.Post(
        os.Getenv("LERT_URL"),
        "application/json",
        bytes.NewReader(payload),
    )
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("lert: unexpected status %d", resp.StatusCode)
    }
    return nil
}

The tag is a free-form label that shows up in your in-app history, so a worker ping is easy to distinguish from a deploy ping later.

Checking the status code

A success is 200 with {"delivered":1}. Decode the body when you want to be sure the message actually reached a device rather than just being accepted.

Go · decode delivered
var result struct {
    Delivered int    `json:"delivered"`
    Warning   string `json:"warning"`
    Error     string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    return err
}
if result.Delivered == 0 {
    return fmt.Errorf("lert: not delivered: %s", result.Warning)
}

Worth handling: 404 {"error":"unknown endpoint"} means the URL is wrong or was regenerated, and 200 with delivered:0 means no device is registered yet — open the Lert app once on your phone.

Backing off on 429

Lert allows 60 requests/minute per IP and 50/day on the free plan. When you exceed a limit you get 429 with a Retry-After header; honor it.

Go · retry on 429
if resp.StatusCode == http.StatusTooManyRequests {
    wait, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
    if wait == 0 {
        wait = 5
    }
    time.Sleep(time.Duration(wait) * time.Second)
    // retry the request once here
}

Plain text vs JSON

For a one-line message you can skip JSON. Send the text with strings.NewReader and a text/plain content type — important when the message contains = or &, so it isn't parsed as form data.

Go · plain text
resp, err := http.Post(
    os.Getenv("LERT_URL"),
    "text/plain",
    strings.NewReader("Healthcheck failed: db=down & cache=ok"),
)

A real-world snippet

Fire a notification from a defer so it runs whether the worker returns cleanly or panics its way out — you hear about both.

Go · notify on exit
func runWorker() {
    var err error
    defer func() {
        if err != nil {
            Notify("Worker failed ❌", err.Error(), "worker")
        } else {
            Notify("Worker finished ✅", "clean exit", "worker")
        }
    }()

    err = process()
}

Deploying this somewhere scheduled? The same Notify slots into a cron job, a monitoring loop, or a deploy step. Prefer another language? See Python, Node.js, or the curl deep-dive.

Why it's this simple

There's no Go module to go get because there's nothing to configure. The endpoint URL is the credential — no auth handshake, no token refresh, no client to keep current. Notifying your phone from Go is standard library and a struct.

Keep Notify in a small internal package and it composes with the rest of your code: call it from a defer, hook it into your structured logger so an error-level line also pings you, or fire it from a context cancellation path. One reusable function, and every service you run gains a direct line to your pocket.

FAQ

Do I need a third-party HTTP library in Go?

No. The standard library net/http and encoding/json are all you need to POST a JSON body to Lert. There's no SDK to add.

Why must I close resp.Body?

Go keeps the connection open until the body is closed, so skipping defer resp.Body.Close() leaks connections. Always close it, even if you don't read the body.

How do I send plain text instead of JSON?

Pass a strings.NewReader with your text and set the content type to text/plain. Do this when the message contains = or & so it isn't treated as form data.

How do I handle a 429 rate limit?

Check for resp.StatusCode == http.StatusTooManyRequests, read the Retry-After header, sleep that many seconds, and retry. Limits are 60 requests/minute per IP and 50/day on the free plan.

Ship a Notify, hear from your code.

Download Lert, copy your URL, drop in a Notify function. Your Go services tell you the moment they're done.

Get the app iOS