All docs

Webhooks

Log events from a URL, and push notify events to your own endpoints, Slack or Discord.

Two independent features, both scoped to a project:

  • Incoming webhooks — a per-project secret URL that creates events without an API key, in the style of ntfy : curl -d "Backup finished" <url> works.
  • Outgoing webhooks — events posted with notify: true are delivered to HTTP endpoints you configure, alongside your ntfy destinations. Formats: signed JSON, Slack, and Discord.

Both are managed from a project's Settings tab.

Incoming webhooks

Create one from Settings → Incoming webhooks — just a name and a default category. The full URL stays visible on the Settings page after it's created, in the shape:

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

The token is the URL. There's no separate API key — anything that can reach the URL can create an event, so treat it as a credential: don't post it somewhere public, and if it leaks, delete the hook and create a new one.

An unknown or paused URL always answers a bare 404, whether the token never existed or was just disabled — that way a probe learns nothing.

Sending events

By default the request body becomes the event: the first line is the title, and the rest (if any) becomes the description.

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

Set a title, category, tags, and highlight it in the feed with headers:

curl -d "Backup finished" \
  -H "Title: Nightly backup" \
  -H "Category: ops" \
  -H "Tags: env=prod,host=db1" \
  -H "Notify: 1" \
  https://app.getbutters.com/hook/whk_YOUR_TOKEN

Or send a JSON body for full control over the fields:

curl -H "Content-Type: application/json" \
  -d '{"category":"ops","title":"Nightly backup","notify":true}' \
  https://app.getbutters.com/hook/whk_YOUR_TOKEN

Can only make GET requests? Everything can go in the query string instead:

curl "https://app.getbutters.com/hook/whk_YOUR_TOKEN?title=Ping&category=uptime"

Header values have to be plain ASCII, so an emoji icon goes in the query string instead: ?icon=%F0%9F%92%BE.

A request body over 64 KB, or an event over your plan's monthly cap, is refused rather than partially processed.

Outgoing webhooks

Every event posted with notify: true — from the API, an incoming webhook, or the playground — fans out to every enabled webhook destination on the project, alongside any ntfy destinations. Add one from Settings → Outgoing webhooks: a name, a URL, and a format.

Use Test on any destination to send a sample event through the real delivery path and see the result immediately. A test makes a single attempt with no retries, so if it fails, fix the problem and press Test again.

JSON

A signed envelope around the event, safe to verify and parse programmatically:

{
  "type": "event.notify",
  "project": "q4q8nb18qc2i",
  "timestamp": "2026-09-24T12:00:01.000Z",
  "event": {
    "id": 128,
    "projectId": "q4q8nb18qc2i",
    "category": "orders",
    "title": "Order Placed",
    "description": "Order **#1234** placed by **Maria**",
    "icon": "🛍️",
    "tags": { "email": "maria@example.com", "amount": "$89.99" },
    "metadata": { "cart": { "items": 3, "coupon": "SPRING" } },
    "url": "https://shop.example.com/admin/orders/1234",
    "user_id": "user-456",
    "notify": true,
    "favorited": false,
    "createdAt": "2026-09-24T12:00:00.000Z"
  }
}

Slack

Posts straight to a Slack incoming-webhook URL:

{ "text": "*Order Placed*\nOrder **#1234** placed by **Maria**\nhttps://shop.example.com/admin/orders/1234" }

Discord

Posts a single embed, with empty fields left out:

{
  "embeds": [
    {
      "title": "Order Placed",
      "description": "Order **#1234** placed by **Maria**",
      "url": "https://shop.example.com/admin/orders/1234",
      "footer": { "text": "orders" }
    }
  ]
}

Verifying signatures

Only JSON destinations are signed — Slack and Discord's own webhook URLs are the credential, so there's nothing extra to check there. A signed JSON delivery carries three headers:

  • webhook-id — stays the same across retries of the same delivery, so dedupe on it.
  • webhook-timestamp — unix seconds the attempt was sent.
  • webhook-signature — v1,<signature>, an HMAC-SHA256 of the id, timestamp, and body, keyed by your signing secret.

This follows the Standard Webhooks spec, so any of their verification libraries work out of the box — recompute the signature, compare it safely (not with ==), and reject anything more than 5 minutes old.

Your signing secret is shown on the destination's Settings row (JSON format only), with a Rotate secret option if it's ever compromised — your receiver will reject deliveries until it's updated with the new one.

Retries

A delivery gets up to 3 attempts — about 1 second after the first try, then about 5 seconds after that — so a receiver that's briefly down or rate-limiting you still gets the event. A definite rejection (a 4xx other than 429) isn't retried, since resending the same bad request only adds noise.

There's no durable queue behind this: if delivery is still retrying when the server restarts, that one delivery is lost. The destination's row in Settings always shows the last error or the last successful send, so a persistent problem is easy to spot.