Custom Webhooks — plug Upvoted into anything.

- Receive signed HTTPS POST requests on your own endpoint.
- Events: feature.created, comment.created, feature.status_changed.
- Every payload is signed with HMAC-SHA256 using the same scheme Stripe uses.
- Verify, route, automate — in any language and any framework.

Custom Webhooks Guide

Custom webhooks let Upvoted push events to any HTTPS endpoint you control — your backend, a workflow tool, an automation platform, anything that can accept a JSON POST. Every delivery is signed with HMAC-SHA256 so your endpoint can verify that the request really came from Upvoted.

1) Create a webhook

  1. Sign in to Upvoted as a board admin
  2. Open your board → Settings → Webhooks
  3. Click New Webhook
  4. Paste your endpoint URL (it must start with https://)
  5. Pick which events should fire the webhook
  6. Save — Upvoted generates a signing secret you’ll see next to the row. Click “Reveal” to view it, and copy it into your server’s environment variables. Treat it like a password.

Use the Send test button to fire a webhook.ping event at your endpoint any time.

2) Events

Event type Fires when
feature.created A user submits a new feature request
comment.created A comment is added to a feature
feature.status_changed An admin updates a feature’s status
webhook.ping You click “Send test” in the admin UI

3) Request format

Every request is an HTTPS POST with a JSON body. Headers:

Content-Type: application/json
User-Agent: Upvoted-Webhooks/1.0
Upvoted-Signature: t=1713321600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Upvoted-Event-Id: evt_018f4a9b-2d3e-7c4a-8a6d-12345abc
Upvoted-Event-Type: feature.created
Upvoted-Timestamp: 1713321600

Upvoted-Event-Id is unique per delivery — use it as an idempotency key.

Example payload — feature.created

{
  "id": "evt_018f4a9b-2d3e-7c4a-8a6d-12345abc",
  "type": "feature.created",
  "created": 1713321600,
  "data": {
    "object": {
      "id": "018f4a9b-2d3e-7c4a-8a6d-fe1234abcdef",
      "title": "Dark mode for mobile app",
      "description": "Users have asked repeatedly for a dark theme...",
      "image": "https://cdn.upvoted.io/features/dark-mode-mockup.png",
      "status": { "id": "status_01", "name": "Open" },
      "author": { "name": "Alice", "email": "alice@example.com" },
      "triggered_by": "Alice",
      "board": { "id": "brd_01", "name": "Acme Roadmap", "slug": "acme" },
      "url": "https://upvoted.io/app/board/acme/features/.../detail",
      "created_at": "2026-04-17T10:30:00Z",
      "updated_at": "2026-04-17T10:30:00Z",
      "custom_fields": { "priority": "high" }
    }
  }
}

Example payload — comment.created

{
  "id": "evt_...",
  "type": "comment.created",
  "created": 1713321600,
  "data": {
    "object": {
      "id": "cmt_...",
      "message": "This would be huge for us",
      "author": { "name": "Bob", "email": "bob@example.com" },
      "triggered_by": "Bob",
      "feature": { "id": "ftr_...", "title": "Dark mode for mobile app" },
      "board": { "id": "brd_01", "name": "Acme Roadmap", "slug": "acme" },
      "url": "https://upvoted.io/app/board/acme/features/.../comments",
      "created_at": "2026-04-17T10:35:00Z",
      "updated_at": "2026-04-17T10:35:00Z"
    }
  }
}

Example payload — feature.status_changed

{
  "id": "evt_...",
  "type": "feature.status_changed",
  "created": 1713321600,
  "data": {
    "object": {
      "id": "ftr_...",
      "title": "Dark mode for mobile app",
      "image": "https://cdn.upvoted.io/features/dark-mode-mockup.png",
      "previous_status": { "id": "status_01", "name": "Open" },
      "status": { "id": "status_02", "name": "In Progress" },
      "triggered_by": "Admin User",
      "board": { "id": "brd_01", "name": "Acme Roadmap", "slug": "acme" },
      "url": "https://upvoted.io/app/board/acme/features/.../detail",
      "created_at": "2026-04-17T10:30:00Z",
      "updated_at": "2026-04-17T11:00:00Z"
    }
  }
}

4) Verifying signatures

The Upvoted-Signature header looks like:

Upvoted-Signature: t=1713321600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

To verify:

  1. Parse out the t= (Unix timestamp) and v1= (hex signature)
  2. Build the signed payload: "{timestamp}.{raw_request_body}"
  3. Compute HMAC-SHA256(signing_secret, signed_payload) and hex-encode it
  4. Compare byte-equality against v1 using a constant-time comparison
  5. Reject the request if the timestamp is more than 5 minutes from now (replay protection)

Node.js

const crypto = require("crypto");

function verifyUpvotedSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const timestamp = parseInt(parts.t, 10);
  const signature = parts.v1;

  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
    throw new Error("Timestamp outside tolerance");
  }

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

  if (
    signature.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    throw new Error("Invalid signature");
  }
}

Important: pass the raw request body (as a string or Buffer) — not the parsed JSON. Express users should use express.raw({ type: "application/json" }) for the webhook route.

Python

import hmac, hashlib, time

def verify_upvoted_signature(raw_body: bytes, header: str, secret: str, tolerance=300):
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts["t"])
    signature = parts["v1"]

    if abs(int(time.time()) - timestamp) > tolerance:
        raise ValueError("Timestamp outside tolerance")

    signed_payload = f"{timestamp}.{raw_body.decode()}".encode()
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(signature, expected):
        raise ValueError("Invalid signature")

Ruby

require "openssl"

def verify_upvoted_signature(raw_body, header, secret, tolerance: 300)
  parts = header.split(",").map { |p| p.split("=", 2) }.to_h
  timestamp = parts["t"].to_i
  signature = parts["v1"]

  raise "Timestamp outside tolerance" if (Time.now.to_i - timestamp).abs > tolerance

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
  raise "Invalid signature" unless Rack::Utils.secure_compare(signature, expected)
end

Elixir

def verify_upvoted_signature(raw_body, header, secret, tolerance \\ 300) do
  parts = header |> String.split(",") |> Map.new(&List.to_tuple(String.split(&1, "=", parts: 2)))
  timestamp = String.to_integer(parts["t"])
  signature = parts["v1"]

  if abs(System.system_time(:second) - timestamp) > tolerance do
    raise "Timestamp outside tolerance"
  end

  expected =
    :crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{raw_body}")
    |> Base.encode16(case: :lower)

  unless Plug.Crypto.secure_compare(signature, expected) do
    raise "Invalid signature"
  end
end

5) Delivery semantics

  • Deliveries are best-effort. We do not currently retry on failure — if your endpoint returns non-2xx, or times out, the event is dropped.
  • Your endpoint should respond within 10 seconds.
  • Any 2xx response is considered a successful delivery.
  • Use Upvoted-Event-Id to deduplicate if your endpoint can be called multiple times through other means (e.g. our future retry support).

6) Testing locally

  • Use webhook.site to inspect raw requests
  • Use ngrok or similar to expose your local dev server
  • Use the Send test button in the admin UI to fire a webhook.ping on demand

Need help?

Email us at support@releasy.xyz and include the Upvoted-Event-Id of a sample delivery — we can look up what we sent.