Udviklerdokumentationen er indtil videre kun på engelsk. Alt andet på hjemmesiden er på dansk.

Webhooks

Add an endpoint, pick the events you want, and we POST signed JSON to it when they happen, so your code does not have to poll.

Manage endpoints under Developers in the portal, or from code with a key or OAuth grant that has the webhook scopes (see Managing endpoints from code). Every request is signed twice: with Standard Webhooks headers, which the Standard Webhooks libraries verify for you, and with our original x-50heads-signature header. Check either one.

Events

EventSent when
question.reservedA question went live and its credits were reserved. Also sent when heads are added to a live question or a scheduled follow-up goes live.
question.completedA question got every answer it asked for. Carries the summary, and the verdict for yes/no asks with a go rule.
question.underfilledA question closed with fewer answers than asked for. The unanswered part is refunded.
question.canceledA live question was stopped. The unanswered part is refunded.
question.refusedReview refused a question. Carries the category and the reason; the credits are refunded.
credits.purchasedCredits were bought. Carries the new balance and the invoice number.
credits.lowThe balance fell below your low-credit threshold.
credits.expiringCredits are about to expire.
dispute.resolvedA dispute about a question was decided, upheld or not.
connection.cap_warningA key or connection is close to its spend cap for the period.
member.provisionedYour organization's identity provider added someone, or turned them back on, through SCIM. Carries who, whether an existing account was linked, and their teams and roles. Sent to the organization's main team.
member.deprovisionedYour organization's identity provider turned someone off or deleted them. Their sessions, keys and connections ended; their questions and credits stay with the team. Sent to the organization's main team.
policy.changedAn organization admin changed a security policy: two-step sign-in, a session limit or the re-authentication window. Carries what changed, before and after. Sent to the organization's main team.
approval.requestedA spend approval policy held an ask. Carries the approval id, the amount held, why it waited and when it expires. Nothing has gone to heads yet.
approval.decidedAn approver approved or denied a held ask, or the asker withdrew it. Carries the decision, the approver's role and the typed reason. Approved: the question is live. Denied: the held credits are released.
approval.expiredNobody decided in time, so the ask was denied and the held credits went back to the balance.
approval.refusedA decision was refused: the approver's own key made the ask (`separation_of_duties`), or they are not an approver. For audit and alerting.
workspace.createdAn organization admin made a new workspace. Carries its id, name and owner. Sent to the organization's main workspace.
workspace.movedA team moved into another organization when its owner accepted the request. Sent to the receiving organization's main workspace.
workspace.closedA workspace's owner closed it; it is read-only from then on. Carries the unused credits refunded. Sent to the organization's main workspace.
webhook.testYou sent a test. Never subscribed to; sent only to the endpoint you test.

Payload and versions

The body is the same on every attempt and replay of one event. id is the event id; data depends on type. Question events carry teamId (null for a personal account), projectId and labels, so you can route them.

{
  "id": "evt_3kd9",
  "type": "question.completed",
  "createdAt": "2026-10-10T10:12:00Z",
  "apiVersion": "2026-10-10",
  "data": {
    "questionId": "q_8k2f",
    "status": "complete",
    "summary": {
      "winner": "Menu B",
      "margin": 36,
      "confidence": "high",
      "note": "Menu B led by 36 points.",
      "suggestedFollowUp": null
    },
    "creditsSpent": 600,
    "refundCredits": 0,
    "teamId": "team_4hq1",
    "projectId": "prj_launch",
    "labels": [
      "pricing"
    ]
  }
}

Each endpoint is pinned to a payload version, apiVersion, set to the latest when you create it (today 2026-10-10). Inside a version we only add optional fields; existing fields never change meaning or disappear. Ignore fields you do not know.

The JSON Schema for every event's data, per version, is public at https://api.50heads.com/v1/webhooks/schemas. Generate types from it, or validate payloads in tests.

Verifying signatures

Verify against the raw request body, before you parse it: re-serialized JSON will not match. Reject timestamps more than five minutes from your clock. Every delivery also carries x-50heads-event, x-50heads-event-id, x-50heads-delivery and x-50heads-attempt, which are not signed; use them for logging, not for trust.

HeaderValue
webhook-idThe event id. The same on retries and replays, so use it to drop repeats.
webhook-timestampUnix seconds when this attempt was signed.
webhook-signaturev1,<base64>: the HMAC-SHA256 of <id>.<timestamp>.<body>, keyed with the base64-decoded part of your secret after whsec_. During a rotation there are two entries, separated by a space.
x-50heads-signaturet=<unix seconds>,v1=<hex>: the HMAC-SHA256 of <t>.<body>, keyed with the whole secret string. During a rotation there are two v1 values.

Signing secrets start whsec_. Secrets made since October 2026 are standard base64 after the prefix, so Standard Webhooks libraries take them as they are. Older secrets are URL-safe base64: the snippets below accept both, but if you use a Standard Webhooks library with an older secret, rotate it once.

Standard Webhooks headers

import { createHmac, timingSafeEqual } from "node:crypto";

// Standard Webhooks: webhook-signature is "v1,<base64>" entries, space-separated,
// each a base64 HMAC-SHA256 of "<webhook-id>.<webhook-timestamp>.<raw body>".
// The key is the base64 after "whsec_", decoded.
export function verifyWebhook(rawBody: string, headers: Headers, secret: string, toleranceSec = 300): boolean {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signatures = headers.get("webhook-signature");
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const given = Buffer.from(signature, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
import base64, hashlib, hmac, time

def verify_webhook(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp") or ""
    signatures = headers.get("webhook-signature")
    if not (msg_id and timestamp.isdigit() and signatures):
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    # Accepts standard and URL-safe base64, so secrets made before October 2026 work too.
    b64 = secret.removeprefix("whsec_").replace("-", "+").replace("_", "/")
    key = base64.b64decode(b64 + "=" * (-len(b64) % 4))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        version == "v1" and hmac.compare_digest(signature, expected)
        for version, _, signature in (entry.partition(",") for entry in signatures.split(" "))
    )
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// Verify checks the Standard Webhooks headers against the raw body.
func Verify(body []byte, h http.Header, secret string) bool {
	id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
	t, err := strconv.ParseInt(ts, 10, 64)
	if id == "" || sigs == "" || err != nil {
		return false
	}
	if d := time.Now().Unix() - t; d > 300 || d < -300 {
		return false
	}
	// Accepts standard and URL-safe base64, so secrets made before October 2026 work too.
	b64 := strings.NewReplacer("-", "+", "_", "/").Replace(strings.TrimPrefix(secret, "whsec_"))
	key, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(b64, "="))
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	for _, entry := range strings.Split(sigs, " ") {
		version, sig, ok := strings.Cut(entry, ",")
		if !ok || version != "v1" {
			continue
		}
		if given, err := base64.StdEncoding.DecodeString(sig); err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}

The x-50heads-signature header

Receivers written before October 2026 keep working: this header is sent on every delivery.

import { createHmac, timingSafeEqual } from "node:crypto";

// x-50heads-signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
// Each v1 is a hex HMAC-SHA256 of "<t>.<raw body>", keyed with the whole secret string.
export function verifyLegacy(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
  const parts = header.split(",").map((p) => p.trim().split("=") as [string, string | undefined]);
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return parts
    .filter(([k]) => k === "v1")
    .some(([, v]) => {
      const given = Buffer.from(v ?? "", "hex");
      return given.length === expected.length && timingSafeEqual(given, expected);
    });
}
import hashlib, hmac, time

# x-50heads-signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
def verify_legacy(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    pairs = [p.strip().partition("=") for p in header.split(",")]
    t = next((v for k, _, v in pairs if k == "t"), "")
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(k == "v1" and hmac.compare_digest(v, expected) for k, _, v in pairs)
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

// VerifyLegacy checks x-50heads-signature: t=<unix seconds>,v1=<hex>[,v1=<hex>].
func VerifyLegacy(body []byte, header, secret string) bool {
	var t string
	var sigs []string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "t":
			t = v
		case "v1":
			sigs = append(sigs, v)
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return false
	}
	if d := time.Now().Unix() - ts; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	for _, s := range sigs {
		if given, err := hex.DecodeString(s); err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}

Rotating a secret without dropping deliveries

Rotating makes a new secret and shows it once. The old secret keeps signing beside it for an overlap you choose: 24 hours unless you say otherwise, up to 168 hours (7 days). During the overlap every delivery carries a signature from each secret, so a receiver holding either one verifies.

  1. Rotate, and store the new secret beside the old one.
  2. Deploy your receiver so it accepts either secret.
  3. Once it is live everywhere, remove the old secret. It stops signing at previousSecret.expiresAt.

An overlap of 0 cuts over at once, for a secret you think has leaked. Expect failed verifications until your receiver has the new one; replay those deliveries afterwards.

curl -X POST https://api.50heads.com/v1/webhooks/endpoints/whe_123/rotate-secret \
  -H "Authorization: Bearer $FIFTYHEADS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"overlapHours": 48}'

Team endpoints

An endpoint belongs to the account it was made in. Made in a team (in the portal with the team selected, or with one of the team's keys), it receives events for every member's asks in that team and for the team's credits. The member who asked also gets the event on their own personal endpoints.

  • The team's owner and admins can add, change, rotate, test, replay and delete team endpoints.
  • Members can list them and read their deliveries. The viewer and billing roles cannot.
  • Up to 100 endpoints on a team and 10 on a personal account.

Filters

To receive question events only for some work, give an endpoint a filter with up to 20 projectIds and up to 20 labels. A question event is sent when its project is one you listed and it carries at least one of the labels; leave either list out to skip that check. Events that are not about a question, such as credits.low, are always sent. Send "filter": null to clear it.

{
  "filter": {
    "projectIds": [
      "prj_launch"
    ],
    "labels": [
      "pricing",
      "naming"
    ]
  }
}

Managing endpoints from code

Every action in the portal is in the API under /v1/webhooks; the API reference lists the routes and fields. Keys and OAuth grants need the webhook scopes, which are never granted by default: they let a caller send your events to an address it chooses, so ask for them by name.

ScopeAllows
webhooks:readList endpoints (secrets masked), deliveries and attempts.
webhooks:writeCreate, change, pause, rotate, test, replay and delete endpoints.

Keys made before scopes existed cannot manage webhooks: make a new key that includes them. A missing scope returns 403 insufficient_scope with the scope it needs.

curl -X POST https://api.50heads.com/v1/webhooks/endpoints \
  -H "Authorization: Bearer $FIFTYHEADS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/50heads", "events": ["question.completed"]}'

The response has the signing secret in endpoint.secret. It is shown once; after that it is masked.

Sending a test

POST /v1/webhooks/endpoints/{endpointId}/test sends a webhook.test event to that endpoint now, signed like any other delivery, and returns the outcome and the delivery. Use it after creating an endpoint or rotating its secret.

Retries

Answer with a 2xx within 10 seconds, then do the work. Anything else, including a redirect, counts as a failure. We try each delivery up to eight times over about 24 hours: after about 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours, with a little jitter. Retries are sent in five-minute rounds, so the first can take up to five minutes.

Deliveries arrive at least once and in no fixed order. Use the event id (webhook-id) to drop repeats, and createdAt to ignore an older event that arrives late. If five deliveries in a row fail every retry, we switch the endpoint off and email you; turn it back on in the portal or with PATCH {"enabled": true}.

Delivery log and replay

GET /v1/webhooks/deliveries lists the latest 100 deliveries, filtered by endpointId and state (pending, delivering, succeeded or failed). Each delivery's attempts show the status code, the error and the time taken. Logs are kept for 90 days.

Replay sends a delivery's event again as a new delivery: the same body and event id, signed now with the endpoint's current secrets. A receiver that keeps event ids will treat it as a repeat, so clear the id first if you want it processed again.

Recent events

GET /v1/webhooks/events?type=question.completed&limit=3 returns the latest events of one type in the account, newest first, each shaped exactly like a delivery body. limit is 1 to 25, 3 by default, and the route needs webhooks:read. Use it for sample payloads, or to catch up after an outage. The Zapier app uses it for both.

Keyboard shortcuts

Press ? on any page to see these. They are off while you type in a box.

General

Show keyboard shortcuts
?
Search help
/
Ask a question
n
Close menus
Esc

Go to

Home
gthenh
Pricing
gthenp
How it works
gthenw
How we verify
gthenv
For AI agents
gthena
For teams
gthent
Earn
gthene
Blog
gthenb
API docs
gthend
Your account
gtheno