# 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.

Source: https://docs.alphorn.dev/api-reference/

Alphorn provides a simple HTTP API. No SDK required - any HTTP client works.

The API is also described as an [OpenAPI 3.1 specification](https://docs.alphorn.dev/openapi.json) for code generators and AI tools, with a generated reference for [Send a notification](/endpoints/send-notification) and [Stream notifications](/endpoints/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 `401` until you do.
- Only admins and owners can create, edit, and delete webhooks or change their credentials.

### JSON Body

```json
{
  "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](/webhook-templates#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](/filtering) and [templates](/webhook-templates). If your JSON doesn't use `title`/`message`, Alphorn guesses them from common field names, or you can map them with [webhook templates](/webhook-templates).

### Response

`201 Created`

```json
{
  "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-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](/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](/filtering#payload-paths).

## 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](https://alphorn.dev/#pricing).

## Examples

All examples assume an API key. Leave out the `Authorization` header if **Require API key** is switched off.

### cURL

```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": "Alert", "message": "Disk space low", "priority": 4}'
```

### JavaScript (fetch)

```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: "Alert",
    message: "Disk space low",
    priority: 4,
  }),
});
```

### Python (requests)

```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": "Alert",
        "message": "Disk space low",
        "priority": 4,
    },
)
```

### Go

```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

```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

```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)
```
