AlphornAlphorn Docs

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

HeaderValue
AuthorizationBearer <api-key> - required unless Require API key is switched off for the webhook. Bearer is case-sensitive
Content-Typeapplication/json, text/plain, or omit for auto-detection
X-TitlePlain-text bodies only: notification title
X-PriorityPlain-text bodies only: priority 1-5
X-TagsPlain-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 401 until 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

FieldTypeRequiredDescription
titlestringNoShort title, truncated to 200 characters
messagestringNoNotification body, truncated to 4,000 characters. If no message can be found, the whole JSON body is used as the message
prioritynumberNo1 (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
tagsstring[] or stringNoTags for filtering. A comma-separated string also works
payloadobjectNoAny 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

CodeDescription
201Notification accepted and queued
400Content-Type declares JSON but the body is not valid JSON, or not a JSON object
401Missing Authorization: Bearer header, or invalid API key
403Webhook is disabled
404Webhook not found
413Body larger than the limit (1 MiB by default, WEBHOOK_MAX_BODY_BYTES when self-hosting)
429Monthly message quota exceeded (hosted plans only)
508Loop 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-Type containing json (including application/*+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 covers curl -d '{...}', which sends application/x-www-form-urlencoded by 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 here

The 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:

PlanMessages / month
Free3,000
Pro25,000
Business500,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)

On this page