Beta
Event Topics
A topic names what happened. PPS publishes eight, in two families, and an endpoint may accept any subset of them for each product it declares.
| Topic | Fires when | data.status | Extra data ids |
|---|---|---|---|
| workflow.pending | A run enters processing, for the first time or on resuming after a failure. | pending | |
| workflow.updated | New data becomes available for the workflow: a run becomes ready, or a finding on one of its runs changes. | ready when a run became ready; the run’s current status when it accompanies a finding change | |
| workflow.failed | A run fails. | failed | |
| finding.result_updated | A finding’s result was overridden. The effective outcome is resultOrOverriddenResult on the finding. | the run’s current status | findingId |
| finding.reviewed | A finding was cleared, by a reviewer (for example with reviewed: true on the edit call) or automatically. | the run’s current status | findingId |
| finding.note_updated | A finding’s review note was set, replaced or cleared. | the run’s current status | findingId |
| finding.attachment_updated | An attachment was added to a finding, or an existing attachment’s content or details changed. | the run’s current status | findingId, attachmentId |
| finding.attachment_removed | An attachment was removed from a finding. Its content can no longer be fetched. | the run’s current status | findingId, attachmentId |
Matching is exact and sensitive to case. Workflow.Updated is not workflow.updated, and any value outside the eight is rejected with a 400.
On a workflow topic, data.status agrees with the topic. On a finding topic it is whatever status the run holds at that moment, for example pending, ready or complete; it does not describe the finding. A test delivery carries no data.status at all.
There is no completion event. None of the three workflow topics implies another follows, and none is terminal: a failed run can later announce workflow.pending and then workflow.updated.
Finding events
A finding event says which finding changed and how. It fires only when something actually changed: a save that leaves the value as it was publishes nothing.
data.findingIdnames the finding, in the samefndg_-prefixed form the Findings API publishes. Pass it exactly as you received it as thefindingIdof Read a Workflow Finding.data.attachmentIdis carried only on the two attachment topics. It names the attachment in the samejattmt_-prefixed form the Findings API publishes asattachmentId. Afterfinding.attachment_removedthe attachment is gone, so the attachment routes answer 404 for it.data.runIdis the run the finding belongs to, which can be an earlier run of the workflow than its latest one.
Every finding event is accompanied by its own workflow.updated: a separate event, with its own eventId, for the same workflowId and runId. They are not combined, so a change touching several findings, or several things on one finding, publishes one workflow.updated per finding event. An endpoint that accepts only workflow.updated therefore still hears that a finding changed, just not which one.
A reset, which returns a cleared finding to unreviewed (reviewed: false), publishes no finding topic. It arrives as a workflow.updated alone, with no data.findingId, so refetch the workflow’s findings to see which one changed.
Consent is per topic. Accepting workflow.updated does not opt you into any finding topic, so an endpoint receives no finding events until it declares them.
An example finding.reviewed envelope:
{
"type": "finding.reviewed",
"eventId": "msg_6e2b9f47-1c83-4a5d-b0e6-8d4f2a7c9153",
"timestamp": "2026-08-28T16:02:47Z",
"data": {
"workflowId": "wfpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92",
"runId": "wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92",
"findingId": "fndg_3a9d6c1e-47b2-4f08-9e5d-2c7b1f0a8e64",
"correlationId": "9c2e14a7-55d0-4b8e-a731-6f0d2b93e185",
"serviceName": "ADV",
"modelName": "ADV-120",
"status": "complete"
}
}
Receiving Deliveries covers what to do with each topic and why there is no completion event.
Send A Test Delivery
Send a synthetic delivery to one endpoint and report what came back.
POST /v1/webhooks/endpoints/{id}/test
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"
}'
It is synchronous and makes one attempt only: the call blocks for the round trip and there is no retry. It can block for as long as your own server takes to answer, up to about 45 seconds — a connect timeout of 15 seconds plus a read timeout of 30 seconds — before PPS gives up and reports the failure in the body. Set your client’s timeout accordingly.
It is the real delivery path, not a simulation of it. It uses the same signing, the same headers, the same destination-safety validation, the same check that the destination domain is authorized, and the same HTTP client that production deliveries use.
You choose a selector and PPS builds the envelope. You can never supply payload bytes. PPS signatures verify against a published public key everywhere, so accepting caller-supplied bytes would make this API a signing oracle.
Header Properties
| Property | Value | Required? |
|---|---|---|
| Authorization | Bearer your_access_token_here | true |
| Content-Type | application/json | true |
Path Parameters
| Property | Description | Type | Required |
|---|---|---|---|
| id | The endpoint’s id. | number | yes |
Request Data Properties
| Property | Description | Type | Required |
|---|---|---|---|
| serviceName | The product’s service name, matching what you name when starting a workflow and the acceptedEventTypes entry you registered. | string | yes |
| modelName | The product’s model name, paired with serviceName to identify the product exactly as acceptedEventTypes does. | string | yes |
| eventType | Which topic to synthesize, any of the eight. It must be one this endpoint has declared for that product; any other value is rejected with a 400, checked after authorization. | string | yes |
The eventType must be one this endpoint has declared for this product, or the test is refused with a 400. PPS also checks that something routes that product and topic to the endpoint, and refuses with a 400 when nothing does. An endpoint always receives its own org unit’s events, so that second check passes for any endpoint that has declared the topic, and you need no subscription grant to test. Both checks run after authorization, so a caller who may not see this endpoint cannot use either error to learn what it accepts.
A disabled endpoint is still testable and surfaces as a warnings entry rather than a refusal. An open circuit does not block a test either: a successful test closes the circuit and doubles as the recovery probe, while a failed test never counts against it. You can therefore test a broken receiver as often as you need without making its situation worse.
A test creates no delivery record. The response body is the only record of it, so the attempt never appears in your delivery listing and there is nothing to replay. It can still return a 429, because it is checked against the same per-second budget as the endpoint’s real deliveries. It consumes none of that budget itself, so a test can never starve production traffic.
The test envelope is the same for every topic. It carries data.test: true, one random id in both workflowId and runId, and the serviceName and modelName you sent. It carries no status, no correlationId, and, for a finding topic, no findingId or attachmentId.
Responses
200
The test ran. Read success to learn whether your endpoint accepted it.
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.
| Property | Description | Type | Notes |
|---|---|---|---|
| eventId | The synthetic event’s id, formatted as msg_ followed by a version-4 UUID. Structurally disjoint from production event ids, so it can never collide with a real one. A test makes one attempt and is never replayed, so there is nothing to deduplicate on it: do not feed it to whatever tracks your real webhook-id values. | string | always emitted |
| topic | The topic that was synthesized, echoing your eventType. | string | always emitted |
| url | The URL the test was actually sent to, snapshotted at send time. | string | always emitted |
| sent | Whether an HTTP request actually left PPS. False when the attempt was stopped before the wire. | boolean | always emitted |
| success | Whether a 2xx came back. This is the outcome of the test, not the HTTP status of the call you just made. | boolean | always emitted |
| statusCode | The HTTP status your endpoint returned. Emitted as an explicit null when no HTTP response ever arrived. | number | always emitted, may be null |
| failureReason | A short reason token for a failed test, for example HTTP_404, TRANSPORT_SocketTimeoutException or SSRF_VALIDATION_FAILED. Absent when the test succeeded. Not a stable vocabulary to match on. | string | conditional |
| failureDetail | A longer explanation of the failure, written for a person. Absent when the test succeeded. | string | conditional |
| responseBody | Your endpoint’s response body, truncated to the first 8,192 characters — the same bound that applies to a real delivery’s lastResponseBody. The cut is unmarked. Absent when no response arrived or the response carried no body. Untrusted text, and possibly incomplete. | string | conditional |
| durationMillis | Elapsed real time of the HTTP attempt in milliseconds. Measures the outbound call alone. Absent when nothing was sent. | number | conditional |
| payload | The exact JSON body PPS sent, embedded as a JSON object. Always emitted, including when the request never left PPS. | object | always emitted |
| warnings | Advisories about conditions that did not stop the test. Each one describes something that would have stopped a real delivery. English sentences, not codes. | array | always emitted, including [] |
statusCode is emitted as an explicit null. The remaining optional properties are absent when they have nothing to report. The difference is part of the contract.
Example, a successful test:
{
"eventId": "msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451",
"topic": "workflow.updated",
"url": "https://hooks.example.com/pps/workflows",
"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"
}
},
"warnings": []
}
The synthesized envelope on its own. It matches a production one except for its test markers: data.test is true, which never appears on a production envelope, and workflowId and runId are random and resolve to nothing on any PPS API.
{
"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"
}
}
Example, your server answered and rejected it. sent: true with success: false.
{
"eventId": "msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451",
"topic": "workflow.updated",
"url": "https://hooks.example.com/pps/workflows",
"sent": true,
"success": false,
"statusCode": 502,
"failureReason": "HTTP_502",
"failureDetail": "The destination returned 502 Bad Gateway.",
"responseBody": "<html><body>Bad Gateway</body></html>",
"durationMillis": 412,
"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"
}
},
"warnings": []
}
Example, the request never left PPS. sent: false and statusCode is an explicit null.
{
"eventId": "msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451",
"topic": "workflow.updated",
"url": "https://hooks.example.com/pps/workflows",
"sent": false,
"success": false,
"statusCode": null,
"failureReason": "SSRF_VALIDATION_FAILED",
"failureDetail": "The destination did not pass destination-safety validation, so nothing was sent.",
"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"
}
},
"warnings": []
}
Example, a success on a disabled endpoint. The condition is a warning, not a refusal.
{
"eventId": "msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451",
"topic": "workflow.updated",
"url": "https://hooks.example.com/pps/workflows",
"sent": true,
"success": true,
"statusCode": 200,
"responseBody": "{\"received\":true}",
"durationMillis": 203,
"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"
}
},
"warnings": [
"Endpoint 4711 is disabled, so a real event of this shape would not have been delivered."
]
}
A test that succeeds while warnings is not empty is telling you the endpoint works but is not yet receiving.
400
The selector names an event type this endpoint has not declared for that product. The body is an about:blank problem document whose detail names the selector.
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "This endpoint has not declared eventType 'workflow.failed' for product 'ADV'/'ADV-120'. Add it to the endpoint's acceptedEventTypes first.",
"instance": "urn:pps:request:24f80b6e-91a3-4c57-bd08-7e5f3a2c169d"
}
Edit the endpoint to add the topic to its acceptedEventTypes, then test again. A malformed body, or a Content-Type naming a character set the service cannot use (https://pointservices.com/problems/invalid-request), is also a 400.
409
The endpoint’s destination domain is no longer authorized, so nothing can be sent to it.
{
"type": "https://pointservices.com/problems/conflict",
"title": "The resource's current state refuses this request",
"status": 409,
"detail": "This endpoint's destination domain is no longer authorized, so no delivery can be sent to it. Re-authorize the domain, or edit the endpoint to a URL on an authorized one.",
"instance": "urn:pps:request:13e6a97c-40d8-4b21-8f5a-6c09b2e7d384"
}
429
The endpoint’s delivery rate budget for the current window is spent, shared with its real deliveries.
503
PPS could not sign, which is a PPS fault and never a statement about your endpoint.
401
No credential was presented, or the one presented was rejected.
403
Refused before the operation ran: an about:blank problem document, or an HTML body when the platform edge refused the request, for example for a missing Content-Type or a value that resembles SQL or script. An endpoint that is not yours is reported as a 404, not a 403.
404
No such endpoint, delivery or task is available to this request. This also covers a resource that exists but is not yours, or one you may not act on: the three cases are deliberately indistinguishable so that ids cannot be enumerated, which is why an endpoint that is not yours answers 404 rather than 403. Check the id you sent and the org unit of the credential you sent it with.
See Errors.
Simulate An Event
Fire one synthetic event of the type you name and report every delivery it produced.
POST /v1/webhooks/events/simulate
This is the real fan out, not a dry run. It writes real delivery rows that appear in your delivery listing, count toward retention, retry on the ordinary schedule, and are replayable. A failing receiver will open the circuit exactly as a real event would, and the endpoint then stops receiving real traffic until the circuit closes. The test operation is the one that cannot do this.
curl -X POST https://api.pointservices.com/riskinsight-services-ws/resources/v1/webhooks/events/simulate \
-H "Authorization: Bearer your_access_token_here" \
-H "Content-Type: application/json" \
-d '{
"eventType": "workflow.updated",
"serviceName": "ADV",
"modelName": "ADV-120",
"correlationId": "9c2e14a7-55d0-4b8e-a731-6f0d2b93e185"
}'
It fans out to every entitled endpoint in your org unit, not to one you choose. There is no endpoint id in the request. If three endpoints in your org unit accept this product and topic, all three receive it. Endpoints in other org units, including org units beneath yours, receive nothing from a simulation. To exercise a single endpoint, use the test operation.
It answers the receiver’s question and only that one: would events of this shape arrive at your own endpoints? PPS fires one event per org unit that could produce it for you:
- one attributed to your own org unit, because an endpoint receives its own org unit’s events without a subscription grant; this one is always fired, and fired first;
- one for each other org unit holding a live subscription grant that routes this product and topic to your org unit.
Your org unit comes from your credential, never from the request, and every event is delivered only to endpoints in your org unit. You cannot simulate an event on behalf of another org unit, or aim a simulation at another org unit’s endpoints.
Simulating a finding topic fires that one event only, not the workflow.updated a real finding change is accompanied by. Its envelope omits data.status and carries a random data.findingId, plus a random jattmt_-prefixed data.attachmentId for the two attachment topics. Like the random workflowId and runId, those ids resolve to nothing.
Header Properties
| Property | Value | Required? |
|---|---|---|
| Authorization | Bearer your_access_token_here | true |
| Content-Type | application/json | true |
Request Data Properties
| Property | Description | Type | Required |
|---|---|---|---|
| eventType | The topic to fire, any of the eight. For a workflow topic it also decides data.status: pending, ready or failed. A finding topic omits data.status. | string | yes |
| serviceName | The product’s service name, exactly as you name it when starting a workflow. Together with modelName it decides which endpoints are entitled to the event. | string | yes |
| modelName | The product’s model name. Matched exactly, so a value that names no real model reaches no endpoint and the simulation reports zero deliveries. | string | yes |
| correlationId | The correlation id to stamp on the simulated envelope. A value you supply is delivered unchanged. Omit it and a random UUID is minted, so the envelope’s data.correlationId is never absent. The response’s deliveries entries do not carry it; fetch a delivery to see it in the envelope that was sent. | string | no |
Responses
200
The event was fanned out.
| Property | Description | Type | Notes |
|---|---|---|---|
| eventId | The simulated event’s id, formatted as msg_ followed by a UUID. One event is fired per producing org unit, and this field carries the first of them, the one attributed to your own org unit. | string | always emitted |
| simulated | Always true on this response, and the same value that appears on every delivery row this call created. | boolean | always emitted |
| deliveries | One entry per delivery row the fan out created, one row per entitled endpoint. | array | always emitted, including [] |
| warnings | Advisories about why the fan out reached fewer endpoints than you expected. English sentences, not codes. | array | always emitted, including [] |
deliveries Entry Properties
| Property | Description | Type | Notes |
|---|---|---|---|
| deliveryId | The created delivery’s id. Fetch it to see the envelope that was built and, once attempted, what your server returned. | number | always emitted |
| endpointId | The endpoint this delivery is addressed to. It is also the endpointId you pass when fetching the delivery. | number | always emitted |
| subscriptionId | The subscription grant that entitled this delivery. Absent when the delivery was created on a path that resolved no single grant, most commonly because the endpoint sits in the org unit that produced the event, which needs no grant to receive it. | number | conditional |
| eventId | The event this delivery belongs to, the same value the receiver sees in its webhook-id header. | string | |
| sourceOuGuid | The org unit the simulated event was attributed to: your own org unit for the event PPS always fires, or the granting org unit for each of the others. | string |
Example, no grant routes to you, and two endpoints in your org unit accept the product and topic. Only the event attributed to your own org unit was fired, so both rows carry the one eventId and neither carries a subscriptionId.
{
"eventId": "msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384",
"simulated": true,
"deliveries": [
{
"deliveryId": 80531,
"endpointId": 4711,
"eventId": "msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384",
"sourceOuGuid": "OU41502"
},
{
"deliveryId": 80532,
"endpointId": 4712,
"eventId": "msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384",
"sourceOuGuid": "OU41502"
}
],
"warnings": []
}
When another org unit also holds a grant routing this product and topic to you, it is fired as its own event with its own eventId, and the top-level eventId carries only the first. Read each delivery’s own eventId rather than assuming the top-level one covers them all.
Example, one other org unit, OU10432, holds a grant to yours. Each event reaches the same endpoint of yours, so endpointId repeats while eventId, sourceOuGuid and subscriptionId differ.
{
"eventId": "msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384",
"simulated": true,
"deliveries": [
{
"deliveryId": 80533,
"endpointId": 4711,
"eventId": "msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384",
"sourceOuGuid": "OU41502"
},
{
"deliveryId": 80534,
"endpointId": 4711,
"subscriptionId": 620,
"eventId": "msg_6e3f8b41-7c02-4a95-8d16-b504e9a2f731",
"sourceOuGuid": "OU10432"
}
],
"warnings": []
}
Example, nothing received the event. An empty deliveries array is a real answer rather than a failure, and warnings says what was missing: here, no endpoint in your org unit has declared the topic, and no other org unit routes it to you.
{
"eventId": "msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384",
"simulated": true,
"deliveries": [],
"warnings": [
"No enabled endpoint in your org unit has declared that it accepts event type 'workflow.updated' for product 'ADV'/'ADV-120'. An endpoint receives events produced in its own org unit without any subscription grant, so adding this event type to the endpoint's acceptedEventTypes is all that is required for your own events.",
"No live subscription grant routes product 'ADV'/'ADV-120' and event type 'workflow.updated' to your org unit from any OTHER org unit. Your own events need no grant; a PPS operator must grant this flow from any other org unit whose events you expect to receive."
]
}
The warnings are sentences for a person to read, not codes to match on.
Take a deliveryId from the response and fetch it to see the envelope that was built.
curl -X GET "https://api.pointservices.com/riskinsight-services-ws/resources/v1/webhooks/deliveries/4711/80531" \
-H "Authorization: Bearer your_access_token_here"
400
The request body was malformed or carried a value outside this API’s vocabulary.
401
No credential was presented, or the one presented was rejected.
403
Your credential may not manage webhooks in its own org unit. Simulate takes the org unit from your credential, never from the request, and the refusal comes before any event is fired. A 403 with type: about:blank can also come back before the operation runs, and the platform edge can refuse a request with an HTML body rather than a problem document.
{
"type": "https://pointservices.com/problems/not-authorized",
"title": "Not authorized",
"status": 403,
"detail": "The authenticated identity is not permitted to perform this action.",
"instance": "urn:pps:request:8c41b6e0-25d7-4a39-9f18-3b07d2e54c96"
}
404
No such endpoint, delivery or task is available to this request. This also covers a resource that exists but is not yours, or one you may not act on: the three cases are deliberately indistinguishable so that ids cannot be enumerated, which is why an endpoint that is not yours answers 404 rather than 403. Check the id you sent and the org unit of the credential you sent it with.
See Errors.
Test Versus Simulate
| Test | Simulate | |
|---|---|---|
| What it addresses | One endpoint, named in the path | Every entitled endpoint; there is no endpoint id in the request |
| Delivery rows | Creates none. The response body is the only record, and the attempt never appears in your delivery listing. | Writes real rows that appear in your delivery listing |
| Retries | No, one attempt only | Yes, on the ordinary schedule |
| Counts against the circuit breaker | No. A failed test cannot open it, and a successful one closes it. | Yes, and a failing receiver can open it |
| Needs a subscription grant | No. The endpoint must have declared the topic for the product; an undeclared one is a 400 | No for the event attributed to your own org unit. Each other org unit’s event needs a grant routing it to you, and without one that event is simply not fired |
| How the payload is marked | data.test: true plus a webhook-test: true header | data.test: true in the envelope, with no webhook-test header, and simulated: true on the delivery row |
| Envelope | The same synthetic shape for every topic, with no status | Shaped for the topic: data.status for a workflow topic, a random findingId for a finding topic |
Getting Started uses the test operation for a first end-to-end check.