API Reference
Alphorn HTTP API for sending notifications - endpoint, Bearer API key authentication, JSON, Slack-compatible and plain-text payloads, status codes, and code examples.
Alphorn provides a simple HTTP API. No SDK required - any HTTP client works.
The API is also described as an OpenAPI 3.1 specification for code generators and AI tools, with a generated reference for Send a notification and Stream notifications.
Send Notification
POST /n/{publicId}{publicId} is the random 21-character ID in your webhook's Endpoint URL, shown on the webhook detail page (for example https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT). Self-hosted instances use their own BETTER_AUTH_URL as the base.
Headers
| Header | Value |
|---|---|
Authorization | Bearer <api-key> - required unless Require API key is switched off for the webhook. Bearer is case-sensitive |
Content-Type | application/json, text/plain, or omit for auto-detection |
X-Title | Plain-text bodies only: notification title |
X-Priority | Plain-text bodies only: priority 1-5 |
X-Tags | Plain-text bodies only: comma-separated tags |
Authentication
API keys are optional and off by default: switch on Require API key when creating the webhook or later on the webhook page. The key looks like alp_ followed by 64 hex characters and is sent as a Bearer token:
Authorization: Bearer alp_3f9c...- The full key is shown only once, right after the webhook is created with the key switched on. Alphorn stores only a SHA-256 hash, so it cannot be shown again.
- Lost the key? Use Regenerate key on the webhook page. The old key stops working immediately.
- Regenerate URL issues a new endpoint URL; the old URL stops working immediately.
- The Require API key switch on the webhook page turns the key requirement off and on at any time. Without it, anyone who knows the endpoint URL can send notifications, so keep it private.
- If the webhook was created without an API key and you switch Require API key on later, click Regenerate key to get a key - the original one was never shown, so every request gets
401until you do. - Only admins and owners can create, edit, and delete webhooks or change their credentials.
JSON Body
{
"title": "Notification title",
"message": "Notification body text",
"priority": 3,
"tags": ["tag1", "tag2"],
"payload": {}
}Parameters
| Field | Type | Required | Description |
|---|---|---|---|
title | string | No | Short title, truncated to 200 characters |
message | string | No | Notification body, truncated to 4,000 characters. If no message can be found, the whole JSON body is used as the message |
priority | number | No | 1 (min) to 5 (urgent). Rounded and clamped into 1-5. Must be a JSON number - a string like "4" is ignored. If omitted, the notification has no priority |
tags | string[] or string | No | Tags for filtering. A comma-separated string also works |
payload | object | No | Any extra structured data you want to attach. If no title/message candidate is found at the top level, Alphorn also looks inside wrapper keys such as data, event, or payload (see heuristic fallback) |
The body must be a JSON object. Any field is allowed - the whole body is stored with the message, forwarded to channels that support structured data, and available to payload filters and templates. If your JSON doesn't use title/message, Alphorn guesses them from common field names, or you can map them with webhook templates.
Response
201 Created
{
"messageId": "0199b2f4-3c1e-7a8d-9f20-5d4c3b2a1e0f"
}The notification is stored and queued for delivery to all matching channels. The response does not wait for delivery.
Status Codes
| Code | Description |
|---|---|
201 | Notification accepted and queued |
400 | Content-Type declares JSON but the body is not valid JSON, or not a JSON object |
401 | Missing Authorization: Bearer header, or invalid API key |
403 | Webhook is disabled |
404 | Webhook not found |
413 | Body larger than the limit (1 MiB by default, WEBHOOK_MAX_BODY_BYTES when self-hosting) |
429 | Monthly message quota exceeded (hosted plans only) |
508 | Loop detected - the request was forwarded through Alphorn webhooks too many times (3 hops by default, WEBHOOK_MAX_HOPS when self-hosting), or came back to a webhook it already passed |
Error responses have the form {"error": "..."}. A 429 also includes limit, usage, and plan; a 413 includes limit.
The checks run in this order, and the first failing one decides the response: webhook exists (404), API key (401), webhook enabled (403), loop detection (508), quota (429), body size (413), JSON validity (400). For example, a disabled webhook called with a wrong key returns 401.
Content Detection
- Any
Content-Typecontainingjson(includingapplication/*+json) is parsed as JSON. - A body that starts with
{(leading whitespace allowed) is also parsed as JSON when no JSON content type is declared. This coverscurl -d '{...}', which sendsapplication/x-www-form-urlencodedby default. If parsing fails, it is treated as plain text. - Everything else is treated as plain text.
- NUL characters (
\u0000) are removed from the body before processing.
CORS
The endpoint sends no CORS headers, so browsers block requests from other websites. Send notifications from your backend - this also keeps the API key out of the browser.
Slack-Compatible Format
For easy migration from Slack incoming webhooks:
POST /n/{publicId}
Authorization: Bearer <api-key>
Content-Type: application/json
{
"text": "Notification message"
}A body counts as Slack-formatted when it has text, blocks, or attachments and none of message, body, or msg. The message comes from text, then section blocks, then the first attachment's fallback, text, or pretext. The title comes from the first attachment that has a title. Webhook templates still take precedence when set.
Plain Text
POST /n/{publicId}
Authorization: Bearer <api-key>
Content-Type: text/plain
X-Title: Backup finished
X-Priority: 2
X-Tags: backup,nightly
Your notification message hereThe body becomes the message (truncated to 4,000 characters). The optional X-Title, X-Priority, and X-Tags headers set the other fields. Plain-text notifications have no JSON payload, so every payload path counts as missing in filters.
Quotas
On the hosted service, each accepted request counts as one message toward your monthly quota:
| Plan | Messages / month |
|---|---|
| Free | 3,000 |
| Pro | 25,000 |
| Business | 500,000 |
Additional message packs are available on Pro and Business plans. Self-hosted instances without billing configured have no quota. See pricing.
Examples
All examples assume an API key. Leave out the Authorization header if Require API key is switched off.
cURL
curl -X POST https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT \
-H "Authorization: Bearer $ALPHORN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Alert", "message": "Disk space low", "priority": 4}'JavaScript (fetch)
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: "Alert",
message: "Disk space low",
priority: 4,
}),
});Python (requests)
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": "Alert",
"message": "Disk space low",
"priority": 4,
},
)Go
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]interface{}{
"title": "Alert",
"message": "Disk space low",
"priority": 4,
})
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)
}PHP
$ch = curl_init('https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ALPHORN_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'title' => 'Alert',
'message' => 'Disk space low',
'priority' => 4,
]),
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
curl_close($ch);Ruby
require 'net/http'
require 'json'
uri = URI('https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV['ALPHORN_API_KEY']}"
request['Content-Type'] = 'application/json'
request.body = {
title: 'Alert',
message: 'Disk space low',
priority: 4
}.to_json
http.request(request)Privacy & Security
How Alphorn handles your data: TLS encryption, webhook API key security, authentication, channel credential storage, project isolation, and disclosure.
Send a notification
POST /n/{publicId}: send a notification to Alphorn with JSON or plain text. Request body, Bearer API key auth, status codes, and response format.