feature.created, comment.created, feature.status_changed.
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.
https://)
Use the Send test button to fire a webhook.ping event at your endpoint any time.
| 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 |
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.
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" }
}
}
}
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"
}
}
}
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"
}
}
}
The Upvoted-Signature header looks like:
Upvoted-Signature: t=1713321600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
To verify:
t= (Unix timestamp) and v1= (hex signature) "{timestamp}.{raw_request_body}" HMAC-SHA256(signing_secret, signed_payload) and hex-encode it v1 using a constant-time comparison 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.
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")
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
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
Upvoted-Event-Id to deduplicate if your endpoint can be called multiple times through other means (e.g. our future retry support). webhook.ping on demand
Email us at support@releasy.xyz and include the Upvoted-Event-Id of a sample delivery — we can look up what we sent.