Send events in, send them out: webhooks and push
Not everything can hold an API key, and not everyone lives in ntfy. Incoming webhooks turn any URL call into an event, and outgoing webhooks and web push send the events that matter to Slack, Discord, your own backend, or your phone.
The notify post covered one path: an event marked notify: true goes to an ntfy topic and your phone buzzes. That path assumes two things. That whatever sends the event can hold an API key and call the API, and that ntfy is where you want the alert to land.
Often neither is true. The thing that knows a job finished is a router, a NAS, or a no-code tool that can only hit a URL. And the place your team actually looks is a Slack channel, a Discord server, or your own backend.
This post covers the rest of the plumbing. Incoming webhooks get events into Butters from anything that can make an HTTP request. Outgoing webhooks and web push get notified events out to wherever you need them.
Incoming: a URL that creates events
Every project can have incoming webhooks. Create one in the project's Settings tab, under Incoming webhooks. You give it a name and a default category, and you get a URL like this:
https://app.getbutters.com/hook/whk_5f2a9c1e0b7d4a3f8c6e1b2d That's the whole setup. No API key, no SDK, no JSON required. If you've used ntfy, this will feel familiar, because the simplest call is the same shape:
curl -d "Backup finished" https://app.getbutters.com/hook/whk_YOUR_TOKEN A plain text body becomes an event. The first line is the title (trimmed to 200 characters), and if the body runs longer than that line, the whole body becomes the description. The event lands in the hook's default category. If you send nothing at all, the title falls back to Webhook, so a bare ping still shows up.
Setting fields with headers
When you want more than a title, add headers:
curl -d "Backup finished in 4m12s" \
-H "Title: Nightly backup" \
-H "Category: ops" \
-H "Tags: env=prod,host=db1" \
-H "Notify: 1" \
https://app.getbutters.com/hook/whk_YOUR_TOKEN The headers you can use:
- Title sets the title.
- Category overrides the hook's default category.
- Tags takes comma-separated
key=valuepairs. A bare word with no=(the ntfy habit,Tags: backup) becomesbackup: true. - Notify accepts
1,trueoryes, and sends the event through the same notification fan-out as the API does. - Icon sets the emoji icon.
- Click sets the URL the event opens.
Each header also works with an X- prefix (X-Title, X-Category), for tools that insist on it. Header names are case-insensitive.
If you'd rather send JSON, send JSON. The body takes the same field names as the API: title, description, category, tags, notify, icon, url, user_id, metadata.
curl -H "Content-Type: application/json" \
-d '{"category":"ops","title":"Nightly backup","notify":true}' \
https://app.getbutters.com/hook/whk_YOUR_TOKEN A project field in the body is ignored. The hook already decides which project the event belongs to, so nothing in the request can redirect it elsewhere.
GET, for senders that can't POST
Some things can only make a GET request. Uptime checkers, cheap IoT devices, a bookmarklet, the "call this URL" action in a router's admin page. The hook accepts GET too, with everything in the query string:
curl "https://app.getbutters.com/hook/whk_YOUR_TOKEN?title=Ping&category=uptime" The query string takes title, message (the description), category, tags, notify, icon and click. It's also the answer for emoji. HTTP headers can't carry non-ASCII characters, so an emoji icon has to go in the query string, URL-encoded: ?icon=%F0%9F%92%BE.
When the same field arrives more than one way, the query string beats a header, and a field in the JSON body beats both.
For wiring up specific senders, like automation tools, GitHub Actions and routers, see Turn any service's webhook into a readable feed .
The token is the URL
There's no separate secret. Anyone who has the URL can create events in that project, so treat it like a password. Keep it out of public repos, client-side code, and screenshots.
If one leaks, delete the hook and create a new one. That's the rotation. The old URL stops working immediately.
A wrong token, a paused hook, and a hook whose project was deleted all get the same bare 404. Someone guessing at URLs learns nothing from the response.
The limits are simple. The body is capped at 64 KB, and your plan's event cap applies exactly as it does for the API.
Outgoing: notified events, wherever you want them
Incoming hooks are about getting events in. The other half is getting the important ones out.
Every event created with notify: true fans out to every enabled destination on its project, in parallel. It doesn't matter whether the event came from the API, the CLI's --notify flag, an incoming hook, or the dashboard's playground. The ntfy destinations from the earlier post are one kind of destination. Outgoing webhooks and web push are the other two.
Add an outgoing webhook in Settings, under Outgoing webhooks. Give it a name, a URL, and one of three formats.
Slack. Paste a Slack incoming-webhook URL (it must be on hooks.slack.com). The event arrives as a message with the title in bold, then the description, then the event's URL on its own line.
Discord. Paste a Discord webhook URL. The event arrives as an embed: the title (linked to the event's URL, if it has one), the description, and the category in the footer. Long titles and descriptions are truncated to fit Discord's limits instead of being rejected.
JSON. For your own endpoint. Butters POSTs an envelope with a type of event.notify, the project id, a timestamp, and the full event in the same shape the API returns. That's the one to use if you want to open a ticket, page someone through your own system, or kick off a follow-up job.
JSON deliveries are signed. Each request carries webhook-id, webhook-timestamp and webhook-signature headers, following the Standard Webhooks format, so their verification libraries work as-is. Verify the signature before you trust the body, and dedupe on webhook-id, which stays the same across retries of one delivery. How we sign webhooks, and why we refuse redirects walks through the full check in Node and Python. Slack and Discord deliveries aren't signed, because for those services the URL itself is the secret.
Each destination has a Test button that sends a sample event through the real delivery path and shows the result right there. Use it before you rely on a destination.
There are no per-destination filters. Every notified event goes to every enabled destination on the project. If Slack should hear about everything but your pager endpoint should only hear about outages, put them in separate projects.
Retries, honestly
A delivery gets up to three attempts. The second comes about a second after the first, the third about five seconds after that, and each attempt waits up to five seconds for a response. A 429, any 5xx, a network error, or a timeout gets retried. Any other 4xx doesn't, because resending a request the endpoint rejected just doubles the noise. Redirects aren't followed, so the URL you save has to be the final one.
What there isn't is a queue. Retries run in memory. If the server restarts in the middle of one, that delivery is gone, and there's no delivery log to replay. The destination's row in Settings shows when it last sent successfully and the last error, and that's the record.
For "tell the team in Slack", that's fine. If a missed delivery would actually cost you something, don't make a Butters webhook the only path. Have your receiver write to a durable queue first and act from there, or keep a second channel on the same project.
Web push: no app required
The third destination is the simplest to set up. Butters can send notified events as native browser notifications, on your desktop or your phone, with no ntfy app or other service in between.
On a project's Settings tab, find the Push notifications card and click Enable on this device. Allow the browser's permission prompt. That's it. From then on, notified events for that project show up as system notifications on that device.
It's per project and per device, and it's yours. You turn it on separately for each project and each browser, and teammates can't see or manage your devices.
On an iPhone or iPad (iOS 16.4 or later), Safari only allows push for web apps on the Home Screen:
- Open Butters in Safari.
- Tap Share, then Add to Home Screen.
- Open Butters from the new Home Screen icon, not from Safari.
- Go to the project's Settings and enable push there.
The notification shows the event's title, and its description as the body (or its category, if there's no description). Tapping it opens the event's URL, or the project feed if there isn't one. If the same event is sent again, it replaces the earlier notification instead of stacking another one.
If Send test says the test went out but nothing appears, the browser accepted it and the operating system is holding it back. Check that notifications are allowed for your browser in the OS settings, and that Focus or Do Not Disturb is off.
A starter setup
You don't need all of this on day one. Here's a setup that covers most small teams:
- One incoming hook per noisy source. Your backup script, your uptime checker, your NAS. Give each its own hook and default category, so you can revoke one without touching the others.
- Push on your own phone for the project that matters most.
- One Slack or Discord webhook for the channel your team already reads.
- Test each destination with its Test button before trusting it.
Then keep notify as sparing as the notify post suggests. With more destinations, every notified event now reaches more places, so the flag matters more, not less.