Publish API
Send one notification request, derive one public channel code, and hand users a web channel URL or bootstrap response.
Smallest useful mental model
Keep the workflow tight: publish key in, request out, channel code derived, channel page shared.
First successful publish
curl -X POST https://alrim.io/<publishKey> \
-H 'Content-Type: application/json' \
-H 'X-Alrim-Severity: warning' \
-H 'X-Alrim-Ring: force' \
-H 'X-Alrim-Level: 2' \
-d '{
"title": "Server deploy",
"body": "v1.2.3 shipped",
"data": {
"service": "api"
}
}'- 1. Paste a
publishKeybetween32and256characters using only lowercase letters and digits. - 2. Start with the path route so the key and payload stay visible in one request.
- 3. Open the derived channel page after success to confirm delivery.
Path auth first
Path-based: POST /<publishKey> is the most direct route when you already know the publish key.
Use POST /<publishKeyA>,<publishKeyB> to send the same payload to multiple derived channels in one request.
Header-based fan-out: POST /api/notify also accepts comma-separated keys in X-Alrim-Key. Keep publish keys out of the request body; the body is always the notification payload.
Root route: POST / generates a random publish key and returns it alongside the send result. GET / returns a CLI bootstrap envelope only for curl-like user agents.
Content type: the parser follows the request content-type. Use JSON, form-urlencoded, plain text, or multipart/form-data; curl --data-urlencode and -d send form-urlencoded by default.
Validation: trimmed keys must be at least 32, at most 256, and use only lowercase letters a-z plus digits 0-9.
Path, multi-target, and root route examples
Accepts JSON, form-urlencoded, plain text, or multipart uploads. Plain text uses the raw request body as body, and ?title=... can supply the notification title when the payload does not already include one.
curl -X POST https://alrim.io/<publishKey> \
-H 'Content-Type: application/json' \
-d '{
"title": "Smoke test",
"body": "publish key in path"
}'A comma-separated publish key list fans out one request to multiple channels. Each key is validated and hashed independently, duplicate derived channels are rejected, and the body remains the notification payload. A request can target up to 50 keys; multipart fan-out with uploaded files is limited to 5 targets.
curl -X POST https://alrim.io/<publishKeyA>,<publishKeyB> \
-H 'Content-Type: application/json' \
-d '{
"title": "Server deploy",
"body": "send the same payload to two channels"
}'The compatibility header route supports the same comma-separated key list via X-Alrim-Key: keyA,keyB. Single-key requests keep the existing direct response shape; comma-separated requests return a batch response.
curl -X POST https://alrim.io/api/notify \
-H 'X-Alrim-Key: <publishKeyA>,<publishKeyB>' \
-H 'Content-Type: application/json' \
-d '{
"title": "Server deploy",
"body": "send the same payload to two channels"
}'JSON body with an explicit application/json content-type header.
curl -X POST https://alrim.io/<publishKey> \
-H 'Content-Type: application/json' \
-d '{
"title": "Smoke test",
"body": "publish key in path"
}'Attach files with repeated files fields. All file extensions are accepted; clients may show a warning before opening executable, script, web, and app package formats. Uploaded bytes are hosted by alrim storage; FCM receives only attachment metadata and public URLs.
curl -X POST https://alrim.io/<publishKey> -H 'X-Alrim-Severity: warning' -H 'X-Alrim-Level: 2' -F 'title=Incident report' -F 'body=Screenshots and runbook attached' -F 'data={"service":"incident-api"}' -F 'files=@./screenshot.png' -F 'files=@./runbook.pdf'Generates a random publish key, sends the notification once, and returns both the send result and the generated publish URL.
curl -X POST https://alrim.io/ \
-H 'Content-Type: application/json' \
--data-binary '{"title":"Server deploy","body":"v1.2.3 shipped"}'When the request user agent looks like a CLI tool such as curl, the root GET route returns a bootstrap JSON payload with a generated publish key and ready-to-copy curl commands.
curl https://alrim.io/
Payload shape
Keep the top level small: title, body, and optional publisher-owned data. Alrim controls are sent outside the request body.
{
"title": "Server deploy",
"body": "v1.2.3 shipped",
"data": {
"service": "api",
"deployId": "v1.2.3"
}
}curl -X POST "https://alrim.io/<publishKey>?title=Server%20deploy&severity=warning&show=1&ring=force&level=2" -H 'Content-Type: text/plain' --data-binary 'v1.2.3 shipped'
curl -X POST https://alrim.io/<publishKey> -H 'Content-Type: application/json' -H 'X-Alrim-Severity: warning' -H 'X-Alrim-Show: 1' -H 'X-Alrim-Ring: force' -H 'X-Alrim-Level: 2' -d '{"title":"Server deploy","body":"v1.2.3 shipped"}'Auto injected: channel_code, alrim_created_at_ms, notification_id, and optional alrim_controls metadata are added to the FCM transport envelope. Your request body stays unchanged.
Form fields: nested publisher data accepts both data[service]=api and data.service=api. Duplicate field names are rejected.
Plain text: text/plain maps the entire request body to body. Add ?title=... to produce { title, body: rawText } without sending JSON.
Publisher data: data is never interpreted by Alrim. Legacy data.severity, data.show, data.ring, and data.level no longer affect delivery behavior.
Alrim controls: send severity, show, ring, and level as X-Alrim-* headers, or as query parameters for curl/manual calls. Query title becomes the payload title only when the payload has no title. Sending the same control in both query and header is rejected.
Attachments: use multipart/form-data and repeated files fields. External attachment URLs are not accepted; alrim hosts uploaded files and returns metadata in attachments. The policy is permissive: upload all extensions, avoid SVG image previews, and warn clients before opening executable/script-like formats.
FCM limit: the encoded data payload is rejected before delivery when it exceeds the server safety budget of about 3800 bytes.
Template notification titles and bodies
Display Rules are client-side templates saved per channel. They do not change the stored send payload or the FCM request; they only change how alrim renders the notification title and body in the web inbox and channel history.
{
"title": "Deploy",
"body": "v1.2.3 shipped",
"data": {
"service": "api",
"status": "open",
"tags": ["release", "backend"],
"meta": {
"region": "iad"
}
}
}Title template:
{{ data.service | upper }} - {{ title | default: "Untitled" }}
Body template:
{{ body | default: data.message | truncate: 120 }}
{{ data.tags | join: ", " | prepend: "tags: " }}
{{ data.status | eq: "open" | if: "needs attention", "resolved" }}API - Deploy v1.2.3 shipped tags: release, backend needs attention
Wrap each expression in {{ ... }}. Plain text outside expressions is kept as-is.
Start with a path such as title, body, data.service, data.meta.region, or data.tags.0. Use $ for the whole template input object.
Chain functions with pipes: {{ data.service | trim | upper }}. Function arguments come after a colon and multiple arguments are comma-separated: {{ status | eq: "open" | if: "Open", "Closed" }}.
Arguments can be quoted strings, numbers, booleans, null, or another path. String escapes supported inside quotes are \\n, \\r, \\t, \\', \\", and \\\\.
Templates are limited to 240 characters. Rendered titles are capped at 240 characters and rendered bodies at 2000.
| Function | Args | Behavior |
|---|---|---|
trim | none | Remove leading and trailing whitespace. |
lower, lowercase | none | Lowercase text. |
upper, uppercase | none | Uppercase text. |
capitalize | none | Trim, then uppercase the first character. |
truncate | n | Keep the first n characters. |
default | value | Use value only when the current value is null or missing. |
append, concat | value | Add text after the current value. |
prepend | value | Add text before the current value. |
replace | from, to | Replace exact text matches. |
| Function | Args | Behavior |
|---|---|---|
contains | value | Case-insensitive substring check. |
eq | value | Exact text equality. |
ne | value | Exact text inequality. |
if | yes, no | Return yes when the current value is truthy, otherwise no. |
length | none | String or array length, object key count, or text length for other displayable values. |
join | separator | Join array items with separator. |
json | none | Stringify the current value as compact JSON. |
pretty | none | Render objects/arrays as indented JSON with 2-space formatting; strings and primitive values pass through as text. |
Unknown paths, invalid expressions, or unknown functions render as empty text.
{{ title }} keeps the sent title.
{{ body | default: $ | pretty }} keeps the sent body, then falls back to the whole input object as readable JSON.
{{ data.service | upper }} renders a nested data value.
{{ data.tags | join: ", " }} renders an array as text.
{{ data.status | eq: "open" | if: "Open", "Closed" }} maps a boolean expression to labels.
{{ data.meta | json }} renders an object as compact JSON.
{{ $ | pretty }} renders the full payload as indented JSON; string values pass through without extra quotes.
Response envelopes
POST /<publishKey> returns the direct notify result.
Comma-separated publish keys return a batch envelope with one result per target and use HTTP 207 when only some targets fail. POST / adds generated publish key metadata on top of the send result. GET / for CLI user agents returns only the generated publish key bootstrap payload.
{
"ok": true,
"channel_code": "3ba3f5f43b92602683c19aee62a20342",
"channel_url": "https://alrim.me/3ba3f5f43b92602683c19aee62a20342",
"notification_id": "d6d5f95b3bbd4fb4b8d98f7bc8e89c17",
"created_at_ms": 1712345678901,
"attachments": []
}{
"ok": true,
"total": 2,
"sent": 2,
"failed": 0,
"results": [
{
"index": 0,
"ok": true,
"status": 200,
"body": {
"ok": true,
"channel_code": "3ba3f5f43b92602683c19aee62a20342",
"channel_url": "https://alrim.me/3ba3f5f43b92602683c19aee62a20342",
"notification_id": "d6d5f95b3bbd4fb4b8d98f7bc8e89c17",
"created_at_ms": 1712345678901,
"attachments": []
}
},
{
"index": 1,
"ok": true,
"status": 200,
"body": {
"ok": true,
"channel_code": "e704fc0d4f7f8f0f4ff3f2ea1a9ee5c8",
"channel_url": "https://alrim.me/e704fc0d4f7f8f0f4ff3f2ea1a9ee5c8",
"notification_id": "afd6728f7be84082922553cc4ca1363b",
"created_at_ms": 1712345678901,
"attachments": []
}
}
]
}{
"ok": false,
"error": "publishKey must use only lowercase letters (a-z) and digits (0-9). See https://alrim.io/doc for publishKey rules and examples."
}{
"ok": true,
"publish_key": "5765aff90d6e4d2e169b3b496c418cc9",
"publish_url": "https://alrim.io/5765aff90d6e4d2e169b3b496c418cc9",
"channel_code": "3ba3f5f43b92602683c19aee62a20342",
"channel_url": "https://alrim.me/3ba3f5f43b92602683c19aee62a20342",
"short_code": "123-456",
"short_code_expires_at_ms": 1712346578901,
"short_code_notice": "Short code valid for 15 minutes.",
"notification_id": "d6d5f95b3bbd4fb4b8d98f7bc8e89c17",
"created_at_ms": 1712345678901,
"curl": "curl -X POST "https://alrim.io/5765aff90d6e4d2e169b3b496c418cc9" ..."
}{
"ok": true,
"publish_key": "5765aff90d6e4d2e169b3b496c418cc9",
"publish_url": "https://alrim.io/5765aff90d6e4d2e169b3b496c418cc9",
"channel_code": "3ba3f5f43b92602683c19aee62a20342",
"channel_url": "https://alrim.me/3ba3f5f43b92602683c19aee62a20342",
"short_code": "123-456",
"short_code_expires_at_ms": 1712346578901,
"short_code_notice": "Short code valid for 15 minutes.",
"curl": "curl -X POST "https://alrim.io/5765aff90d6e4d2e169b3b496c418cc9" ...",
"curl_json": "curl -X POST "https://alrim.io/5765aff90d6e4d2e169b3b496c418cc9" ...",
"curl_form": "curl -X POST "https://alrim.io/5765aff90d6e4d2e169b3b496c418cc9" ...",
"curl_text": "curl -X POST "https://alrim.io/5765aff90d6e4d2e169b3b496c418cc9" ..."
}Server keys, public subscribe UI
@alrim-me/sdk/server now uses a channel-first API. Create a publisher with a private secret, call channel(name), and publish with channel.publish(payload, options). Single-channel sends use POST /<publishKey>; channel arrays fan out through POST /api/notify with a comma-separated X-Alrim-Key header. Browser widgets receive only public channel config.
createAlrimClient() becomes createAlrimPublisher().
scope becomes a private channel name passed to publisher.channel('deploys').
publishNotification(...) becomes channel.publish(payload, options); controls and attachments live in the second argument.
createSubscribeConfig(...) becomes channel.subscribeConfig(...). The returned config is public and safe to pass to browser code.
import { createAlrimPublisher } from '@alrim-me/sdk/server';
const alrim = createAlrimPublisher({
secret: process.env.ALRIM_SECRET!
});
const deploys = alrim.channel('deploys');
export async function POST() {
await deploys.publish(
{
title: 'Server deploy',
body: 'v1.2.3 shipped',
data: { service: 'api' }
},
{
controls: { severity: 'warning', ring: 'force', level: 2 }
}
);
return Response.json({
subscribe: deploys.subscribeConfig({
title: 'Deploys'
})
});
}const releaseChannels = alrim.channel(['deploys', 'incidents']);
await releaseChannels.publish({
title: 'Server deploy',
body: 'v1.2.3 shipped'
});
// The SDK derives one publish key per channel name and sends:
// POST /api/notify
// X-Alrim-Key: <deploysKey>,<incidentsKey>
// Body remains the notification payload only.
// Fan-out channels are publish-only; use alrim.channel('name') for subscribe config.import { createAlrimChannel } from '@alrim-me/sdk/server';
const alerts = createAlrimChannel({
publishKey: process.env.ALRIM_PUBLISH_KEY!
});
await alerts.publish({
title: 'Direct publish',
body: 'Using an existing publish key'
});
const fanout = createAlrimChannel({
publishKey: [process.env.ALERTS_KEY!, process.env.INCIDENTS_KEY!]
});
await fanout.publish({ title: 'Fan-out', body: 'Sent to two publish keys' });await alrim.channel('incidents').publish(
{
title: 'Incident report',
body: 'Screenshots and runbook attached',
data: { incidentId: 'inc_123' }
},
{
attachments: [
chartFile,
{ name: 'runbook.txt', content: 'restart api workers', type: 'text/plain' },
{ name: 'payload.json', content: JSON.stringify({ incidentId: 'inc_123' }), type: 'application/json' }
]
}
);
// Attachments switch the request to multipart/form-data.
// Pass File/Blob objects directly, or { name, content, type } on the server.
// The JSON payload is sent in the data field; files are repeated files fields.import { AlrimPublishError } from '@alrim-me/sdk/server';
try {
await alrim.channel('deploys').publish({ title: 'Ping' });
} catch (error) {
if (error instanceof AlrimPublishError) {
console.error(error.status, error.body);
}
}import { createAlrimPublisher } from '@alrim-me/sdk/server';
const alrim = createAlrimPublisher({
secret: process.env.ALRIM_SECRET!
});
const deploys = alrim.channel('deploys');
export const load = () => ({
subscribe: deploys.subscribeConfig({
title: 'Deploys'
})
});import { createAlrimPublisher } from '@alrim-me/sdk/server';
const alrim = createAlrimPublisher({
secret: process.env.ALRIM_SECRET!
});
export default defineEventHandler(async () => {
const deploys = alrim.channel('deploys');
await deploys.publish({
title: 'Server deploy'
});
return {
subscribe: deploys.subscribeConfig({
title: 'Deploys'
})
};
});import express from 'express';
import { createAlrimPublisher } from '@alrim-me/sdk/server';
const app = express();
const alrim = createAlrimPublisher({
secret: process.env.ALRIM_SECRET!
});
const deploys = alrim.channel('deploys');
app.post('/deploy', express.json(), async (_req, res) => {
await deploys.publish({ title: 'Server deploy' });
res.json({ ok: true });
});
app.get('/subscribe-config', (_req, res) => {
res.json(deploys.subscribeConfig({
title: 'Deploys'
}));
});<script src="https://alrim.me/sdk/v1/alrim-subscribe.js"></script> <alrim-me channel-code="3ba3f5f43b92602683c19aee62a20342" title="Deploys" origin="https://alrim.me" ></alrim-me>