HFU Digital Docs
StarPlan APIDeveloper Program

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

StepEndpoint
CreatePOST /v1/starplan/webhooks
List your subscriptionsGET /v1/starplan/webhooks
InspectGET /v1/starplan/webhooks/:id
UpdatePATCH /v1/starplan/webhooks/:id
DeleteDELETE /v1/starplan/webhooks/:id
List deliveriesGET /v1/starplan/webhooks/:id/deliveries
Trigger a synthetic deliveryPOST /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

FieldTypeNotes
urlstringRequired. Must include a protocol; TLD is not required (http://internal.local is accepted), so use TLS at your edge.
secretstringOptional. 16–255 characters. If set, signs every delivery.
filterScopeenumOne of global, program, semester, course, room, instructor. Default global.
filterProgramId / filterSemesterId / filterCourseId / filterRoomId / filterInstructorIdUUIDOptional. Only the field matching the chosen scope is consulted at delivery time; other ids are stored but ignored.
eventTypesstring[]Subset of ["created", "updated", "deleted"]. Default: all three.

Filter scopes

ScopeWebhook fires when…
global…any change is logged. The default.
programchange.programId === filterProgramId.
semesterchange.semesterId === filterSemesterId.
coursechange.courseId === filterCourseId.
roomchange.roomId === filterRoomId.
instructorchange.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 */ }
  }
}
  • id is the StarPlan change-log row id (the same id as GET /v1/starplan/changes/:id).
  • event is "<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 via GET /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:

AttemptDelay before this attempt
10s (immediate, on the next 30s cron tick)
21 minute after the failed attempt 1
35 minutes after the failed attempt 2
415 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).

On this page