# Webhooks

> Receive notifications via HTTP webhooks - create a webhook, authenticate with an API key, and send JSON, Slack-compatible, or plain-text payloads.

Source: https://docs.alphorn.dev/webhooks/

Webhooks are the entry point for notifications into Alphorn. Each webhook provides a unique URL that accepts HTTP POST requests.

## Creating a Webhook

1. Navigate to **Webhooks** in the dashboard
2. Click **Create webhook**
3. Give it a name (e.g., "Production Alerts")
4. Select the channels it should deliver to, optionally with [filters](/filtering) per channel
5. Optionally open **Advanced: field templates** to set [templates](/webhook-templates) - or start from a preset in **Source preset (optional)** (only available when creating a webhook)
6. Click create and **copy the API key** - it is shown only once

The webhook detail page shows the **Endpoint URL** (`https://app.alphorn.dev/n/<publicId>`) and ready-to-use code examples.

### API key

By default the endpoint URL is the only secret: anyone who knows it can send notifications. Switch on the API key when creating the webhook, or later with **Require API key** on the webhook page, and every request must send `Authorization: Bearer <api-key>`. Requests without a valid key then get `401`.

- Alphorn stores only a hash of the key. The full key is shown once, right after creation. Afterwards the webhook page shows just its first characters.
- If you lose the key, click **Regenerate key**. The old key stops working immediately.
- **Regenerate URL** gives the webhook a new endpoint URL, for example if the URL leaked.
- Created the webhook without a key and switched **Require API key** on later? Click **Regenerate key** - a key was never shown for this webhook, so requests fail with `401` until you do.
- Leave the key off for services that can't send custom headers (GitHub, Stripe, many monitoring tools). Turn it on whenever the caller can send headers.

### Disabling and deleting

- To disable a webhook, switch it to **Disabled** on its **Edit** page. A **disabled** webhook rejects requests with `403` until you enable it again. It still counts toward your plan's webhook limit.
- **Deleting** a webhook makes its URL return `404`. Its messages and delivery history stay visible under **Messages**, and it no longer counts toward your plan's webhook limit.
- On the webhook page you can also switch individual linked channels on and off without unlinking them.

## Sending Notifications

Send a POST request to your webhook URL:

```bash
curl -X POST https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT \
  -H "Authorization: Bearer $ALPHORN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Server Alert",
    "message": "CPU usage above 90% on web-01",
    "priority": 4,
    "tags": ["infrastructure", "critical"]
  }'
```

The same request in other languages:

```javascript
await fetch("https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.ALPHORN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    title: "Server Alert",
    message: "CPU usage above 90% on web-01",
    priority: 4,
    tags: ["infrastructure", "critical"],
  }),
});
```

```python
import os
import requests

requests.post(
    "https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT",
    headers={"Authorization": f"Bearer {os.environ['ALPHORN_API_KEY']}"},
    json={
        "title": "Server Alert",
        "message": "CPU usage above 90% on web-01",
        "priority": 4,
        "tags": ["infrastructure", "critical"],
    },
)
```

```go
payload, _ := json.Marshal(map[string]interface{}{
    "title":    "Server Alert",
    "message":  "CPU usage above 90% on web-01",
    "priority": 4,
    "tags":     []string{"infrastructure", "critical"},
})

req, _ := http.NewRequest(
    "POST",
    "https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT",
    bytes.NewBuffer(payload),
)
req.Header.Set("Authorization", "Bearer "+os.Getenv("ALPHORN_API_KEY"))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
```

## Payload Format

### JSON payload

```json
{
  "title": "Notification title",
  "message": "Notification body text",
  "priority": 3,
  "tags": ["tag1", "tag2"],
  "payload": {
    "any": "data you want"
  }
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `title` | string | No | Short notification title (max 200 characters) |
| `message` | string | No | Notification body (max 4,000 characters). Without a message, the whole JSON body is used |
| `priority` | number | No | Priority level 1-5 (1 = lowest, 5 = highest). Out-of-range values are clamped, strings are ignored. Omitted = no priority |
| `tags` | string[] or string | No | Tags for filtering and routing. A comma-separated string also works |
| `payload` | object | No | Arbitrary JSON data you want to attach |

Longer titles and messages are truncated. You don't have to use these field names - Alphorn guesses title and message from common names like `subject`, `text`, or `description`, or you can map any field with [webhook templates](/webhook-templates).

### Structured data

Alphorn keeps the **whole JSON body** of the request as the notification's payload - not only the `payload` key. A common pattern is to put extra data under a `payload` key, like below - deployment metadata, error details, metrics, or anything else your system produces.

```json
{
  "title": "Deploy complete",
  "message": "v2.1.0 deployed to production",
  "payload": {
    "environment": "production",
    "version": "2.1.0",
    "service": "api",
    "commit": "a1b2c3d",
    "duration_seconds": 42,
    "deployed_by": "ci"
  }
}
```

The payload is:

- **Forwarded to channels** - the [Webhook channel](/channels#generic-webhook) sends the original body under a `payload` key, and [Live Stream](/streaming) can include it. Plain-text notifications have an empty payload (`{}`)
- **Available for filtering** - payload filter paths are resolved against the whole body, so the example above is matched with path `payload.environment` equals `production`. A top-level field would just be `environment`
- **Stored with the message** - visible in the dashboard message detail for debugging

This means you can send a single notification and route it differently based on the payload content. For example, route production deployments to PagerDuty but staging deployments to Slack only.

```bash
curl -X POST https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT \
  -H "Authorization: Bearer $ALPHORN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Health check result",
    "message": "All checks passed",
    "priority": 2,
    "payload": {
      "service": "payments",
      "region": "eu-west-1",
      "checks": {
        "database": "ok",
        "redis": "ok",
        "external_api": "ok"
      },
      "response_time_ms": 145
    }
  }'
```

### Slack-compatible payload

Alphorn also accepts Slack-compatible payloads for easy migration:

```json
{
  "text": "Notification message"
}
```

A body is treated as Slack-formatted when it has `text`, `blocks`, or `attachments` and none of `message`, `body`, or `msg`. Webhook templates still win over the Slack fields when set.

### Plain text

Send plain text with `Content-Type: text/plain`. The body becomes the message, and the optional `X-Title`, `X-Priority`, and `X-Tags` (comma-separated) headers set the other fields:

```bash
curl -X POST https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT \
  -H "Authorization: Bearer $ALPHORN_API_KEY" \
  -H "Content-Type: text/plain" \
  -H "X-Title: web-01" \
  -H "X-Tags: infrastructure" \
  -d "Server is back online"
```

## Response

A successful request returns `201 Created`:

```json
{
  "messageId": "0199b2f4-3c1e-7a8d-9f20-5d4c3b2a1e0f"
}
```

The notification is queued for delivery to all matching channels. See the [API Reference](/api-reference#status-codes) for error codes.
