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:
tis the timestamp (Unix seconds), matchingX-PerfBlocks-Timestamp.v1is the hex-encoded HMAC-SHA256 of the stringt.<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
- Read the
X-PerfBlocks-Signatureheader. Parsetand one or morev1values. - Reject if
tis more than 5 minutes from your server's current time (replay protection). - Compute
expected = hex(HMAC_SHA256(secret, t + "." + raw_body)). - Compare each
v1toexpectedusing a constant-time comparison. - 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:
- Read
event.idfrom the body (orX-PerfBlocks-Event-Idfrom headers — they match). - On receipt, atomically
INSERTthe event ID into a dedupe table with a unique constraint. - If the insert fails with a unique-violation, return
2xximmediately — you've already processed this event. - Otherwise, process the event and commit your work.
- 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. |