Skip to main content
Webhooks are a powerful way to keep your backend in sync with Stack. They allow you to receive real-time updates when events occur in your Stack project, such as when a user or team is created, updated, or deleted. For payload schemas and each webhook event, see the webhook API reference.

Setting up webhooks

In the Stack dashboard, you can create a webhook endpoint in the “Webhooks” section. After creating this endpoint with your server URL, you will start receiving POST requests with a JSON payload at that endpoint. The event payload will look something like this:
{
  "type": "team.created",
  "data": {
    "id": "2209422a-eef7-4668-967d-be79409972c5",
    "display_name": "My Team",
    ...
  }
}

Testing webhooks locally

You can use services like Svix Playground or Webhook.site to test the receiving of webhooks or relay them to your local development environment.

Verifying webhooks

To ensure the webhook is coming from Stack (and not from a malicious actor) and is not prone to replay attacks, you should verify the request. Stack signs the webhook payload with a secret key that you can find in the endpoint details on the dashboard. You can verify the signature using the Svix client library. Check out the Svix documentation for instructions on how to verify the signature in JavaScript, Python, Ruby, and other languages. Here are example handlers across the supported frameworks:
Next.js
// app/api/webhooks/stack/route.ts
import { Webhook } from "svix";

export async function POST(request: Request) {
  const secret = process.env.STACK_WEBHOOK_SECRET!;
  const payload = await request.text();
  const headers = {
    "svix-id": request.headers.get("svix-id") ?? "",
    "svix-timestamp": request.headers.get("svix-timestamp") ?? "",
    "svix-signature": request.headers.get("svix-signature") ?? "",
  };

  const wh = new Webhook(secret);
  // Throws on error, returns the verified content on success.
  const verifiedPayload = wh.verify(payload, headers);

  return Response.json({ ok: true, type: verifiedPayload.type });
}
// server/webhooks.ts
// Webhooks must be received on a server, even if your frontend is a React app.
import express from "express";
import { Webhook } from "svix";

const app = express();
app.use("/api/webhooks/stack", express.text({ type: "application/json" }));

app.post("/api/webhooks/stack", (req, res) => {
  const wh = new Webhook(process.env.STACK_WEBHOOK_SECRET!);
  const verifiedPayload = wh.verify(req.body, {
    "svix-id": req.header("svix-id") ?? "",
    "svix-timestamp": req.header("svix-timestamp") ?? "",
    "svix-signature": req.header("svix-signature") ?? "",
  });

  res.json({ ok: true, type: verifiedPayload.type });
});
import express from "express";
import { Webhook } from "svix";

const app = express();
app.use("/api/webhooks/stack", express.text({ type: "application/json" }));

app.post("/api/webhooks/stack", (req, res) => {
  const wh = new Webhook(process.env.STACK_WEBHOOK_SECRET);
  const verifiedPayload = wh.verify(req.body, {
    "svix-id": req.header("svix-id") ?? "",
    "svix-timestamp": req.header("svix-timestamp") ?? "",
    "svix-signature": req.header("svix-signature") ?? "",
  });

  res.json({ ok: true, type: verifiedPayload.type });
});
import { createServer } from "node:http";
import { Webhook } from "svix";

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/api/webhooks/stack") {
    res.writeHead(404).end();
    return;
  }

  const payload = await new Promise((resolve, reject) => {
    let body = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => {
      body += chunk;
    });
    req.on("end", () => resolve(body));
    req.on("error", reject);
  });

  const wh = new Webhook(process.env.STACK_WEBHOOK_SECRET);
  const verifiedPayload = wh.verify(payload, {
    "svix-id": req.headers["svix-id"] ?? "",
    "svix-timestamp": req.headers["svix-timestamp"] ?? "",
    "svix-signature": req.headers["svix-signature"] ?? "",
  });

  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ ok: true, type: verifiedPayload.type }));
}).listen(3000);
// server.js
// Browser-only JavaScript apps still need a server endpoint to receive webhooks.
import { createServer } from "node:http";
import { Webhook } from "svix";

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/api/webhooks/stack") {
    res.writeHead(404).end();
    return;
  }

  const payload = await new Promise((resolve, reject) => {
    let body = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => {
      body += chunk;
    });
    req.on("end", () => resolve(body));
    req.on("error", reject);
  });

  const wh = new Webhook(process.env.STACK_WEBHOOK_SECRET);
  const verifiedPayload = wh.verify(payload, {
    "svix-id": req.headers["svix-id"] ?? "",
    "svix-timestamp": req.headers["svix-timestamp"] ?? "",
    "svix-signature": req.headers["svix-signature"] ?? "",
  });

  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ ok: true, type: verifiedPayload.type }));
}).listen(3000);
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from svix.webhooks import Webhook


@csrf_exempt
def stack_webhook(request):
    wh = Webhook(settings.STACK_WEBHOOK_SECRET)
    verified_payload = wh.verify(
        request.body.decode("utf-8"),
        {
            "svix-id": request.headers["svix-id"],
            "svix-timestamp": request.headers["svix-timestamp"],
            "svix-signature": request.headers["svix-signature"],
        },
    )

    return JsonResponse({"ok": True, "type": verified_payload["type"]})
from fastapi import FastAPI, Request
from svix.webhooks import Webhook

app = FastAPI()


@app.post("/api/webhooks/stack")
async def stack_webhook(request: Request):
    payload = await request.body()
    wh = Webhook(STACK_WEBHOOK_SECRET)
    verified_payload = wh.verify(
        payload.decode("utf-8"),
        {
            "svix-id": request.headers["svix-id"],
            "svix-timestamp": request.headers["svix-timestamp"],
            "svix-signature": request.headers["svix-signature"],
        },
    )

    return {"ok": True, "type": verified_payload["type"]}
from flask import Flask, jsonify, request
from svix.webhooks import Webhook

app = Flask(__name__)


@app.post("/api/webhooks/stack")
def stack_webhook():
    wh = Webhook(STACK_WEBHOOK_SECRET)
    verified_payload = wh.verify(
        request.get_data(as_text=True),
        {
            "svix-id": request.headers["svix-id"],
            "svix-timestamp": request.headers["svix-timestamp"],
            "svix-signature": request.headers["svix-signature"],
        },
    )

    return jsonify({"ok": True, "type": verified_payload["type"]})
If you do not want to install the Svix client library or are using a language that is not supported, you can verify the signature manually.

Event types

These are the type values you may receive. Each links to its API reference page.

Examples

Some members of the community have shared their webhook implementations. For example, here is an example by Clark Gredona that validates the Webhook schema and update a database user.