Outbound webhooks

For: an admin, or the engineer building the receiver. Takes about 15 minutes.

What this does

An outbound webhook is a URL you register with Stacktic. Every time a signal fires or resolves, Stacktic sends a signed HTTP request to that URL with the details. That covers built-in signals like Ghost access, and signals your organization wrote itself (see Writing a custom signal definition).

Your own code decides what happens next. It could:

  • open a ticket;
  • post somewhere other than Slack;
  • kick off a script;
  • do whatever else your workflow needs.

Stacktic's job ends at sending the request. Everything after that is yours.

This is how Stacktic hands off work it can't do on its own behalf. It never acts on a vendor tool directly. (See What is a signal? for the difference between a signal you act on and a vendor outage you can only wait out.) A webhook is a notification to an endpoint your organization chose. It's the same idea as a Slack card, which is a notification to a channel your organization picked.

Before you start

  • You need to be an Owner to add an endpoint. You add it at Settings → Integrations → Outbound webhooks.
  • You need somewhere to receive the request. That's a URL your own code controls. It must be reachable over the public internet and accept an https POST. Stacktic can't deliver to:
    • an http:// URL;
    • a bare IP address;
    • a private or internal address.
  • You can limit each endpoint to certain events: a signal firing, a signal resolving, or both. You can also, optionally, limit it to certain signals your organization defined. Leave that second part unchecked to receive every signal, built-in and organization-defined alike.

Adding an endpoint

  1. Go to Settings → Integrations → Outbound webhooks and click Add endpoint.
  2. Enter the URL your code will listen on.
  3. Choose which events you want: Signal fired, Signal resolved, or both. Both are checked by default.
  4. Optionally, check one or more signals under Restrict to signals to receive only those. Leave everything unchecked to receive every signal.
  5. Click Add endpoint.

What you'll see

Once you add an endpoint, Stacktic shows you a secret. Copy it now. It's shown exactly once and can't be retrieved later. If you lose it, rotate the endpoint to get a new one. You'll use this secret to check that a request claiming to be from Stacktic really is.

Your endpoint then appears in the list. The list shows its status (Active or Disabled), which events it's subscribed to, and its most recent delivery.

Each endpoint also has its own Send test event button. Use it to confirm two things before a real signal fires: your receiver is reachable, and your signature check works. A test event is sent immediately, not on Stacktic's usual schedule. It shows up in that endpoint's delivery list like any other.

Each endpoint's recent deliveries are listed below it. Each one shows the event, status, response code, and, if it failed, why. Any delivery that's finished (delivered or failed) has a Redeliver button. Use it to resend that delivery on demand once your receiver is fixed.

Verifying a request came from Stacktic

Every request carries these headers:

Header What it is
X-Stacktic-Timestamp When the request was signed, as seconds since the Unix epoch.
X-Stacktic-Signature v1=<hex>: an HMAC-SHA256 signature over the request.
X-Stacktic-Event signal.fired or signal.resolved.
X-Stacktic-Delivery A unique ID for this delivery. Use it to recognize a request you've already handled. See "At-least-once delivery" below.

The signature is HMAC-SHA256, keyed with your endpoint's secret. It's computed over this exact string:

v1:{timestamp}:{raw request body}

It uses the exact bytes of the timestamp header and the body. A reformatted or re-parsed version of either won't match. Here's a minimal check in Node.js:

const crypto = require("node:crypto");

function verifyStacticSignature(secret, timestampHeader, signatureHeader, rawBody) {
  // Reject anything more than 5 minutes old or from the future — a replay-attack guard.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestampHeader));
  if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`v1:${timestampHeader}:${rawBody}`)
    .digest("hex");

  const provided = (signatureHeader || "").replace(/^v1=/, "");
  const expectedBuf = Buffer.from(expected, "hex");
  const providedBuf = Buffer.from(provided, "hex");
  if (expectedBuf.length !== providedBuf.length) return false;

  return crypto.timingSafeEqual(expectedBuf, providedBuf);
}

rawBody has to be the unparsed request body. Most frameworks parse JSON before your handler sees it. So you may need to read the raw body separately, for the bytes to match exactly what Stacktic signed. In Express, that's express.raw() or a similar middleware.

The payload

Every delivery is a JSON body shaped like this:

{
  "format": 1,
  "id": "whd_…",
  "type": "signal.fired",              // or "signal.resolved"
  "occurred_at": "2026-10-05T03:02:11Z",
  "org": { "public_id": "org_…" },
  "signal": {
    "public_id": "sig_…",
    "type_label": "ghost_access",      // "custom" for an org-defined signal
    "definition_key": "super-inactivity-lockout",  // only present for an org-defined signal
    "severity": "info",                // info | warning | critical
    "title": "Super inactivity lockout",
    "summary": "Abigail Bloom hasn't signed in to Super in 82 days — locks in 8 days",
    "status": "active",                // active | resolved | dismissed | snoozed
    "entity": {
      "type": "tool_employee_pair",
      "tool":   { "public_id": "tol_…", "name": "Super", "slug": "super" },
      "person": { "public_id": "emp_…", "name": "Abigail Bloom", "email": "abigailb@helpscout.com" }
    },
    "payload": { /* the same underlying data the signal detail page reads */ }
  },
  "url": "https://app.stacktic.app/signals/sig_…"
}

entity.tool and entity.person are each present only when the signal is actually scoped to a tool or a person. A tool-only signal has no person key at all, rather than a null one. id matches the X-Stacktic-Delivery header on the same request.

At-least-once delivery

Stacktic guarantees a signal fire or resolve reaches your endpoint at least once. It doesn't guarantee exactly once. Any of these causes a retry, so you can occasionally receive the same event twice:

  • a network hiccup;
  • a timeout;
  • your endpoint briefly returning an error.

X-Stacktic-Delivery is the same on every retry of one delivery. So is the payload's own id field, which holds the same value. Track the IDs you've already processed and skip a repeat.

Stacktic counts a request as successful only on a 2xx response, within 10 seconds. Anything else is retried: an error status, a timeout, or your endpoint being unreachable. Retries follow a backoff schedule: 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours. If the sixth attempt still fails, that delivery stops retrying and is marked failed. Use Redeliver once your endpoint is working again.

If an endpoint racks up 10 failed deliveries in a row, with none succeeding in between, Stacktic automatically disables it and shows you why. Re-enable it once you've fixed the problem.

Stacktic never follows a redirect. If your endpoint moves, don't rely on a redirect at the old URL. Delete the old endpoint in Settings and add the new URL.

Security notes

  • Treat your endpoint's secret like a password. Anyone who has it can forge requests that look like they came from Stacktic. If you think it's been exposed, rotate it right away. The old secret stops working the moment you do.
  • Always verify the signature before acting on a request. An unverified POST to your URL could be anyone.
  • Your endpoint must be https. Stacktic won't deliver to a plain http:// URL, or to a private or internal address. That's true even if your receiver happens to be reachable that way from inside your own network.

Troubleshooting

What you'll see What to do
Endpoint shows Disabled with an auto-disabled reason 10 deliveries in a row failed. Fix the underlying problem (often the last error shown), then click Enable. That also resets the failure count.
Send test event fails Check the error shown. A timeout or connection error usually means your URL isn't reachable from the public internet, or is rejecting the connection.
Signature check keeps failing on your side It's almost always the raw body. Make sure you're hashing the exact, unparsed bytes of the request body. Don't hash a value your framework has already parsed and re-serialized.
A delivery is stuck as failed Use Redeliver on that row once your endpoint is fixed. It goes back into the queue immediately, rather than waiting for the schedule to run through again.

Frequently asked questions

Can I get every signal without listing them one by one? Yes. Leave every checkbox under "Restrict to signals" unchecked when adding the endpoint. That's the default.

Can one endpoint receive only fires, not resolves? Yes. Uncheck Signal resolved when adding the endpoint. There's no way to change an existing endpoint's events afterward. Delete it and add a new one instead. That mints a new secret and a new endpoint, so update your receiver's stored secret when you do.

How long are old deliveries kept? 30 days. After that, a delivery's record is removed, whether it succeeded or failed. This doesn't affect whether the event itself was ever delivered. It only affects how long the history is visible in Settings.

Do I need to do anything special for a signal my organization defined, versus a built-in one? No. Both arrive in the same shape. A signal your organization defined also carries definition_key, so you can tell which of your own definitions fired.

See also

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.