GuidesWebhooks
Open app

Webhooks

Subscribe to events emitted by Uptend and receive them at your endpoint, signed and delivered with retries.

Webhook endpoints are HTTPS URLs you register against a workspace. When something happens in that workspace — an evaluation cycle finishes and lifecycle states, signals and scores are recomputed, an endpoint is auto-disabled, etc. — Uptend POSTs a JSON payload to every endpoint subscribed to that event type, signed per the Standard Webhooks spec.

For the full request/response shape of every endpoint mentioned below, see the API reference: webhook endpoints, webhook deliveries, and events.

Subscribing

Register an endpoint with POST /v1/webhooks/endpoints (or via Settings → Developer → Webhooks in the admin):

curl https://api.uptend.io/v1/webhooks/endpoints \
  -H "Authorization: Bearer uptend_sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/uptend",
    "subscribedEvents": ["lifecycle.evaluated"]
  }'

The response includes a secret field — the raw signing secret. Save it now. Uptend retains a hash only; you cannot retrieve the raw secret again.

subscribedEvents must match entries in the published event catalog returned by GET /v1/webhooks/endpoints/event-types. Subscribe to specific types (lifecycle.evaluated) or to a prefix (lifecycle.*).

Delivery format

Every request is a POST with Content-Type: application/json and a small set of webhook-* headers from the Standard Webhooks spec:

HeaderDescription
webhook-idUnique id of the delivery. Use to dedupe — retries reuse the same id.
webhook-timestampUnix epoch seconds when the signature was computed. Reject deliveries skewed more than ±5 minutes from your clock.
webhook-signatureSpace-separated list of v1,<base64-hmac> signatures. Multiple signatures appear during secret rotation.

Uptend webhook bodies are thin — they carry an identifier reference (subject) for the resource the event is about, never an embedded snapshot of resource fields:

{
  "id": "evt_01HJZQK7XJ8R9YGEWABCDEFGHJK",
  "type": "lifecycle.evaluated",
  "created": "2026-05-22T15:42:11.012Z",
  "subject": {
    "id": "wsp_01HJZQK7XJ8R9YGEWABCDEFGHJK",
    "type": "workspace",
    "url": "/v1/workspaces/wsp_01HJZQK7XJ8R9YGEWABCDEFGHJK"
  }
}

This shape is the same for every webhook event Uptend emits. To act on the event, follow subject.url and call the resource's retrieve endpoint — for lifecycle.evaluated that is GET /v1/workspaces/{id}.

Hydrating events

After verifying a webhook signature, fetch the affected resource's current state via the path in subject.url:

import { createUptendClient, getWorkspace } from "@uptend/sdk";

const client = createUptendClient({ apiKey: process.env.UPTEND_API_KEY! });

async function handle(event: WebhookEvent) {
  if (event.type === "lifecycle.evaluated" && event.subject.type === "workspace") {
    const { data } = await getWorkspace({ client, path: { workspaceId: event.subject.id } });
    // ...refresh whatever you derive from this workspace's lifecycle data
  }
}

Two things to keep in mind:

  • The resource you fetch reflects current state, not state at event-time. If several evaluation cycles complete between the event firing and your handler running, you'll see the latest version. This is usually what you want.
  • lifecycle.evaluated is a workspace-level signal. It tells you that the workspace's lifecycle states, signals and scores were recomputed; it does not enumerate which profiles changed. The subject is the workspace because that is the resource the evaluation ran over, so treat the event as a cue to refresh anything you derive from the workspace rather than as a per-profile change feed.

Retrieving events directly

The same envelope you receive on a webhook can also be retrieved through the API. This is useful for re-fetching, debugging, or building event-driven dashboards without setting up a public endpoint:

# Retrieve a single event by id
curl https://api.uptend.io/v1/events/evt_01HJZQK7XJ8R9YGEWABCDEFGHJK \
  -H "Authorization: Bearer uptend_sk_…"

# List recent events in the bound workspace, optionally filtered by type or subject
curl "https://api.uptend.io/v1/events?type=lifecycle.evaluated" \
  -H "Authorization: Bearer uptend_sk_…"

curl "https://api.uptend.io/v1/events?subjectType=workspace&subjectId=wsp_01HJZQK7XJ8R9YGEWABCDEFGHJK" \
  -H "Authorization: Bearer uptend_sk_…"

The response is byte-identical to the body that was POSTed to your webhook endpoint for the same event id — same id, type, created, subject. No additional fields are returned. See retrieve an event and list all events for the full parameter set.

Verifying signatures

The signature is HMAC-SHA256(secret, "<webhook-id>.<webhook-timestamp>.<body>"), base64-encoded, prefixed with the scheme version v1,. Verify it before trusting the payload:

import crypto from "node:crypto";

function verify(req: { headers: Headers; rawBody: string }, secret: string) {
  const id = req.headers.get("webhook-id");
  const timestamp = req.headers.get("webhook-timestamp");
  const signatures = req.headers.get("webhook-signature");
  if (!id || !timestamp || !signatures) throw new Error("Missing signature headers");

  const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (skewSeconds > 300) throw new Error("Timestamp skewed too far");

  const keyBytes = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
  const expected = crypto
    .createHmac("sha256", keyBytes)
    .update(`${id}.${timestamp}.${req.rawBody}`)
    .digest("base64");

  const ok = signatures
    .split(" ")
    .map((s) => s.split(",")[1])
    .some((sig) => sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
  if (!ok) throw new Error("Bad signature");
}

Verify against the raw request body bytes — re-serializing parsed JSON may change byte ordering and break the signature. Any Standard-Webhooks-compatible library (e.g. Svix's verify()) works as a drop-in alternative.

Retries

If your endpoint returns a non-2xx response (or fails to respond within 10 seconds), Uptend retries on a Stripe-style schedule across 11 total attempts over ~3 days: 5 min, 30 min, 2 hr, 5 hr, 10 hr, then five 12-hour backoffs. The delivery row tracks every attempt; once the schedule is exhausted, the delivery is marked failed.

You can pause an endpoint at any time via PATCH /v1/webhooks/endpoints/{endpointId} with { "active": false } — this stops new deliveries without losing the endpoint or its history.

Auto-disable

To stop firing at a permanently broken receiver, Uptend auto-disables endpoints after 5 consecutive failed deliveries (each delivery already represents ~11 HTTP attempts over ~3 days, so the trigger means the endpoint has been broken for roughly two weeks of cumulative event-days). Disabled endpoints stay in your list and continue to receive new events into their delivery history as disabled rows, but no HTTP requests are made.

When an endpoint auto-disables, Uptend emits a webhook.endpoint.auto_disabled event and notifies every user in the workspace with workspace:developer:read. Re-enable the endpoint from Settings → Developer → Webhooks in the admin once the underlying issue is fixed.

Replaying a delivery

Every attempt is logged. Inspect a single delivery (request and response bodies, headers, timing) via GET /v1/webhooks/deliveries/{deliveryId}. To resend the original payload, call POST /v1/webhooks/deliveries/{deliveryId}/replay — the endpoint must still be active. Replays write a fresh delivery row; the original is left untouched.

Testing your endpoint

Send a synthetic webhook.ping event without waiting for a real one:

curl -X POST https://api.uptend.io/v1/webhooks/endpoints/{endpointId}/send-test-event \
  -H "Authorization: Bearer uptend_sk_…"

The response includes the deliveryId so you can tail the outcome via list webhook deliveries. The endpoint must be active.

webhook.ping carries the envelope's id, type, and created fields only — there is no subject because pings are synthetic and don't correspond to a real resource event. Real events always carry subject.

Rotating secrets

POST /v1/webhooks/endpoints/{endpointId}/rotate-secret issues a fresh signing secret. The previous secret remains valid for 24 hours so receivers can roll over without missing deliveries — during the overlap window, Uptend signs each delivery with both secrets and the webhook-signature header carries both values.

On this page