Turn any service's webhook into a readable feed

Forms, routers, CI and payment tools can all call a URL when something happens. Here is how to point them at a Butters incoming webhook, why pasting the URL straight into Stripe gives you a doorbell rather than an event, and the recipes that work instead.

6 min read
A yellow funnel catching tangled coral and blue lines, beside a tidy stack of event cards

Most of the things you'd want in a feed already happen somewhere else. A form gets filled in. A backup finishes on the NAS. Someone stars the repo. A customer's card is declined. Each of those tools can call a URL when it happens, and Butters gives you a URL to call.

This post is a set of recipes for pointing other tools at an incoming webhook. It also covers the one thing that catches people out: what happens when a service sends its own JSON, and why that usually isn't the event you want.

The URL

Create an incoming webhook from a project's Settings tab. You give it a name and a default category, and you get a URL like this:

https://app.getbutters.com/hook/whk_5f2a9c1e0b7d4a3f8c6e1b2d

There's no API key. The token in the URL is the credential, so treat it like a password, and if it leaks, delete the hook and create a new one. Send events in, send them out covers the security side and every header in more detail.

You can make as many hooks as you like on one project, so give each sender its own. The Settings page shows when each one was last used, and you can pause one without deleting it.

How a request becomes an event

The hook accepts POST and GET. It reads fields from three places:

  • The query string: ?title=, category, tags, notify, icon, click (the link), and message (the description).
  • Headers: Title, Category, Tags, Notify, Icon and Click, each also accepted with an X- prefix.
  • A JSON body, when the request says Content-Type: application/json. It uses the same field names as the API: title, description, category, url, tags, notify, icon, metadata.

Names are case-insensitive. When two sources set the same field, the query string beats a header, and an explicit JSON field beats both.

A plain-text body works too. The first line becomes the title, and the whole body becomes the description when there's more than one line. With no title anywhere, the event is called Webhook. With no category, it uses the hook's default. That means there's no request shape that fails for a missing title or category. Over 64 KB is a 413, broken JSON is a 400, and anything over your plan's event cap is a 402.

Why you can't just paste the URL into Stripe

It's tempting to take the hook URL and drop it straight into Stripe's or GitHub's webhook settings. Here's what actually happens.

Those services send their own JSON: Stripe sends id, type, data and so on, GitHub sends action, repository, sender. The hook reads the body as JSON and looks for its own field names. It finds none of them. Unknown keys are ignored and not stored, so the payload is thrown away. You get a 201 and an event titled Webhook in the default category, with no description and no detail.

You can do a little better by putting fields in the query string, since they still apply alongside a JSON body:

https://app.getbutters.com/hook/whk_YOUR_TOKEN?title=Stripe%20event&category=billing

Now every delivery shows up as "Stripe event". That's a doorbell. It tells you something happened, not what. For a low-volume sender, that can be enough. For anything you'd want to act on, put a step in between that picks out the fields you care about. The recipes below do exactly that.

Recipe: a no-code automation tool

Zapier, Make, n8n and similar tools already understand Stripe, GitHub, Typeform and hundreds of other services. Use one as the translator. The trigger is the service's own event, and the action is an HTTP request to your hook.

Most of these let you send a JSON body, which is the easiest way to map fields:

  • URL: your hook URL
  • Method: POST
  • Content type: application/json
  • Body:
{
  "title": "Payment failed: {{customer_email}}",
  "description": "Invoice {{invoice_id}} for {{amount}}",
  "category": "billing",
  "url": "https://dashboard.stripe.com/invoices/{{invoice_id}}",
  "notify": true
}

The {{...}} parts are placeholders for whatever your tool calls its mapped fields. Set url to the page you'd open to deal with the event. It's what the title in the feed links to.

If your tool only lets you set a URL, put everything in the query string instead: ?title=...&message=...&category=billing. Make sure the values are URL-encoded.

Recipe: GitHub Actions

For GitHub, you don't need a translator. A workflow can make the request itself with curl, and it has every detail already. Store the hook URL as a repository secret called BUTTERS_HOOK, then add a step:

- name: Tell Butters
  if: always()
  run: |
    curl -sS -H "Content-Type: application/json" \
      -d "{\"title\":\"${{ github.workflow }}: ${{ job.status }}\",\"category\":\"ci\",\"url\":\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\",\"notify\":${{ job.status != 'success' }}}" \
      "$BUTTERS_HOOK"
  env:
    BUTTERS_HOOK: ${{ secrets.BUTTERS_HOOK }}

Every run lands in the feed with a link to the run. Only failures set notify, so only failures reach your phone. For more on what's worth reporting from CI, see Deploys, cron jobs, and the things that fail quietly .

Recipe: routers, NAS boxes and small devices

Plenty of devices can call a URL when something happens, but only with GET, and with no way to set headers or a body. That's what the GET support is for. Everything goes in the query string:

https://app.getbutters.com/hook/whk_YOUR_TOKEN?title=Backup%20finished&category=nas&tags=host%3Dsynology

Tags are key=value pairs separated by commas, so tags=host=synology,job=nightly (encoded) becomes two tags. A bare word like tags=backup becomes backup: true.

Emoji icons can't travel in HTTP headers, so if you want one, put it in the query string too: &icon=%F0%9F%92%BE is 💾.

Recipe: shell scripts and anything with curl

This is the shortest one. If it can run curl, it can post plain text:

curl -d "Backup finished" https://app.getbutters.com/hook/whk_YOUR_TOKEN

Add headers when you want more than a title:

curl -d "Restored 1,204 rows from last night's dump" \
  -H "Title: Staging refresh" \
  -H "Category: ops" \
  -H "Tags: env=staging" \
  https://app.getbutters.com/hook/whk_YOUR_TOKEN

This is also the quickest way to check a new hook works before you wire it into anything else.

Recipe: forms

Some form tools let you add a webhook that posts each submission as JSON using your own field names, or let you template the request body. If yours does, set the body to the shape shown in the automation recipe above and you're done. If it only sends its own fixed JSON, you're back to the Stripe situation: route it through an automation tool, or accept a doorbell with a ?title= on the URL.

A few habits that help

  • One hook per sender. The name and "last used" time tell you which source has gone quiet, and you can rotate one without touching the others.
  • Set the default category to the sender. A hook called "NAS" with a default category of nas needs nothing but a title from the device.
  • Be careful with notify. A third-party tool that fires on every change will fire a lot. Leave notify off until you've watched the volume for a few days. The rule from Get pinged for the right events, and only those still applies.

Starting out

Pick one thing you currently find out about by accident. Make a hook for it, send a test with curl, then wire the real sender in. If that sender speaks its own JSON, put an automation step in front of it. Once the first one is working, adding the next is about two minutes of work.