Skip to content
Swingback, home
Docs / Webhooks

Webhooks

Get a signed request on your own server whenever something happens in your workspace: a new post, a status change, a comment, a vote, a changelog release.

Add an endpoint#

Owners and admins manage endpoints under Settings → Webhooks.

  1. Click Add an endpoint.
  2. Enter the URL, an https address that answers with a 2xx, and an optional Description.
  3. Under Events, keep Every event, those added later too, or choose Only the events I choose and check the ones you want.
  4. Click Add endpoint, then copy the signing secret (whsec_…). It's shown only once: keep it with your receiver's secrets, for example in SWINGBACK_WEBHOOK_SECRET.

An endpoint gets what happens after you add it, never earlier events. To try your receiver, open the endpoint and click Send a test: it sends one webhook.test message. You can change the URL, the description and the events on the same page, and Remove deletes the endpoint with its delivery log.

Public addresses only#

The URL must use https, hold no user name or password, and use port 443, 80, or 1024 and above. Each time it connects, Swingback resolves the name itself and refuses private, loopback, link-local and other non-public addresses. A receiver on localhost or on your internal network can't get messages: give it a public HTTPS address.

The message#

Each message is a POST with a JSON body and three headers, as the Standard Webhooks specification describes:

POST /swingback HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: swingback-webhooks/1
webhook-id: evt_01h455vb4pex5vsknk084sn02q
webhook-timestamp: 1790604193
webhook-signature: v1,<base64 HMAC-SHA256>
  • webhook-id is the event's id. It stays the same on every retry and replay, so use it to skip duplicates.
  • webhook-timestamp is when this attempt was sent, in Unix seconds.
  • webhook-signature is v1, and the signature. For a day after a rotation, it holds two, separated by a space.

The body of a post.status_changed message:

{
  "type": "post.status_changed",
  "timestamp": "2026-09-28T14:03:12.482Z",
  "workspace_id": "ws_01h455vb4pex5vsknk084sn02q",
  "data": {
    "from": "planned",
    "post": {
      "id": "post_01h455vb4pex5vsknk084sn02q",
      "number": 42,
      "title": "Export reports to CSV",
      "slug": "export-reports-to-csv",
      "status": "in_progress",
      "board": { "id": "board_01h455vb4pex5vsknk084sn02q", "name": "Feature requests", "visibility": "public" },
      "visibility": "public",
      "moderation_state": "approved",
      "source": "portal",
      "author_id": "user_01h455vb4pex5vsknk084sn02q",
      "vote_count": 17,
      "comment_count": 4,
      "merged_into_id": null,
      "url": "https://feedback.example.com/posts/export-reports-to-csv",
      "created_at": "2026-09-02T09:15:00.000Z",
      "updated_at": "2026-09-28T14:03:12.482Z",
      "deleted_at": null
    },
    "status_change_id": "change_01h455vb4pex5vsknk084sn02q",
    "to": "in_progress"
  }
}

timestamp is when the event happened. data holds the event's own fields and a snapshot of what it's about: post, comment, vote with its post, or changelog_entry. The snapshot is taken when the message is queued, a moment after the event, and a replay sends it unchanged. It's null if the subject was deleted for good in between.

  • No names or emails. People appear by id, such as author_id or end_user_id. Look them up with the REST API.
  • Small snapshots. A post's snapshot leaves out its text; a comment's holds its Markdown (body_md).
  • Private and held content is included. Posts on private boards, and posts and comments held for review or rejected, are sent too. Check visibility, board.visibility and moderation_state before you show anything publicly.
  • Internal notes never. Only public comments are sent.

Event types#

The 22 event types, and the fields each adds to data next to its snapshot:

TypeWhenAlso in data
post.createdA post was created.board_id, author_id, number
post.updatedIts title, text, board or visibility changed.changed: the fields
post.status_changedIts status changed.from, to, status_change_id
post.tags_changedIts tags changed.added, removed
post.mergedIt was merged into another post.into_post_id
post.unmergedA merged post was split off again.from_post_id
post.deletedIt went to the trash.
post.restoredIt came back from the trash.
post.approvedA held or rejected post was approved.from: its previous state
post.rejectedA post was rejected.from
post.heldA post was held for review.reasons
comment.createdA public comment was written.post_id, author_id, moderation_state; parent_id for a reply
comment.updatedA public comment was edited.changed
comment.deletedA public comment went to the trash.
comment.restoredA public comment came back from the trash.
comment.approvedA held or rejected comment was approved.from
comment.rejectedA comment was rejected.from
comment.heldA comment was held for review.reasons
vote.createdA customer voted for a post.post_id, end_user_id
vote.deletedA customer took their vote back.post_id, end_user_id
changelog_entry.publishedA changelog entry was published.whisper, first_publication
changelog_entry.unpublishedA published entry went back to draft.

A test sent from the dashboard has the type webhook.test and a data.message.

Verify the signature#

The signature is the HMAC-SHA256 of webhook-id, webhook-timestamp and the body, joined with dots, keyed with your secret's bytes (the base64 after whsec_), and encoded in base64. Check it on the raw body, before you parse the JSON, and refuse a timestamp more than 5 minutes away from your clock. Any Standard Webhooks library does this. Here is the same check with Node.js's own node:crypto:

// verify.mjs: checks a Swingback webhook with Node.js's own crypto.
import { createHmac, timingSafeEqual } from "node:crypto";

const tolerance = 5 * 60; // seconds

/**
 * Whether a message comes from Swingback: its signature matches, and it was sent within 5 minutes.
 * `headers` are the request's (with lowercase names), `body` its raw bytes, before any JSON parsing.
 */
export function verifyWebhook(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  const sent = Number(timestamp);
  if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > tolerance) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest();

  // After a rotation, the header holds two signatures separated by a space: one match is enough.
  return signatures.split(" ").some((signature) => {
    const [version, value] = signature.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

A receiver on Node.js's own HTTP server that uses it:

// server.mjs: SWINGBACK_WEBHOOK_SECRET=whsec_... node server.mjs
import { createServer } from "node:http";

import { verifyWebhook } from "./verify.mjs";

const handled = new Set(); // In production, keep the ids in your database.

createServer(async (request, response) => {
  const chunks = [];
  for await (const chunk of request) chunks.push(chunk);
  const body = Buffer.concat(chunks);

  if (!verifyWebhook(process.env.SWINGBACK_WEBHOOK_SECRET, request.headers, body)) {
    response.writeHead(401).end();
    return;
  }
  // A message can arrive more than once, with the same webhook-id: handle it once.
  const id = request.headers["webhook-id"];
  if (!handled.has(id)) {
    handled.add(id);
    const message = JSON.parse(body.toString("utf8"));
    console.log(message.type, message.data);
  }
  response.writeHead(204).end();
}).listen(3000);

Answers and retries#

Answer with any 2xx within 15 seconds, and do slow work afterwards. Anything else is a failure: another status, no answer in time, a connection or TLS error, or a redirect, which is never followed.

The first attempt is made at once. After a failure, the next attempt comes 5 seconds later, then 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours and 24 hours after each failure: 10 attempts over about three days, each wait varying by up to 10%. A Retry-After header, in seconds, can push the next attempt later, never sooner.

  • At least once. A message can arrive twice, with the same webhook-id.
  • No set order. Each message travels on its own, so a retried one can arrive after newer ones. Compare timestamp, or read the current state from the API.
  • 410 stops everything. Answering 410 Gone disables the endpoint at once.
  • Swingback reads at most 64 KB of your answer, and never stores it.

The delivery log and replays#

Open an endpoint under Settings → Webhooks to see its deliveries of the last 30 days, newest first: the event, its state (waiting, retrying, delivered, failed or canceled), the attempts, your endpoint's last answer or why there was none, the next attempt, and the message with its webhook-id.

Replay sends a delivery's message again as a new delivery: the same webhook-id and body, signed anew. The endpoint must be enabled.

Rotate the secret#

On the endpoint's page, click Rotate the secret and copy the new secret: it's shown once. For the next 24 hours, each message carries two signatures, one per secret, so you can move your receiver to the new secret without missing a message. Then only the new secret signs. Rotating again within those 24 hours drops the oldest secret at once.

Disabled endpoints#

An endpoint stops getting messages when:

  • your team clicks Disable;
  • a message failed every attempt, and nothing reached the endpoint since that message was queued;
  • it answered 410 Gone.

The endpoint's page says which. Messages still waiting are canceled, and events that happen while it's disabled are never queued for it. Send a test works on a disabled endpoint and disables nothing: check your receiver, then click Enable. To catch up, replay deliveries from the log, or read what changed from the REST API.