Skip to main content

Notifications

This guide covers delivery patterns after a query triggers, with Auto as the source of truth for event emission.

note
Query title and description in notifications

The title and description you set on the query are included in outbound notifications across channels (Telegram, webhook, notify). Recipients often see an alert hours or days after the query was created — these fields are what make it immediately clear what fired and why it was set up. See Title and Description.

Fastest Path to First Alert
  1. Create your query with Create Query.
  2. Start delivery with telegram_bot, webhook, or SSE stream.
  3. Run Agent Runner so the agent can continue actions after each trigger.

Delivery Channels (Side-by-Side)

ChannelBest forSetup
WebhookProduction agent automationaction.type = "webhook" with signature verification and queue/worker processing
Telegram BotFast human-readable alertsaction.type = "telegram_bot" with params.botToken + params.chatId for direct delivery, or webhook/SSE relay for custom formatting. The body is auto-composed from query title + description + trigger context — no message param.
SSE StreamReal-time event consumersAlways available regardless of the selected action — GET /v2/auto/queries/stream for every query on one connection, or GET /v2/auto/queries/{queryId}/stream for a single query, using the same auth as query creation. For minimal setup, add a notify action with a message; those notifications are retrievable only via SSE or poll.

Event Payload Contract (Canonical)

To keep webhook, Telegram relay, and SSE processing consistent, normalize incoming events into one internal contract.

Canonical event object:

Note: SSE uses Server-Sent Events format with event: notification. The canonical payload below is for documentation reference only — actual SSE delivery uses the format defined in the SSE section.

{
"id": 12345,
"type": "athena_query_notify_only",
"category": "alerts",
"title": "Query triggered: BTC > 100000",
"body": "BTC price crossed above 100000",
"data": {
"queryId": "a12d20ff-6cb2-433e-afed-cc2e6a0380b6"
},
"priority": "high",
"createdAt": "2026-04-01T12:00:00.000Z"
}

Webhook Event Sample

Headers:

X-Auto-Event-Id: 12345
X-Auto-Signature-Timestamp: 1775035200
X-Auto-Signature: v1=<hmac_hex>

Body example:

{
"id": 12345,
"type": "athena_query_notify_only",
"category": "alerts",
"title": "Query triggered: BTC > 100000",
"body": "BTC price crossed above 100000",
"data": {
"queryId": "a12d20ff-6cb2-433e-afed-cc2e6a0380b6"
},
"priority": "high",
"createdAt": "2026-04-01T12:00:00.000Z"
}

Telegram Relay Event Sample

If you use direct telegram_bot actions, delivery can go straight to Telegram.
If you use relay mode, normalize to a Telegram job object:

{
"id": 12345,
"queryId": "a12d20ff-6cb2-433e-afed-cc2e6a0380b6",
"channel": "telegram",
"chatId": "<CHAT_ID>",
"text": "BTC trigger fired: price > threshold",
"priority": "high"
}

SSE Event Sample

SSE frame:

id: 9df34377-4d82-4b3c-a016-b0ba27556aa1
event: notification
data: {"status":"triggered","title":"Plan Triggered","body":"BTC RSI Breakout","queryId":"a12d20ff-6cb2-433e-afed-cc2e6a0380b6","executionId":"2dbf0d70-a85f-4f67-9bd3-876e8fd89f86","triggerTime":"2026-04-01T12:00:00.000Z","conditionsMet":2,"timestamp":1774328400000}

status is one of triggered, stopped, ended, or update. The payload also carries the query's execution context when present (executionId, triggerTime, conditionsMet, autoDetails).

Note: The id in the SSE frame header is the notification outbox event UUID, not a query ID. queryId is a top-level field on the payload — use it to correlate with poll results via /v2/auto/queries/{queryId}.

Implementation note:

  • Preserve the original payload for audit/debug.
  • Map to the canonical contract before queueing downstream work.

Best Practice: Run a Background Orchestrator

When a condition triggers, avoid handling business logic inline in the event ingress handler.

Recommended pattern:

  1. Receive Auto event.
  2. Verify + dedupe (eventId).
  3. Push a job to your runner queue.
  4. Let a worker decide what the agent should do next.
  5. Execute and log outcomes.

Why this is preferred:

  • Keeps ingestion fast and reliable
  • Prevents duplicate downstream actions
  • Makes policy and retries easier to manage
  • Scales from local dev to cloud workers

Use action.type = "webhook" and verify Auto signatures on receipt. For new integrations, provide an explicit per-webhook signingSecret:

{
"stepId": "step_1",
"type": "webhook",
"params": {
"url": "https://your-runner.example/auto/events",
"signingSecret": "your-webhook-signing-secret",
"allNotifications": true
}
}

LLM callback webhooks use the same destination params at query creation time:

{
"stepId": "step_1",
"type": "llm",
"params": {
"action": "tokenAnalysis",
"symbol": "BTC",
"callback": {
"action": {
"type": "webhook",
"params": {
"url": "https://your-runner.example/auto/events",
"signingSecret": "your-webhook-signing-secret",
"allNotifications": true
}
}
}
}
}

signingSecret is write-only. Elfa stores it for delivery signing, but does not return it in public query, execution, webhook, SSE, or LLM callback payloads.

Which Secret Should I Use?

  • Generate a separate high-entropy webhook secret for each webhook/action, for example openssl rand -hex 32.
  • API-key clients: do not use x-elfa-api-key or ELFA_HMAC_SECRET as the webhook secret. The API key authenticates your client to Elfa; the request HMAC secret signs your requests to Elfa; webhook.params.signingSecret verifies outbound webhook deliveries from Elfa to your server.
  • x402/agent clients: prefer explicit webhook.params.signingSecret. If it is omitted, older x402/agent webhooks may be signed with SHA256(x-elfa-agent-secret) as the HMAC key. Treat that as a legacy fallback, not the recommended setup.
  • If no explicit signingSecret is provided, signature headers may be absent depending on access mode and server configuration. Require an explicit signingSecret for production receivers.

Signature inputs:

expected = HMAC_SHA256(signingSecret, timestamp + "." + eventId + "." + rawBody)

Node.js Verification Snippet

import crypto from "crypto";

export function verifyAutoWebhook(
signingSecret: string,
rawBody: string,
signatureHeader: string,
timestamp: string,
eventId: string,
): boolean {
if (!signatureHeader?.startsWith("v1=")) return false;
const givenHex = signatureHeader.slice(3);
if (!/^[0-9a-f]{64}$/i.test(givenHex)) return false;

const payload = `${timestamp}.${eventId}.${rawBody}`;
const expectedHex = crypto
.createHmac("sha256", signingSecret)
.update(payload)
.digest("hex");

const given = Buffer.from(givenHex, "hex");
const expected = Buffer.from(expectedHex, "hex");
if (given.length !== expected.length) return false;
return crypto.timingSafeEqual(given, expected);
}

Operational notes:

  • Use the exact raw HTTP request body bytes/string when computing rawBody.
  • Enforce replay window checks with X-Auto-Signature-Timestamp.
  • Deduplicate by X-Auto-Event-Id.
  • Return 2xx quickly, then process asynchronously.

2) Telegram Bot Delivery

Telegram can be used as a primary delivery channel from day one.

Recommended patterns:

  1. Direct: use action.type = "telegram_bot" in your query, with params: { botToken, chatId }.
  2. Relay: receive webhook/SSE events, transform payloads, and send to Telegram Bot API.

Relay flow:

  1. Receive Auto events (webhook/SSE).
  2. Transform message payload.
  3. Send message to Telegram Bot API.

Get Telegram Bot Token

  1. Open @BotFather in Telegram.
  2. Run /newbot.
  3. Save the bot token (treat as secret).

Get Chat ID

  1. Send any message to the bot (or in a group where the bot is present).
  2. Call:
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates"
  1. Read message.chat.id from the response.

Direct Chats, Groups, and Supergroups

telegram_bot delivers to private chats, groups, and supergroups. Channels are not supported and are rejected at create time.

Two things to watch for when using a group:

  • Group chat IDs are negative. A group's chatId looks like -1001234567890, not a positive integer. Copy it verbatim from getUpdates — including the leading -.
  • The bot must be able to post in the chat. At create time Auto checks the bot's membership and permissions. Administrators and the chat creator always pass; an ordinary member passes unless the chat has disabled can_send_messages; a restricted member passes only if its own can_send_messages is enabled. If the bot cannot post, create fails with Telegram bot cannot send messages in this chat (check its permissions).

If a group is later upgraded to a supergroup, Telegram issues it a new chat ID. Auto handles this for you — it detects the migration, updates the stored chat ID, and retries the send. No action needed on your side.

Send Message

curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage" \
-H "Content-Type: application/json" \
-d '{
"chat_id": "<CHAT_ID>",
"text": "Auto trigger fired for BTC RSI"
}'

3) SSE Stream Delivery

There are two streams. Both are free, both are GET, and neither requires HMAC.

EndpointScopeUse when
GET /v2/auto/queries/streamEvery query you own, on one connectionDefault choice. Correlate events with the payload's queryId.
GET /v2/auto/queries/{queryId}/streamA single queryYou only care about one query, or you are on x402 / an agent identity.

Prefer the account-wide stream over opening one connection per query.

Auth: the stream requires the same auth used to create the query — send x-elfa-api-key: <YOUR_API_KEY> for API-key queries, or the x402 secret for x402 queries. It is not limited to x-elfa-api-key.

The account-wide stream is API-key only

GET /v2/auto/queries/stream is not available to agent identities (x-elfa-agent-secret) and is not exposed on x402 — both return 403. An agent's identity is self-asserted, and the per-query stream tolerates that only because a caller must also know the query's UUID; an account-wide firehose would drop that second factor. Agents keep the per-query stream.

Quick test:

curl -N "https://api.elfa.ai/v2/auto/queries/<QUERY_ID>/stream" \
  -H "x-elfa-api-key: <YOUR_API_KEY>"

Both streams emit the same frames (event: notification, id = outbox event UUID) and send a : keep-alive comment every 15s. Events are live-only: there is no replay and Last-Event-ID is ignored.

Stream lifecycle

Each stream closes once there is nothing left to deliver, emitting a final end event:

/queries/stream/queries/{queryId}/stream
410 on connectYou have no active queriesThe query is already terminal and drained
Closes whenNo active query and no pending execution, held for 30sThe query reaches a terminal state (triggered, failed, expired, cancelled) and its notifications drain
Final framedata: {"code":"USER_STREAM_CLOSED"}data: {"code":"QUERY_STREAM_CLOSED","status":"<terminalStatus>","queryId":"<uuid>"}

A recurring query is never terminal, so its stream stays open across triggers. If the stream fails after it has started, it emits event: error with {"code":"STREAM_UNAVAILABLE"}.

Open the account-wide stream after creating at least one query — it returns 410 when you have none.

SSE best practices:

  • Run SSE consumers on server/worker (not browser-only clients) so you can set auth headers.
  • Reconnect automatically with backoff.
  • Persist last seen event IDs to avoid duplicate downstream actions.

Troubleshooting

SymptomLikely CauseFix
400 / 401 when polling or streamingMissing/invalid API key or auth headersSend x-elfa-api-key; include HMAC headers where required.
Webhook signature mismatchSigning wrong payload (not raw body) or wrong secretVerify with timestamp + "." + id + "." + rawBody and the exact signingSecret as the HMAC key.
Duplicate downstream actionsNo idempotency on event processingDeduplicate by id before enqueue/execute.
Event received but agent does nothingIngress processes inline and times out or failsACK quickly, push to queue, process in worker.
SSE disconnect/reconnect loopsNo retry/backoff or unstable consumerAdd reconnect backoff and heartbeat monitoring.
Missing triggers after some timeQuery expired or was cancelledPoll query status and check expiresIn, status, and last evaluations.
Signature timestamp rejectedRunner clock skewSync server clock (NTP) and enforce bounded replay window.
Telegram bot cannot send messages in this chat at createBot isn't in the group, or the group revokes can_send_messages for itAdd the bot to the chat and grant it send permission (or make it an admin), then retry.
Telegram create fails for a groupchatId is missing the leading -, or the target is a channelGroup IDs are negative (-1001234567890). Channels are not supported.
StageSuggested Pattern
PrototypeAuto Telegram + local worker
ProductionAuto webhook + queue + worker
Real-time operationsAuto SSE + worker service

Local vs Cloud Deployment

EnvironmentSuggested Setup
Local developmentSSE consumer + single worker process
Cloud productionWebhook ingress + queue + worker autoscaling

For full orchestration guidance, see Agent Runner. For concrete local/cloud blueprints, see Reference Implementations (Local and Cloud).