Developers

Webhooks

Subscribe to Performance Blocks events, verify signatures with HMAC-SHA256, handle retries, and inspect delivery logs.

Plan availability: The Performance Blocks API is available on the Agentic plan. Team plan customers can upgrade in Settings → Billing.

Webhooks let you react to events in Performance Blocks the moment they happen — a new observation, a shared summary, a status change on an objective — without polling. You register an HTTPS endpoint, subscribe to specific event types, and Performance Blocks POSTs a signed JSON payload to your endpoint each time a matching event occurs.

This article covers subscription management, the delivery payload, signature verification, the retry policy, delivery logs, the test endpoint, the full event catalog, and idempotency on the receiver side.

Subscription resource

{
  "id": "whk_01HXX9YK7Z8N2P3Q4R5S6T7U8V",
  "url": "https://hooks.example.com/perfblocks",
  "event_types": ["observation.created", "summary.shared"],
  "secret": "whsec_4f2a9b1c8d3e7f64...",
  "active": true,
  "description": "Production: pipe events to internal queue",
  "last_delivery_at": "2026-05-07T14:00:00.000Z",
  "last_delivery_status": "success",
  "created_at": "2026-04-01T09:00:00.000Z",
  "updated_at": "2026-04-12T15:42:18.000Z"
}
Field Type Notes
id string ULID prefixed with whk_. Read-only.
url string Receiver URL. Must be HTTPS. Public DNS only — private IPs are rejected.
event_types string[] One or more event types from the catalog below. Use ["*"] to subscribe to everything (not recommended for production).
secret string Per-subscription HMAC secret with prefix whsec_. Returned only on create — store it immediately.
active boolean Set to false to pause delivery without deleting the subscription.
description string | null 0–200 characters. For your own bookkeeping.
last_delivery_at string (datetime) | null Read-only.
last_delivery_status enum | null success, failed, dropped. Read-only.
created_at string (datetime) Read-only.
updated_at string (datetime) Read-only.

Create a subscription

POST /webhooks

Required scope: webhooks:write.

{
  "url": "https://hooks.example.com/perfblocks",
  "event_types": ["observation.created", "observation.updated", "summary.shared"],
  "description": "Production webhook receiver"
}

The response includes the full secret. Store it immediately — it cannot be retrieved later. To rotate the secret, see "Rotating the secret" below.

curl -X POST https://api.performanceblocks.com/v1/webhooks \
  -H "Authorization: Bearer $PB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/perfblocks",
    "event_types": ["observation.created", "summary.shared"]
  }'

List, get, update, delete subscriptions

GET /webhooks
GET /webhooks/{id}
PATCH /webhooks/{id}
DELETE /webhooks/{id}

PATCH supports url, event_types, active, and description. To rotate the secret, send PATCH /webhooks/{id} with { "rotate_secret": true } — the response returns the new secret. The previous secret stays valid for 24 hours after rotation to support zero-downtime cutover.

# Pause a subscription
curl -X PATCH https://api.performanceblocks.com/v1/webhooks/whk_01HXX... \
  -H "Authorization: Bearer $PB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"active": false}'

# Rotate the secret
curl -X PATCH https://api.performanceblocks.com/v1/webhooks/whk_01HXX... \
  -H "Authorization: Bearer $PB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rotate_secret": true}'

DELETE is permanent. The subscription stops immediately and cannot be recovered.

Delivery payload

Every delivery is an HTTP POST to your URL with Content-Type: application/json. The body shape is identical for all events:

{
  "id": "evt_01HXX9YK7Z8N2P3Q4R5S6T7U8V",
  "event": "observation.created",
  "created_at": "2026-05-07T14:00:00.000Z",
  "api_version": "v1",
  "organization_id": "org_01HXX...",
  "data": {
    "id": "obs_01HXX...",
    "employee_id": "emp_01HXX...",
    "type": "strength",
    "observation": "Led the Q2 launch retro...",
    "observation_date": "2026-05-07",
    "created_at": "2026-05-07T14:00:00.000Z",
    "updated_at": "2026-05-07T14:00:00.000Z",
    "archived_at": null
  }
}
Field Notes
id Unique event ID. Use this for idempotency — the same event ID will never fire twice for distinct events, but the same delivery may retry.
event The event type name (see catalog).
created_at When the event occurred (not when this delivery attempt was made).
api_version The API version this payload conforms to.
organization_id Your organization's ID. Use this if your endpoint receives webhooks from multiple Performance Blocks orgs.
data The full resource at the time of the event. For events that mutate state, this is the post-change state.

Required headers

Each delivery includes:

Header Notes
Content-Type application/json.
User-Agent PerformanceBlocks-Webhooks/v1.
X-PerfBlocks-Event Event type, e.g. observation.created.
X-PerfBlocks-Event-Id The id from the body.
X-PerfBlocks-Delivery-Id Per-attempt delivery ID. Different attempts share the same Event-Id but get a fresh Delivery-Id.
X-PerfBlocks-Timestamp Unix epoch seconds when the request was signed.
X-PerfBlocks-Signature HMAC-SHA256 signature. See below.

Signature verification

Every delivery is signed using HMAC-SHA256 with your subscription's secret. Verify every request before trusting its contents.

The signature header is:

X-PerfBlocks-Signature: t=1714992000,v1=5257a869e7ec...

Where:

  • t is the timestamp (Unix seconds), matching X-PerfBlocks-Timestamp.
  • v1 is the hex-encoded HMAC-SHA256 of the string t.<raw_body>, computed with your subscription's secret as the key.

Multiple vN signatures may be present during secret rotation (v1=...,v1=... — the older secret's signature first, then the new one). Accept the request if any signature verifies.

Verification steps

  1. Read the X-PerfBlocks-Signature header. Parse t and one or more v1 values.
  2. Reject if t is more than 5 minutes from your server's current time (replay protection).
  3. Compute expected = hex(HMAC_SHA256(secret, t + "." + raw_body)).
  4. Compare each v1 to expected using a constant-time comparison.
  5. If at least one matches, the request is authentic.

TypeScript example

import crypto from 'node:crypto';

const SIGNATURE_TOLERANCE_SECONDS = 5 * 60;

export function verifyWebhook(
  rawBody: string,
  signatureHeader: string | null,
  timestampHeader: string | null,
  secret: string
): boolean {
  if (!signatureHeader || !timestampHeader) return false;

  const timestamp = Number(timestampHeader);
  if (!Number.isFinite(timestamp)) return false;
  const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (ageSeconds > SIGNATURE_TOLERANCE_SECONDS) return false;

  const signedPayload = ` 

Use the raw body bytes for verification, not a re-stringified JSON object. Any whitespace or key-order change breaks the signature.

In a SvelteKit +server.ts handler:

export async function POST({ request }) {
  const rawBody = await request.text();
  const ok = verifyWebhook(
    rawBody,
    request.headers.get('x-perfblocks-signature'),
    request.headers.get('x-perfblocks-timestamp'),
    process.env.PB_WEBHOOK_SECRET!
  );
  if (!ok) return new Response('invalid signature', { status: 400 });

  const event = JSON.parse(rawBody);
  await handleEvent(event);
  return new Response(null, { status: 204 });
}

Responding to a delivery

Return any 2xx status to acknowledge receipt. The body of your response is ignored.

  • Respond within 15 seconds. Slower responses are treated as a timeout failure and the delivery is retried.
  • Acknowledge before doing slow work. Push the event onto an internal queue and process asynchronously. Long synchronous handlers cause retry storms during burst traffic.
  • Any non-2xx response is treated as failure and triggers a retry.

Retry policy

Failed deliveries are retried with exponential backoff:

Attempt Delay after previous attempt
1 (initial)
2 30 seconds
3 2 minutes
4 10 minutes
5 1 hour
6 6 hours
7 24 hours

After 7 failed attempts spanning roughly 31 hours, the delivery is dropped and last_delivery_status becomes dropped. Subsequent events continue to be attempted unless the subscription is automatically disabled (see below).

If 50 consecutive deliveries fail across at least 24 hours, the subscription is automatically set to active: false. The owner is notified via the application. This protects your endpoint and our delivery infrastructure from runaway failures. Re-enable with a PATCH once you've fixed the receiver.

Delivery logs

Every delivery attempt is recorded for 30 days.

GET /webhooks/{id}/deliveries

Query parameters

Parameter Type Notes
limit integer Default 50, max 100.
cursor string Pagination.
filter[status] enum success, failed, dropped.
filter[event_type] string Filter by event.
filter[event_id] string All attempts for one event.

Response shape

{
  "data": [
    {
      "id": "del_01HXX...",
      "subscription_id": "whk_01HXX...",
      "event_id": "evt_01HXX...",
      "event_type": "observation.created",
      "attempt": 2,
      "status": "success",
      "request_url": "https://hooks.example.com/perfblocks",
      "response_status": 204,
      "response_duration_ms": 142,
      "next_attempt_at": null,
      "delivered_at": "2026-05-07T14:00:32.000Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

For failed deliveries, response_status may be 0 if the request never connected (DNS, TLS, timeout). The response body is not stored.

Test endpoint

POST /webhooks/{id}/test

Sends a synthetic delivery to your subscription's URL with a recognizable event type. Useful for validating signature verification end-to-end after creating a subscription or rotating a secret.

curl -X POST https://api.performanceblocks.com/v1/webhooks/whk_01HXX.../test \
  -H "Authorization: Bearer $PB_API_KEY"

The synthetic event has:

{
  "id": "evt_test_01HXX...",
  "event": "test.ping",
  "created_at": "2026-05-07T14:00:00.000Z",
  "api_version": "v1",
  "organization_id": "org_01HXX...",
  "data": { "message": "If you can read this, your endpoint is configured correctly." }
}

The response includes the resulting delivery_id so you can look up the attempt in the delivery log.

Event catalog

The full list of event types you can subscribe to. New event types are added over time and are always additive — clients should ignore unknown event types they receive.

Event Resource Notes
observation.created Observation New observation.
observation.updated Observation Field change on a non-archived observation.
observation.archived Observation Archived.
observation.restored Observation Restored from archive.
summary.created Summary Draft created.
summary.updated Summary Body or includes changed (pre-share).
summary.submitted Summary Moved to pending_review.
summary.approved Summary Moved to approved.
summary.shared Summary Moved to shared.
summary.archived Summary Archived.
summary.feedback_submitted Summary Employee posted feedback on a shared summary.
conversation.message.created Message New message in a conversation.
objective.created Objective New objective at any scope.
objective.status_changed Objective Lifecycle transition.
objective.updated Objective Title, description, or period change.
objective.archived Objective Archived.
assignment.created Assignment Feedback assignment created.
assignment.completed Assignment All required submissions received.
employee.created Employee Created via API or CSV.
employee.updated Employee Field change.
employee.deactivated Employee Deactivated.
employee.reactivated Employee Reactivated.
employee.rehired Employee Rehired.
test.ping (none) Sent by /webhooks/{id}/test.

For data shape on each event, refer to the resource's own reference article.

Idempotency on the receiver side

Deliveries can repeat because of retries, network blips, or rare double-fire conditions. Always make your handler idempotent.

The recommended pattern:

  1. Read event.id from the body (or X-PerfBlocks-Event-Id from headers — they match).
  2. On receipt, atomically INSERT the event ID into a dedupe table with a unique constraint.
  3. If the insert fails with a unique-violation, return 2xx immediately — you've already processed this event.
  4. Otherwise, process the event and commit your work.
  5. Periodically prune dedupe rows older than your retry window (~32 hours covers everything).

Pseudocode:

async function handleEvent(event: WebhookEvent) {
  try {
    await db.insert(processedEvents).values({ id: event.id });
  } catch (err) {
    if (isUniqueViolation(err)) return; // already processed
    throw err;
  }
  await applyEvent(event);
}

This keeps your handler safe even if Performance Blocks ever retries a delivery you've already handled.

Common subscription errors

Status Code Cause
400 validation_error Invalid URL, unknown event type, secret rotation request without an existing subscription.
403 permission_denied Key lacks webhooks:write.
422 unprocessable URL resolves to a private IP, or scheme is not HTTPS.

© 2026 Performance Blocks. All rights reserved.