DEVELOPER GUIDE
Beta
Getting Started
- Overview
- Step 1: Choose a signing secret
- Step 2: Register your endpoint
- Step 3: Verify the signature in your handler
- Step 4: Send a test delivery
- Step 5: Check what arrived
- More about the org unit
- What to read next
Overview
This guide takes you from nothing to a verified delivery. You choose a signing secret, register an endpoint, write a handler that verifies what arrives, send a test delivery, and then read the delivery row it produced.
You need five things before you start. Two of them come from a person, so ask early.
- A PPS access token, sent as
Authorization: Bearer <token>. Sign in with the credentials PPS issued you to obtain one. It expires after an hour, so build the refresh in now rather than later. Build re-authentication in too: the refresh token expires on its own schedule, after which a refresh is rejected and an unattended integration has to be able to sign in again by itself. See Obtaining An Access Token for both lifetimes. - Your org unit GUID, a string like
OU41502. Your PPS account manager supplies it. It is required on every register call and there is no default, so without it no register call can succeed. - The
serviceNameandmodelNameof each product you want events for. They are the same pair you name when you start a workflow, so whatever you start a workflow for is what you name here. Your PPS account manager can confirm them. - An HTTPS URL on a domain PPS has authorized. PPS must authorize every destination domain before it will send webhooks to it. Ask your PPS account manager to authorize yours before you register.
- A subscription grant, but only if you need another tenant’s data. Your own tenant’s data reaches your endpoints as soon as you declare the product and topic in step 2 — there is nothing to request. To receive event data originating in another tenant, that tenant’s data owner must entitle yours. Only PPS writes grants: ask for one, naming your org unit GUID, the
serviceNameandmodelName, and the topics you want.
A mistyped modelName registers successfully and then receives nothing, for good. Unlike serviceName, which is checked against the real services and rejected with a 400 when it is unrecognized, modelName is stored exactly as you send it and matched exactly at delivery time. Compare the acceptedEventTypes echoed back on the register response against what you sent.
Step 1: Choose a signing secret
You choose your own signing secret. PPS never generates one, and no response from any call in this API ever carries one — it travels in one direction only, from you to PPS.
The format is the whsec_ prefix followed by 24 to 64 bytes of key material in base64. The range counts the decoded bytes, not the characters, so the command below produces 32 bytes as a 44-character standard-alphabet string. Generate it from a cryptographically secure random source, never from a password, a passphrase, or a value you use anywhere else.
printf 'whsec_%s\n' "$(openssl rand -base64 32 | tr -d '\n')"
The value shown in the examples on these pages is an illustration. Generate your own and never use one from the documentation. PPS checks that a secret is well formed but cannot check that it is unguessable. A weak secret lets anyone forge deliveries your server will accept as genuine, so treat the value as a credential: store it in your secret store, and do not paste it into a support ticket.
Step 2: Register your endpoint
organizationalUnitGuid names the org unit this endpoint belongs to, and its deliveries are routed to. Your PPS account manager supplies the value. It is required, there is no default, and a register body that omits it is rejected with a 400.
serviceName and modelName name the product — ADV and ADV-120 here. Use the pair for the product you actually run, not these.
curl -X POST https://api.pointservices.com/riskinsight-services-ws/resources/v1/webhooks/endpoints \
-H "Authorization: Bearer your_access_token_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/pps/orders",
"organizationalUnitGuid": "OU41502",
"displayName": "Production order hook",
"acceptedEventTypes": [
{
"serviceName": "ADV",
"modelName": "ADV-120",
"eventTypes": ["workflow.updated", "workflow.failed"]
}
],
"contacts": ["integrations@example.com"],
"signingSecret": "whsec_Kv9mQ3xT7pYbN2sW8gLdR5hJ4cF6nZaE1uXoP0iVtB0="
}'
A successful register returns 200, not 201, and the endpoint comes back without its secret.
{
"id": 4711,
"organizationalUnitGuid": "OU41502",
"displayName": "Production order hook",
"url": "https://hooks.example.com/pps/orders",
"enabled": true,
"circuitState": "CLOSED",
"receiving": true,
"hasNoContactsRegistered": false,
"signatureMode": "SYMMETRIC",
"acceptedEventTypes": [
{
"serviceName": "ADV",
"modelName": "ADV-120",
"eventTypes": ["workflow.updated", "workflow.failed"]
}
],
"warnings": []
}
Keep the id — 4711 here. Every other call that names an endpoint takes it.
warnings carries advisories that blocked nothing; an empty array means your configuration produced none. Read it: a register that declares no accepted event types, or accepts workflow.updated without workflow.failed, succeeds and says so here.
Read receiving, not enabled. A newly registered endpoint is always enabled, but it receives nothing until three conditions hold at once: it is enabled, its circuit is CLOSED, and it has declared the product and topic in acceptedEventTypes. When receiving is false, notReceivingReason names which condition failed and therefore who fixes it. That third condition is per endpoint and per product, so declaring ADV-120 says nothing about any other product.
There is more on which org units you may name, and on what happens when you move an endpoint between them, under More about the org unit below.
Step 3: Verify the signature in your handler
Every delivery carries a signature in the webhook-signature header. Verify it before you parse the body or act on it.
The shortest working handler reads the raw bytes, checks that the timestamp is recent, rebuilds the signed string, and compares in constant time.
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.PPS_WEBHOOK_SECRET; // whsec_...
// express.raw() must come before the route: the signature covers the exact
// bytes received, so a parsed and reserialized body will not verify.
app.post('/pps/orders', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.get('webhook-id');
const timestamp = req.get('webhook-timestamp');
const header = req.get('webhook-signature');
if (!id || !timestamp || !header) {
return res.status(400).json({ error: 'missing signature headers' });
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).json({ error: 'stale timestamp' });
}
const signed = Buffer.concat([
Buffer.from(`${id}.${timestamp}.`, 'utf8'),
req.body,
]);
// The key is the secret with whsec_ stripped, then base64-decoded.
const key = Buffer.from(SECRET.slice('whsec_'.length), 'base64');
const expected = crypto.createHmac('sha256', key).update(signed).digest();
// The header is a space-separated list; accept if ANY entry verifies.
const verified = header.split(/\s+/).some((entry) => {
const [version, value] = [entry.slice(0, entry.indexOf(',')), entry.slice(entry.indexOf(',') + 1)];
if (version !== 'v1' || !value) {
return false;
}
const presented = Buffer.from(value, 'base64');
return (
presented.length === expected.length &&
crypto.timingSafeEqual(expected, presented)
);
});
if (!verified) {
return res.status(401).json({ error: 'signature verification failed' });
}
const envelope = JSON.parse(req.body.toString('utf8'));
// Deduplicate on id, queue the work, then answer.
return res.status(200).json({ received: true });
});
app.listen(8080);
That handler covers the v1 symmetric scheme, which is what a SYMMETRIC endpoint receives. An ASYMMETRIC endpoint receives v1a instead, verified against the PPS public key rather than a shared secret.
Verifying Signatures covers both schemes in full, with Java, Python and Node examples that handle key rotation and JWKS discovery.
Step 4: Send a test delivery
A test delivery exercises the real delivery path against one endpoint: the same signing, the same headers, and the same validation that production deliveries use. It is synchronous and makes one attempt only, with no retry, and it reports the outcome to you inline rather than as an HTTP error.
curl -X POST https://api.pointservices.com/riskinsight-services-ws/resources/v1/webhooks/endpoints/4711/test \
-H "Authorization: Bearer your_access_token_here" \
-H "Content-Type: application/json" \
-d '{
"serviceName": "ADV",
"modelName": "ADV-120",
"eventType": "workflow.updated"
}'
PPS answers with a 200 whenever the test ran at all.
{
"eventId": "msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451",
"topic": "workflow.updated",
"url": "https://hooks.example.com/pps/orders",
"sent": true,
"success": true,
"statusCode": 200,
"responseBody": "{\"received\":true}",
"durationMillis": 187,
"payload": {
"type": "workflow.updated",
"eventId": "msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451",
"timestamp": "2026-08-28T14:26:03Z",
"data": {
"test": true,
"workflowId": "wfpop_8f14e45f-ceea-467a-9c1a-2b6d3e7f4a91",
"runId": "wfrunpop_8f14e45f-ceea-467a-9c1a-2b6d3e7f4a91",
"serviceName": "ADV",
"modelName": "ADV-120",
"status": "ready"
}
},
"warnings": []
}
A 200 means the test RAN, not that it succeeded. Your endpoint’s failure is data in the body, carried by success, statusCode and failureReason, and never an HTTP error from PPS: a 502 from your server is a correct outcome of a working test. Read success, not the status line.
The call blocks for as long as your own server takes to answer, up to about 45 seconds, so set your client’s timeout accordingly.
If the test returns a 400
A test needs both halves of consent in place. The eventType must be one this endpoint has declared for this product, and a live subscription grant must entitle your tenant to that product and event type. A missing half is a 400.
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Endpoint 4711 has not declared `workflow.failed` for ADV/ADV-120, so no test of that shape can be sent.",
"instance": "urn:pps:request:24f80b6e-91a3-4c57-bd08-7e5f3a2c169d"
}
Both halves answer with the same status, so detail is the only thing that tells them apart. Read it. A selector naming something you did not declare is yours to fix — edit the endpoint and add it. A missing grant is not: grants are written by PPS, so go back to your PPS account manager. If you have just asked for one, this is the expected result until it is in place.
Step 5: Check what arrived
A test creates no delivery record, so the test you just sent will not appear here; its response body was the whole record. This listing is where real and simulated deliveries show up. It takes its filter in the request body, so it is a POST that creates nothing. Send {} for an unfiltered first page.
curl -X POST https://api.pointservices.com/riskinsight-services-ws/resources/v1/webhooks/deliveries/4711/query \
-H "Authorization: Bearer your_access_token_here" \
-H "Content-Type: application/json" \
-d '{}'
If no business event has fired for your endpoint yet, the page comes back empty. That is the expected result at this point, not a misconfiguration:
{
"limit": 100,
"count": 0,
"results": []
}
count and results are always present, count including at 0 and results including as [], so you can loop over the page unconditionally.
Once real events start flowing, the same call returns them:
{
"limit": 100,
"count": 1,
"results": [
{
"id": 80510,
"eventId": "msg_4a8c17e3-0b62-4d59-9f83-1e7c05b2d648",
"topic": "workflow.updated",
"url": "https://hooks.example.com/pps/orders",
"status": "DELIVERED",
"simulated": false,
"deliveredDateTime": "2026-08-28T14:26:05Z",
"lastResponseStatus": 200,
"lastFailureReason": null
}
]
}
Deliveries come back oldest first, ascending by delivery id. When you page through them, nextMarker is present only while the page is full, so a page that is not full is the last one.
The endpoint listing uses the opposite rule, so do not write one paging loop for both. There a short or even empty page can still carry a cursor, and you stop only when nextMarker is absent. See List Endpoints.
simulated is false on a delivery produced by a real business event, and true on one produced by the event simulation operation.
More about the org unit
organizationalUnitGuid names the org unit an endpoint belongs to, which is the org unit its deliveries are routed to. On the wire it is a string, written like OU41502.
Where to get yours
Your PPS account manager supplies your org unit GUID. There is no call in this API that lists the org units available to you and no way to derive the value from your access token. Ask for it before you write your first register call: it is required, it has no default, and a register body that omits it is rejected with a 400.
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "`organizationalUnitGuid` is required on register and was not supplied. There is no default.",
"instance": "urn:pps:request:c40a92e7-1b58-4d73-8f26-5a9e3c0b7d41"
}
Which org units you may name
You may name your own org unit, or any org unit beneath yours in your tenant’s org unit tree — which is how you create an endpoint for one branch or department. An org unit that is neither your own nor beneath it is rejected with a 403, and so is one you hold no registration rights for. Ancestors are not reachable either: an org unit above yours is a wider scope than the one you hold, so naming one is refused the same way.
A denial is a 403 and the endpoint is not created. PPS does not fall back to your own org unit, so a register that names the wrong org unit fails loudly rather than quietly creating an endpoint somewhere you did not intend. Read organizationalUnitGuid back off the response to confirm where the endpoint actually landed.
Registering into a child org unit
An endpoint receives the events of the org unit it belongs to, and nothing inherits up or down the tree. An endpoint you create in a department therefore sees that department’s events, not the parent company’s. Both org units are in your own tenant, so no subscription grant is involved either way — point the endpoint at the org unit whose events you want.
Read organizationalUnitGuid and receiving back off the response before assuming anything will arrive, and confirm the org unit you named is the one whose events you actually want.
Moving an endpoint to another org unit
Moving an endpoint between org units changes which org unit’s events it receives: the move returns 200 and the endpoint still reports itself enabled and receiving, while the events it was built for stop arriving. See Moving to another org unit before you do it.
One field is easy to mistake for this one. organizationalUnitGuid is an org unit in your own tenant, the destination deliveries are routed to. sourceOuGuid, which appears only on the delivery rows a simulated event returns, names an org unit in the data owner’s tenant — the one the event originated in, and never your own.
Endpoints API Reference carries the full property tables and the two-row disambiguation.
What to read next
- Verifying Signatures — the full verification contract, in Java, Python and Node.
- Receiving Deliveries — what the envelope carries, what to respond, and how retries work.
- Troubleshooting — when an event does not arrive.