Prism Connect
Webhooks
Get pushed the moment something changes on an account — tasks, horse notes, media, reports and stable updates — instead of polling for it. Every delivery is signed with a secret only you and Prism hold.
1
Register your endpoint
In Prism, open Integration → Webhook, enter your HTTPS URL and tick the modules you want. The signing secret is shown once — store it somewhere safe.
2
Pass the handshake
Prism immediately POSTs a webhook.verification event to your URL. Answer HTTP 200 and your endpoint becomes ACTIVE.
3
Receive events
Every event in a subscribed module is POSTed to your URL, signed with your secret and retried if your endpoint is unavailable.
Endpoint requirements
- HTTPS only. Plain HTTP is rejected at registration.
- Port 443 or 8443.
- A publicly resolvable host. Private, loopback and link-local addresses are rejected, and the host is re-checked at delivery time.
- No credentials embedded in the URL.
What we send
Every delivery is a POST with a JSON body and the headers below.
| Header | Example | Meaning |
|---|---|---|
X-Prism-Event | horse.report.group.created | Event type (wire name). Switch on this — never on the module. |
X-Prism-Event-Id | 8e9e30d5-9934-406a-842c-a38e06ce4443 | Identifies the event. Stable across retries of the same event. |
X-Prism-Delivery-Id | 0ed43984-8494-4536-b3f0-aa227f435af0 | Identifies this delivery attempt chain. Use it as your idempotency key. |
X-Prism-Delivery-Attempt | 1 | 1 on the first attempt, incrementing on each retry. Absent on the verification request. |
X-Prism-Signature-Timestamp | 1785421852 | Unix time in SECONDS at signing. Part of the signed string. |
X-Prism-Signature | sha256=6f3a… | HMAC-SHA256 of timestamp + "." + raw body, lowercase hex. |
Content-Type | application/json | Always JSON. |
User-Agent | Prism-Webhook/1.0 | Prism-Webhook-Verify/1.0 for the verification request. |
The envelope
The outer shape is identical for every event. Only data changes, and its shape is determined by eventType.
| Field | Type | Notes |
|---|---|---|
deliveryId | string | Stable across retries — deduplicate on this. |
eventId | string | Identifies the event itself. |
eventType | string | Wire name, e.g. "task.trackwork.created". |
occurredAt | string | ISO 8601 UTC instant, e.g. "2026-07-30T04:00:00Z". |
accountId | number | The Prism account the event belongs to. |
data | object | Event payload. Its shape is determined by eventType. |
Example delivery
{
"deliveryId": "0ed43984-8494-4536-b3f0-aa227f435af0",
"eventId": "8e9e30d5-9934-406a-842c-a38e06ce4443",
"eventType": "horse.report.group.created",
"occurredAt": "2026-07-30T04:00:00Z",
"accountId": 1234,
"data": {
"accountId": 1234,
"id": 5678,
"type": "GROUP_UPDATE",
"title": "Weekly stable update",
"status": "PUBLISHED",
"publishDate": "2026-07-30T04:00:00Z",
"reportCount": 2,
"reports": [
{
"id": 55101,
"horseId": 42,
"horseName": "Winx"
},
{
"id": 55102,
"horseId": 43,
"horseName": "Black Caviar"
}
]
}
}Verifying the signature
HMAC-SHA256 over <X-Prism-Signature-Timestamp> + "." + <raw request body>, sent as sha256=<lowercase hex digest> in X-Prism-Signature.
The key is your signing secret, used as raw UTF-8 bytes — do not base64-decode or hash it first.
Node / TypeScript
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SIGNING_SECRET = process.env.PRISM_WEBHOOK_SECRET!; // whsec_… from Integration → Webhook
const TOLERANCE_SECONDS = 5 * 60; // reject very old / future timestamps
/**
* IMPORTANT: express.raw() — the signature covers the RAW body bytes.
* express.json() would give you a parsed object, and re-serialising it changes
* whitespace and key order, so the signature would never match.
*/
app.post('/webhooks/prism', express.raw({ type: 'application/json' }), (req, res) => {
const signatureHeader = req.header('X-Prism-Signature'); // "sha256=<hex>"
const timestamp = req.header('X-Prism-Signature-Timestamp'); // unix SECONDS
const rawBody = req.body as Buffer;
if (!signatureHeader || !timestamp) {
return res.status(400).send('missing signature headers');
}
// 1. Reject stale timestamps — this is what stops a captured request being replayed.
const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > TOLERANCE_SECONDS) {
return res.status(400).send('stale timestamp');
}
// 2. Recompute: HMAC-SHA256 over "<timestamp>." + rawBody, keyed with the secret.
const expected = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(timestamp + '.')
.update(rawBody)
.digest('hex');
const received = signatureHeader.replace(/^sha256=/, '');
// 3. Constant-time compare. timingSafeEqual throws on length mismatch, so guard first.
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(received, 'utf8');
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(rawBody.toString('utf8'));
// 4. The verification handshake: answer 200 and you are switched to ACTIVE.
// Until that happens Prism sends no product events at all.
if (event.eventType === 'webhook.verification') {
return res.sendStatus(200);
}
// 5. Acknowledge FAST, then work. Prism gives you 10 seconds before it counts a timeout.
res.sendStatus(200);
// 6. Delivery is at-least-once: the same deliveryId can arrive again after a retry.
void handleEvent(event).catch(err => console.error('[prism] handler failed', err));
});
const seenDeliveries = new Set<string>(); // use Redis/DB in production, not memory
async function handleEvent(event: {
deliveryId: string;
eventType: string;
accountId: number;
data: Record<string, unknown>;
}) {
if (seenDeliveries.has(event.deliveryId)) return; // idempotency
seenDeliveries.add(event.deliveryId);
// Switch on eventType, never on the module: one module can deliver more than one payload shape.
switch (event.eventType) {
case 'horse.report.group.created': {
// A group update: one event carrying every report in the group.
const { id, reportCount, reports } = event.data as {
id: number;
reportCount: number;
reports: { id: number; horseId: number; horseName: string }[];
};
console.log(`group ${id}: ${reportCount} reports`, reports.map(r => r.horseName));
break;
}
case 'task.trackwork.created':
console.log('new trackwork', event.data);
break;
default:
console.log('unhandled event', event.eventType);
}
}
app.listen(3000);
Delivery, retries and idempotency
Acknowledge with any 2xx as soon as you have the payload, then do your work asynchronously. Prism waits 10 seconds before it counts a timeout.
Success | Any 2xx is success. Respond quickly — acknowledge first, process asynchronously. |
Retried statuses | 500-599, 408, 425 and 429 |
Not retried | Any other 4xx is treated as final — we will not retry. |
Attempts | 5 (initial + 4 retries) |
Retry schedule | 30 seconds 2 minutes 10 minutes 30 minutes |
Response timeout | 10 seconds |
Maximum payload | 256 KB |
Semantics | At least once. A retry re-sends the same deliveryId and eventId, so deduplicate on deliveryId. |
Ordering | Delivery order is not guaranteed. Use occurredAt if you need ordering. |
Automatic disabling
If at least 10 deliveries in a 60-minute window fail at a rate of 50% or more, The endpoint is set to AUTO_DISABLED and we email the account. Re-enable it from Integration → Webhook with Verify, which re-runs the handshake and resets the counters.
Event catalogue
You subscribe per module, and each module emits the events below. Always switch on eventType — one module can deliver more than one payload shape.
Trackwork tasks
TASK_TRACKWORKtask.trackwork.createdtask.trackwork.updatedtask.trackwork.completedtask.trackwork.deletedProcedure tasks
TASK_PROCEDUREtask.procedure.createdtask.procedure.updatedtask.procedure.completedtask.procedure.deletedTransport tasks
TASK_TRANSPORTtask.transport.createdtask.transport.updatedtask.transport.completedtask.transport.deletedGeneral tasks
TASK_GENERALtask.general.createdtask.general.updatedtask.general.completedtask.general.deletedHorse notes — one module per note type
HORSE_NOTE_*note.general.*note.vet.*note.temp.*note.weight.*note.feed_left.*note.scope.*note.xray.*note.trot_up.*note.race_plan.*note.blood_profile.*Each note type is subscribed separately and emits created, updated and deleted.
Horse media
HORSE_MEDIAhorse.media.createdhorse.media.updatedhorse.media.deletedHorse reports and group updates
HORSE_REPORThorse.report.createdhorse.report.updatedhorse.report.deletedhorse.report.group.createdhorse.report.group.updatedhorse.report.group.deletedThis module delivers TWO payload shapes: a single report, and a group update carrying a reports[] array. Switch on eventType.
Stable updates
GENERAL_UPDATEgeneral_update.createdgeneral_update.updatedgeneral_update.deletedSystem
—webhook.testwebhook.verificationSystem events bypass module subscriptions. webhook.test is sent from the portal's Send test button.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The endpoint receives nothing at all, and no errors appear anywhere. | The webhook is still PENDING_VERIFICATION. Product events are withheld until the handshake passes. | Open Integration → Webhook and press Verify. Your endpoint must answer the verification request with HTTP 200. |
| Some event types arrive, others never do. | The missing events belong to a module the webhook is not subscribed to. | Tick the module in Integration → Webhook → Events and save. |
| Signature never matches. | Almost always the body: the signature covers the raw bytes, and most frameworks hand you a re-serialised object. | Capture the raw body before any JSON parsing — see the express.raw() example. |
| Deliveries stopped after a period of failures. | Auto-disable tripped: at least 10 deliveries in a 60-minute window with a failure rate of 50% or more. | Fix the endpoint, then press Verify to re-enable it and reset the counters. |
| The same event arrives more than once. | Expected. Delivery is at least once — a retry re-sends the same deliveryId. | Deduplicate on deliveryId and make your handler idempotent. |
Ready to try it?
Explore every endpoint and send live requests right in the browser — no setup, no installs.
Open the API Playground