Webhooks
Push notifications for StarPlan change events — filter scopes, signature verification, retries
A StarPlan webhook is an HTTPS endpoint that the platform calls whenever a matching change event is appended to the change log. Each developer can have many webhooks, each with its own URL, optional secret, filter scope, and selected event types.
Lifecycle
| Step | Endpoint |
|---|---|
| Create | POST /v1/starplan/webhooks |
| List your subscriptions | GET /v1/starplan/webhooks |
| Inspect | GET /v1/starplan/webhooks/:id |
| Update | PATCH /v1/starplan/webhooks/:id |
| Delete | DELETE /v1/starplan/webhooks/:id |
| List deliveries | GET /v1/starplan/webhooks/:id/deliveries |
| Trigger a synthetic delivery | POST /v1/starplan/webhooks/:id/test |
All webhook endpoints require an X-API-Key and operate only on webhooks owned by that key's developer. Attempting to read or modify another developer's webhook returns 403 Forbidden — You do not own this webhook.
Create
curl -X POST https://api.hfu.digital/v1/starplan/webhooks \
-H 'X-API-Key: spk_…' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com/hooks/starplan",
"secret": "a-strong-shared-secret-min-16-chars",
"filterScope": "program",
"filterProgramId": "8b7c…",
"eventTypes": ["created", "updated", "deleted"]
}'Body schema
| Field | Type | Notes |
|---|---|---|
url | string | Required. Must include a protocol; TLD is not required (http://internal.local is accepted), so use TLS at your edge. |
secret | string | Optional. 16–255 characters. If set, signs every delivery. |
filterScope | enum | One of global, program, semester, course, room, instructor. Default global. |
filterProgramId / filterSemesterId / filterCourseId / filterRoomId / filterInstructorId | UUID | Optional. Only the field matching the chosen scope is consulted at delivery time; other ids are stored but ignored. |
eventTypes | string[] | Subset of ["created", "updated", "deleted"]. Default: all three. |
Filter scopes
| Scope | Webhook fires when… |
|---|---|
global | …any change is logged. The default. |
program | …change.programId === filterProgramId. |
semester | …change.semesterId === filterSemesterId. |
course | …change.courseId === filterCourseId. |
room | …change.roomId === filterRoomId. |
instructor | …change.instructorId === filterInstructorId. |
The programId / semesterId / courseId / roomId / instructorId fields on a change are populated by the platform's StarPlan sync layer; events that don't have a relevant id (e.g. a global config change) won't match any non-global scope.
Delivery wire format
When a webhook matches, the platform creates a StarPlanWebhookDelivery row in pending state, and a cron loop picks it up within 30 seconds.
The HTTP request your endpoint sees:
POST https://example.com/hooks/starplan
Content-Type: application/json
X-Webhook-Timestamp: 1714128000
X-Webhook-Signature: sha256=<hex> ← only present if you set a secret{
"id": "ch…",
"timestamp": "2026-04-26T12:00:00.000Z",
"event": "course.updated",
"data": {
"entityType": "course",
"entityId": "co…",
"action": "updated",
"changedFields":["roomId", "startTime"],
"previous": { /* previous data, may be null on created */ },
"current": { /* new data, may be null on deleted */ }
}
}idis the StarPlan change-log row id (the same id asGET /v1/starplan/changes/:id).eventis"<entityType>.<action>"— e.g.course.updated,room.created,instructor.deleted.timestamp(in the body) is the dispatch time, not the original change time. The original change time is available viaGET /v1/starplan/changes/:id.
Test deliveries
POST /v1/starplan/webhooks/:id/test queues a synthetic delivery whose payload is shaped like a normal one but with event: "test.created", entityType: "test", entityId: "test-entity", and a placeholder message:
{
"id": "test-1714128000000",
"timestamp": "2026-04-26T12:00:00.000Z",
"event": "test.created",
"data": {
"entityType": "test",
"entityId": "test-entity",
"action": "created",
"changedFields":[],
"previous": null,
"current": { "message": "This is a test webhook delivery from api.hfu.digital/v1/starplan" }
}
}Test deliveries do not check your filter scope — they always go out, so you can validate signature handling without first creating a matching change.
Signature verification
When a secret is registered, every delivery carries X-Webhook-Signature: sha256=<hex>, where <hex> is:
HMAC-SHA256(secret, "<X-Webhook-Timestamp>.<rawJsonBody>")The timestamp is included in the signed string to prevent replay. Always verify in constant time to avoid timing oracles.
Node.js (server-side)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyStarplanWebhook(
secret: string,
headers: { 'x-webhook-timestamp'?: string; 'x-webhook-signature'?: string },
rawBody: string,
): boolean {
const ts = headers['x-webhook-timestamp'];
const sig = headers['x-webhook-signature'];
if (!ts || !sig) return false;
// Reject deliveries older than 5 minutes — anti-replay.
const skewSec = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
if (!Number.isFinite(skewSec) || skewSec > 300) return false;
const expected = `sha256=${createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex')}`;
if (sig.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}You must verify against the raw request body, not the parsed JSON. Body parsers can re-serialize with different whitespace, breaking the HMAC. In Express, capture req.rawBody in middleware before express.json() runs.
Python (Flask)
import hmac, hashlib, time
from flask import abort, request
SECRET = b'<your secret>'
def verify():
ts = request.headers.get('X-Webhook-Timestamp', '')
sig = request.headers.get('X-Webhook-Signature', '')
if not ts or not sig: abort(401)
if abs(int(time.time()) - int(ts)) > 300: abort(401)
expected = 'sha256=' + hmac.new(
SECRET, f'{ts}.{request.get_data(as_text=True)}'.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected): abort(401)Without a secret
If you create the webhook without secret, the platform omits the X-Webhook-Signature header entirely. Use this only for low-stakes prototyping — without a signature there is no way for your endpoint to confirm the delivery actually came from HFU Digital.
Retry policy
Each delivery is attempted up to 4 times. The schedule is fixed:
| Attempt | Delay before this attempt |
|---|---|
| 1 | 0s (immediate, on the next 30s cron tick) |
| 2 | 1 minute after the failed attempt 1 |
| 3 | 5 minutes after the failed attempt 2 |
| 4 | 15 minutes after the failed attempt 3 |
A delivery is considered successful on any 2xx response. Anything else (non-2xx, network error, timeout, abort) counts as a failed attempt. After the fourth failure the delivery transitions to status: 'failed' and is not retried further.
The HTTP request itself uses a 30-second timeout (AbortSignal.timeout). If your endpoint can't respond within 30 seconds, accept the delivery quickly with a 202 and process asynchronously.
The response body is captured (truncated to 1000 characters) on every attempt — you can inspect it via GET /v1/starplan/webhooks/:id/deliveries.
Inspect deliveries
curl 'https://api.hfu.digital/v1/starplan/webhooks/<webhookId>/deliveries?status=failed&page=1&limit=50' \
-H 'X-API-Key: spk_…'Filters: status (pending | delivered | failed), page (default 1), limit (default 50, hard cap 200).
{
"data": [
{
"id": "del…",
"webhookId": "wh…",
"eventType": "course.updated",
"payload": { /* delivered JSON */ },
"status": "failed",
"attempts": 4,
"lastAttemptAt":"2026-04-26T12:21:00.000Z",
"nextRetryAt": "2026-04-26T12:21:00.000Z",
"responseCode": 502,
"responseBody": "<truncated upstream HTML>",
"errorMessage": null,
"deliveredAt": null,
"createdAt": "2026-04-26T12:00:00.000Z"
}
],
"meta": { "total": 137, "page": 1, "limit": 50, "totalPages": 3 }
}responseCode is null when the failure was a network error or timeout (in which case errorMessage is populated instead).
Idempotency
Until your endpoint returns 2xx, the delivery may be retried. Treat each id (the body's top-level id — i.e. the change-log row id) as idempotent and dedupe on it. The same id will not be re-queued for new attempts after status becomes delivered.
For test deliveries, id is test-<unix-millis> — unique per call to /test, so dedupe still works.
Update / delete
# Pause a webhook without losing its config
curl -X PATCH https://api.hfu.digital/v1/starplan/webhooks/<id> \
-H 'X-API-Key: spk_…' -H 'Content-Type: application/json' \
-d '{ "isActive": false }'
# Permanently remove
curl -X DELETE https://api.hfu.digital/v1/starplan/webhooks/<id> \
-H 'X-API-Key: spk_…'PATCH accepts the same fields as POST plus isActive: boolean. Inactive webhooks are skipped at delivery match time; their pending deliveries continue to retry until they succeed or hit the attempt cap.
DELETE removes the webhook row but keeps prior StarPlanWebhookDelivery history (deliveries cascade-delete only via Prisma if the schema specifies it; pending deliveries for a deleted webhook will fail because the join-load includes webhook — treat deletion as final).