# Buttondown Webhooks

## When to Use This Skill

* How do I receive Buttondown webhooks?
* How do I verify the `X-Buttondown-Signature` header?
* How do I handle `subscriber.created`, `subscriber.confirmed`, `subscriber.unsubscribed` or `email.sent` events?
* Why is my Buttondown webhook signature verification failing, or why are deliveries unsigned?

> **Set a signing key on the Buttondown webhook. Without one, deliveries are unsigned and fail verification.**

## Essential Code (USE THIS)

When the webhook has a signing key, every delivery carries `X-Buttondown-Signature: sha256=<hex>`. The hex is the HMAC-SHA256 of the **raw request body bytes**, keyed with the signing key as UTF-8 (used as-is, not hex- or base64-decoded). Nothing else is signed: no timestamp, no id, so there is no replay window to check.

### Verification (Node.js)

```javascript
const crypto = require('crypto');

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyButtondown(rawBody, header, secret) {
  if (!secret || !header) return false;
  const sig = header.trim().replace(/^sha256=/i, '');   // prefix optional
  // 64 hex chars first: Buffer.from(x, 'hex') silently truncates invalid hex
  if (!/^[0-9a-f]{64}$/i.test(sig)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected);
}

```

### Verification (Python)

```python
import hashlib, hmac, re

def verify_buttondown(raw_body: bytes, header: str, secret: str) -> bool:
    if not secret or not header:
        return False
    sig = re.sub(r"^sha256=", "", header.strip(), flags=re.I)   # prefix optional
    if not re.fullmatch(r"[0-9a-fA-F]{64}", sig, re.ASCII):
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest().encode()
    # bytes on both sides: a non-ASCII header gives a rejection, not a 500
    return hmac.compare_digest(sig.lower().encode(), expected)

```

* Missing or empty header, malformed value or mismatch: respond `401`. Missing server-side secret: `500`.
* Read the body raw (`express.raw({ type: '*/*' })`, `await request.arrayBuffer()`, `await request.body()`) and verify before parsing JSON.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/buttondown-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/buttondown-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/buttondown-webhooks/examples/fastapi/).

## Setup with Hookdeck

In Hookdeck, create a source of the Buttondown type and enter the same signing key. Set a signing key on the Buttondown webhook. Without one, deliveries are unsigned and fail verification. Use the source URL as the webhook URL in Buttondown. See [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/buttondown-webhooks/references/setup.md).

## Event Payload and Types

Every payload is `{ "event_type": "...", "data": { ... } }`; `data` holds ids (for example `newsletter` and `subscriber`). Dispatch on `event_type`. Delivery is at least once and unordered, so make handlers idempotent.

| Event | Triggered When |
| --- | --- |
| `subscriber.created` | A new subscriber is created |
| `subscriber.confirmed` | A subscriber confirmed their enrollment (double opt-in) |
| `subscriber.unsubscribed` | A subscriber manually unsubscribed |
| `email.sent` | An email finished its delivery |
| `automation.invoked` | An automation is manually invoked (for example by a webhook or API call) |

Buttondown documents about 84 event types (subscriber, email, stripe.*, shopify.*, patreon.*, date.* and more). The examples log the rest as unhandled. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/buttondown-webhooks/references/overview.md).

## Important Headers

| Header | Description |
| --- | --- |
| `X-Buttondown-Signature` | `sha256=<hex HMAC-SHA256 of the raw body>`; sent only when a signing key is set |

## Environment Variables

```bash
BUTTONDOWN_WEBHOOK_SECRET=your_signing_key   # the "Signing key" you set on the webhook, used as-is

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 buttondown --path /webhooks/buttondown

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/buttondown-webhooks/references/overview.md) - Buttondown webhook concepts, events, payloads, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/buttondown-webhooks/references/setup.md) - Creating the webhook, setting the signing key, Hookdeck setup
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/buttondown-webhooks/references/verification.md) - Signature details, gotchas, and debugging