Skip to content
UpVoiced Docs

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:

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

Endpoints, deliveries and redelivery in the API reference: