Webhooks
Receive notifications via HTTP webhooks - create a webhook, authenticate with an API key, and send JSON, Slack-compatible, or plain-text payloads.
Webhooks are the entry point for notifications into Alphorn. Each webhook provides a unique URL that accepts HTTP POST requests.
Creating a Webhook
- Navigate to Webhooks in the dashboard
- Click Create webhook
- Give it a name (e.g., "Production Alerts")
- Select the channels it should deliver to, optionally with filters per channel
- Optionally open Advanced: field templates to set templates - or start from a preset in Source preset (optional) (only available when creating a webhook)
- 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
401until 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
403until 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:
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:
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"],
}),
});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"],
},
)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
{
"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.
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.
{
"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 sends the original body under a
payloadkey, and Live Stream 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.environmentequalsproduction. A top-level field would just beenvironment - 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.
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:
{
"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:
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:
{
"messageId": "0199b2f4-3c1e-7a8d-9f20-5d4c3b2a1e0f"
}The notification is queued for delivery to all matching channels. See the API Reference for error codes.
Self-Hosting
Self-host Alphorn with Docker Compose, Podman, or from source - PostgreSQL 18 setup, all environment variables, web/worker modes, reverse proxy and HTTPS, backups, and updating.
Channels
Supported notification channels (Slack, Discord, Teams, Telegram, email, SMS, push, PagerDuty, Opsgenie, webhooks, SSE), their configuration, message length limits, and priority mapping.