API guides
Webhooks
UpVoiced POSTs a signed JSON event to your endpoint when something happens in your workspace. Verify the signature, answer 2xx quickly, then process the event.
Create an endpoint
With an admin-scope API key (or a Dashboard session of an owner or admin). The response contains the signing secret (whsec_…) once: store it as UPVOICED_WEBHOOK_SECRET. The URL must be a public https:// address on the default port. The number of endpoints depends on the plan (none on Free).
curl -X POST https://api.upvoiced.com/v1/webhooks \
-H "Authorization: Bearer $UPVOICED_API_KEY" \
-H "X-Workspace: $UPVOICED_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/upvoiced", "events": ["post.created", "post.status_changed"]}'Events
Subscribe to a list of these types, or to ["*"] for every event, including future ones:
post.createdpost.updatedpost.status_changedpost.mergedpost.deletedcomment.createdvote.createdvote.deletedchangelog.publishedend_user.createdend_user.deleted
webhook.test is what POST /v1/webhooks/{id}/test sends. Events are sent only for approved posts on public boards, unless the endpoint’s board filter lists the private or unlisted boards it may receive. Payloads hold only what a public portal could show: people appear by opaque id, never by email address or name.
A delivery
POST /upvoiced HTTP/1.1
Content-Type: application/json
User-Agent: UpVoicedWebhooks/1.0 (+https://upvoiced.com/docs/webhooks)
X-UpVoiced-Event: post.status_changed
X-UpVoiced-Delivery: 0190f5e2-4c1d-7a3b-8c2d-5e6f7a8b9c0e
X-UpVoiced-Signature: t=1792663200,v1=5d2c…
{
"id": "evt_0190f5e2-4c1d-7a3b-8c2d-5e6f7a8b9c0f",
"type": "post.status_changed",
"occurred_at": "2026-10-22T12:00:00.000Z",
"workspace": { "id": "0190f5e2-4c1d-7a3b-8c2d-5e6f7a8b9c0d", "slug": "acme" },
"data": { … },
"actor": { "kind": "operator", "id": "…" }
}id is the same on every retry and redelivery of an event: use it to process each event once.
Verify the signature
X-UpVoiced-Signature is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256(secret, "<t>." + raw body) keyed with the whole whsec_… string. Compute it over the raw bytes you received, not over re-serialized JSON, compare in constant time and reject timestamps more than 300 seconds away. After you rotate the secret, the old one keeps signing for 24 hours and the header carries one v1 per valid secret; any match is enough.
Node.js (verify-webhook.mjs)
// UpVoiced webhooks: verify that a delivery really comes from UpVoiced. Server-side only.
// Node.js 18+, standard library only. Keep UPVOICED_WEBHOOK_SECRET out of browsers, logs and git.
//
// Use the RAW request body (the exact bytes received), not a re-serialized JSON object:
// app.post('/upvoiced', express.raw({ type: 'application/json' }), (req, res) => { ... })
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Returns true when `x-upvoiced-signature` (t=<unix seconds>,v1=<hex>[,v1=<hex>]) matches the body
* for `secret` and the timestamp is within `toleranceSeconds` of now. During a secret rotation the
* header carries one v1 per valid secret; any match is enough.
*/
export function verifyUpVoicedWebhook({
secret,
header,
body,
toleranceSeconds = 300,
nowSeconds = Math.floor(Date.now() / 1000),
}) {
if (typeof header !== 'string' || typeof secret !== 'string') return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const [key, value = ''] = part.trim().split('=', 2);
if (key === 't' && /^\d{1,12}$/.test(value)) timestamp = Number(value);
else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest();
return signatures.some((signature) => timingSafeEqual(Buffer.from(signature, 'hex'), expected));
}
// After verifying, deduplicate on the body's `id` (evt_…): retries and redeliveries reuse it.
Python (verify_webhook.py)
# UpVoiced webhooks: verify that a delivery really comes from UpVoiced. Server-side only.
# Python 3.8+, standard library only. Keep UPVOICED_WEBHOOK_SECRET out of browsers, logs and git.
#
# Use the RAW request body (the exact bytes received), e.g. Flask: request.get_data().
import hashlib
import hmac
import re
import time
def verify_upvoiced_webhook(secret, header, body, tolerance_seconds=300, now_seconds=None):
"""Return True when the x-upvoiced-signature header (t=<unix>,v1=<hex>[,v1=<hex>]) matches the
raw body for `secret` and the timestamp is within the tolerance. During a secret rotation the
header carries one v1 per valid secret; any match is enough."""
if not isinstance(header, str) or not isinstance(secret, str):
return False
if isinstance(body, str):
body = body.encode("utf-8")
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and re.fullmatch(r"\d{1,12}", value):
timestamp = int(value)
elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
signatures.append(value)
if timestamp is None or not signatures:
return False
now = int(time.time()) if now_seconds is None else int(now_seconds)
if abs(now - timestamp) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode("utf-8"), str(timestamp).encode("ascii") + b"." + body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(signature, expected) for signature in signatures)
# After verifying, deduplicate on the body's "id" (evt_...): retries and redeliveries reuse it.
Retries and the delivery log
- Any 2xx is a success. Redirects are not followed and count as failures.
- A failed attempt (non-2xx, timeout after 10 seconds, unreachable host) is retried after about 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then marked failed.
- After 20 failed attempts in a row the endpoint is disabled. Fix the receiver and re-enable it with
PATCH /v1/webhooks/{id}{"active": true}. - The delivery log keeps 14 days, with the last status code and the start of the response. Any logged delivery can be sent again (same body and
id, new signature).
Endpoints, deliveries and redelivery in the API reference: