Elyra
Elyra The coding agent e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Start here
Concepts
Elyra

Webhooks

Tell another system when something happens here — a CI server, a chat bot, a dashboard, a script.

Administration → Webhooks. Workspace owners and admins only, for reasons that are worth reading below.


What you can subscribe to

Event When
issue.created An issue was opened
issue.updated Anything about it changed
issue.status_changed It moved
issue.assigned It was handed to somebody, or to an agent
comment.created Somebody or something commented
run.completed An agent finished
run.failed An agent could not
delivery.recorded A pull request, branch or link was delivered — or its state changed

One save can fire two events. Moving an issue and reassigning it sends both issue.status_changed and issue.assigned, because they answer different questions and a consumer subscribed to one should not have to infer the other.

Only the ends of a run are sent. A task going from queued to dispatched is machinery, and an endpoint interested in the outcome should not pay for every step.


What arrives

POST /your-endpoint
X-Felagi-Event: issue.status_changed
X-Felagi-Timestamp: 1785540000
X-Felagi-Signature: sha256=…
Content-Type: application/json

{
  "event": "issue.status_changed",
  "delivered_at": "2026-08-01T05:52:38+00:00",
  "workspace": "acme-industries",
  "data": {
    "id": "ACM-231",
    "title": "Rate limit the export endpoint",
    "status": "in_review",
    "assignee": { "type": "agent", "id": 4, "name": "Freya" },
    "url": "https://felagi.example.com/app/issues/231"
  }
}

The shapes are the same ones the API returns, so reading a webhook and then fetching the issue does not hand you two vocabularies for one thing.

It will never contain an agent's instructions or environment, a run's lease token, or its session id. One is a credential; the other identifies a conversation held on somebody's machine.


Verify the signature

Anybody can send you a request that says it is us.

import crypto from 'node:crypto';

const timestamp = req.headers['x-felagi-timestamp'];
const expected = 'sha256=' + crypto
  .createHmac('sha256', SECRET)
  .update(`${timestamp}.${rawBody}`)
  .digest('hex');

const signed = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(req.headers['x-felagi-signature']),
);
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;

if (!signed || !fresh) return res.writeHead(401).end();

Three things that matter in those nine lines:

The timestamp is signed with the body, not the body alone. Signing only the body means a captured delivery can be replayed forever.

Reject anything older than a few minutes. The signature stays valid; the delivery should not.

Compare in constant time. A byte-by-byte comparison that returns early leaks how much of a forged signature was right.

Use the raw body, before any JSON parsing. A round trip through a parser changes the bytes and the signature covers the bytes.


Where a webhook may point

This is a request Félagi makes on somebody's instruction, which is the definition of a server-side request forgery if nobody looks at it.

Link-local addresses are always refused. 169.254.169.254 is the cloud metadata endpoint on every major provider, and a webhook aimed there is somebody reading the instance's own credentials through us. No setting changes that.

Private and loopback addresses are refused by default. The reasoning is the same as for the two-factor allowlist: anybody can create a workspace, so "only a workspace admin can set this" is not a boundary. Without this a workspace admin could use Félagi to probe the network Félagi runs on.

A self-hosted installation with a genuine internal endpoint opts in:

FELAGI_WEBHOOK_ALLOW_PRIVATE=true

Deliberately, and in writing. Redirects are not followed either — a 302 to an internal address is how the check gets walked around.

Credentials in the URL are refused. Deliveries are signed instead.


When your endpoint is down

Four attempts, backing off 10 seconds, a minute, five minutes. A deploy takes about that long, so an endpoint that comes back should not have missed the event.

A 4xx is not retried. The endpoint understood and refused; asking again will not change its mind.

After twelve consecutive failures Félagi stops, marks the webhook as stopped, and says so on the page. Retrying a gone endpoint forever costs a queue worker on every event and tells nobody. Fix it and press Resume — which also clears the count, because somebody switching it back on has fixed something and should not start one failure from the limit.

One success forgives everything before it. Eleven failures then a delivery is a working endpoint that had a bad afternoon.

Every attempt is logged — status, duration, error, which try — and shown under the webhook. "Did you send it?" is the first question anybody asks. Logs are kept for seven days; felagi:reap prunes them.


A queue worker must handle the webhooks lane

Deliveries are queued, so nothing is sent unless something is working that lane:

php artisan queue:work redis --queue=broadcasts,mail,webhooks,default

A worker without it looks healthy and delivers nothing.


Not there yet

  • No replay. A failed delivery cannot be resent from the interface; only a new event can.
  • No per-event endpoints. One webhook, one URL, a set of events.
  • No hostname resolution check. A hostname that resolves to a private address gets through. Catching it needs a DNS lookup on save and another at delivery, because the answer can change in between — and a check that can be defeated by changing a DNS record is worse than one that is documented as absent.
  • No payload filtering. You get the whole shape or you do not subscribe.