Gareth Wilson Gareth Wilson

Guide to BugSmash Webhooks: Features and Best Practices

Published


BugSmash webhooks tell your systems when reviewers talk: a new comment lands on a design or a web page, someone replies, a comment is edited, or its status changes. If your team collects visual feedback in BugSmash, webhooks are how that activity reaches your issue tracker, chat tools or internal dashboards as it happens.

This guide covers how BugSmash webhooks are created and signed, the events you can subscribe to, what BugSmash leaves undocumented, and the practices that keep a BugSmash receiver reliable.

What are BugSmash webhooks?

A BugSmash webhook subscribes a URL to one or more events in a workspace. There is no settings page for them: you create, list, update, delete and test webhooks through BugSmash's API with your API key.

Each delivery is signed. BugSmash computes an HMAC-SHA256 over the X-Webhook-Timestamp header, a dot and the raw JSON body, keyed with the webhook's secret_key, and sends the hex digest in X-BugSmash-Signature. The secret is returned once, in the response to the create call, and can't be fetched again.

BugSmash webhook features

FeatureDetails
ConfigurationAPI only: https://api.bugsmash.io/api/v2, authenticated with X-API-Key
ScopePer workspace. The list endpoint also shows a project_id for project-scoped webhooks
Eventsnew_comment, comment_replied, status_changed, comment_updated
Signature headerX-BugSmash-Signature, a bare hex digest
Timestamp headerX-Webhook-Timestamp, Unix seconds
SchemeHMAC-SHA256, hex
Signed content<X-Webhook-Timestamp>.<raw body>
Secretsecret_key, prefixed bgsmsh_, returned once at creation
TestingPOST /webhook/test fires a sample event
Body formatJSON (content-type: application/json)
Method, retries, timeout, source IPsNot documented

Common events

BugSmash allows four values in a webhook's events array. It doesn't describe them, so the descriptions below are read from the names:

EventFires when
new_commentA comment is added
comment_repliedSomeone replies to a comment
status_changedA comment's status changes
comment_updatedA comment is edited

BugSmash doesn't publish a delivery body, and its list of delivery headers has no event header, so the docs don't say where a delivery names its event. Fire a test event for each type you subscribe to, log the body, and build your router against what arrives.

Setting up BugSmash webhooks

Create the webhook with your API key and the events you want:

curl -X POST "https://api.bugsmash.io/api/v2/webhook" \
  -H "X-API-Key: $BUGSMASH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["new_comment", "comment_replied", "status_changed", "comment_updated"],
    "webhookUrl": "https://example.com/webhooks/bugsmash"
  }'

The response includes a secret_key such as bgsmsh_..., with a notice that it "is only shown once and cannot be retrieved again". Store the whole value straight away, for example as BUGSMASH_WEBHOOK_SECRET. If you lose it, the only fix is to delete the webhook and create a new one.

The other endpoints:

  • GET /webhooks lists the workspace's webhooks with their events, URL, is_active and last_triggered_at.
  • PATCH /webhook/{webhookId} changes the events or URL and keeps the existing secret.
  • DELETE /webhook/{webhookId} removes the webhook, and its secret stops being valid.
  • POST /webhook/test with {"event": "comment_updated"} sends a test event to every webhook in the workspace that subscribes to that event.

If you receive BugSmash webhooks through Hookdeck, create a source with the BugSmash type and use its URL as the webhookUrl. BugSmash returns the secret_key only once, when you create the webhook through the API, so paste the whole value into the source, including the bgsmsh_ prefix. BugSmash's docs don't name the HTTP method it uses, so Hookdeck's BugSmash source pre-selects POST without fixing it. If your first test delivery arrives with a different method, add that method to the source's allowed methods.

Securing BugSmash webhooks

Read X-Webhook-Timestamp and X-BugSmash-Signature. Build the signed bytes from the timestamp exactly as received, a literal ., and the raw request body, compute an HMAC-SHA256 of them with the whole secret_key as a UTF-8 key, and compare the hex digest with the header in constant time.

const crypto = require('crypto');
const express = require('express');

function verifyBugsmashSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  const signature = signatureHeader.trim();
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // check before Buffer.from(x, 'hex')

  // Optional replay window, off by default. Retries and replays resend the original timestamp.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > toleranceSeconds) return false;
  }

  // The key is the whole secret_key, bgsmsh_ prefix included.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestampHeader}.`, 'utf8'), rawBody]))
    .digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.all('/webhooks/bugsmash', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.BUGSMASH_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyBugsmashSignature(
    req.body,
    req.get('x-bugsmash-signature'),
    req.get('x-webhook-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // The body isn't documented: log it, then route once you know which field names the event.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import re
import time


def verify_bugsmash_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                              tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    signature = signature_header.strip()
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    # Optional replay window, off by default.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() - int(timestamp_header)) > tolerance_seconds:
            return False
    signed = timestamp_header.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))


# In FastAPI: raw_body = await request.body()
# verify_bugsmash_signature(raw_body, request.headers.get("x-bugsmash-signature"),
#                           request.headers.get("x-webhook-timestamp"),
#                           os.environ["BUGSMASH_WEBHOOK_SECRET"])

BugSmash's own Node sample builds the signed string from JSON.stringify(payload) of the parsed body, and calls crypto.timingSafeEqual without checking lengths. The same page warns that if verification "fails unexpectedly, sign against the raw request body rather than a re-serialized object". Follow the warning. Deliveries come from a PHP HTTP client (the user agent is GuzzleHttp/7), and re-serialising the parsed JSON in JavaScript won't always reproduce the bytes it sent.

BugSmash also suggests ignoring "any request older than five minutes". The samples above leave that off unless you pass a tolerance. Hookdeck applies no timestamp tolerance, and its retries and replays resend the original X-Webhook-Timestamp, so a five-minute window in your own code would reject a legitimate retry that arrives later.

Make BugSmash webhooks production-ready. Hookdeck Event Gateway verifies the X-BugSmash-Signature header over the timestamp and raw body, stores every comment event, and delivers it to your service with retries you control.

BugSmash webhook limitations and pain points

The secret is returned once

The Problem: A new receiver needs the secret, and nobody saved it.

Why It Happens: BugSmash returns secret_key only in the create response. There is no endpoint to read it again, and no rotation call: you delete the webhook and create a new one.

Workarounds:

  • Write the create call as a script that stores the secret in your secret manager in the same step.
  • Use PATCH to change events or the URL, because it keeps the existing secret.

How Hookdeck Can Help: With Hookdeck in front, the secret is pasted once into the BugSmash source, and moving your own service to a new URL is a change to the Hookdeck destination rather than to the BugSmash webhook.

Verification fails with BugSmash's own sample

The Problem: Signatures don't match even though the secret is right.

Why It Happens: The sample signs JSON.stringify of the parsed body rather than the raw bytes. A body framework that parses JSON before your handler, or any difference in escaping between PHP and JavaScript, changes the bytes.

Workarounds:

  • Read the raw body (express.raw, request.arrayBuffer(), await request.body()) and sign that.
  • Include the bgsmsh_ prefix in the key.

How Hookdeck Can Help: Hookdeck's BugSmash source verifies against the raw body it received. Hookdeck answers BugSmash with a 200 either way, then records a request that fails the check as failed verification, so it never reaches your destination, and you can inspect its headers and body.

No documented payload or method

The Problem: You can't write handlers from the docs, and you don't know which HTTP method to accept.

Why It Happens: BugSmash lists the delivery headers but shows no body and doesn't state the method.

Workarounds:

  • Use POST /webhook/test for each subscribed event and record the method and body that arrive.
  • Read the event name defensively and log anything you can't route.

How Hookdeck Can Help: Hookdeck stores every request with its method, headers and body, so the test deliveries are there to inspect before you write routing logic.

Delivery guarantees are unstated

The Problem: You don't know whether a failed delivery is retried.

Why It Happens: BugSmash documents no retries, timeout or success rule. GET /webhooks shows is_active and last_triggered_at, but not why a webhook might be disabled.

Workarounds:

  • Return 200 quickly and process asynchronously.
  • Check is_active on a schedule and alert if a webhook turns inactive.

How Hookdeck Can Help: Hookdeck accepts each delivery as it arrives and stores it, then delivers to your service with retries you configure.

Best practices

Verify against the raw body

The signature covers the exact bytes BugSmash sent. In Express, use express.raw({ type: '*/*' }). In Next.js, use Buffer.from(await request.arrayBuffer()). In FastAPI, use await request.body().

Use the test endpoint before going live

POST /webhook/test exercises the whole chain: delivery, signature and your handler. Run it once per event type, because it only reaches webhooks subscribed to that event.

Make handlers idempotent

BugSmash doesn't document an event ID, so build a key from what the body contains, such as the comment ID, the event and its timestamp. See our guide to webhook idempotency.

Acknowledge fast and process asynchronously

Verify, persist the event, return 200, and do the work afterwards. See why to process webhooks asynchronously.

Conclusion

BugSmash webhooks are created through the API, and the bgsmsh_ secret comes back once, so save it then. Verify X-BugSmash-Signature as a hex HMAC-SHA256 of X-Webhook-Timestamp, a dot and the raw body, not a re-serialised object, and use test events to learn the body and method before you write handlers.

Hookdeck Event Gateway sits in front of your receiver, verifies every BugSmash signature, and holds each comment event until your service has processed it.

Get started with Hookdeck for free and handle BugSmash webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.