DEVELOPER GUIDE
Beta
Receiving Deliveries
- Overview
- The envelope
- Responding
- Idempotency
- Ordering
- Entitlement is decided on the workflow
- Retries and the circuit breaker
- Keeping test traffic out of production handling
- Handling API errors in your integration
- What to read next
Overview
A delivery is one attempt to POST one event to one endpoint. Your server receives an HTTP POST with a JSON body and the Standard Webhooks signature headers, verifies it, answers with a 2xx, and does the work.
This guide covers what the body carries, what to answer, and what PPS does when your server does not answer at all.
The envelope
Once a delivery verifies, parse the body. It carries these four keys today, and may carry more later.
This run carries a correlationId, because one was supplied when it started. The key is absent when you supply none.
{
"type": "workflow.updated",
"eventId": "msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350",
"timestamp": "2026-08-28T14:26:03Z",
"data": {
"workflowId": "wfpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92",
"runId": "wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92",
"correlationId": "9c2e14a7-55d0-4b8e-a731-6f0d2b93e185",
"serviceName": "ADV",
"modelName": "ADV-120",
"status": "ready"
}
}
type is the topic. eventId is the stable id of the business event and carries the same value as the webhook-id header. timestamp is an RFC 3339 UTC instant with a trailing Z; fractional seconds appear only when they are not zero, so parse it with a library rather than by slicing the string. data carries the event itself.
The two ids in data
workflowId is the workflow — the unit of work you started, and the id to key your integration on. It is prefixed wfpop_.
runId is the individual execution that changed. Usually that is the workflow itself, in which case the two ids name the same package; it is a distinct value when a child run such as a reconfirm moved. It is prefixed wfrunpop_, is always present, and is descriptive only — no endpoint takes one.
Both prefixes are part of the published contract and will not change, so you can rely on their shape when matching or storing these values.
The findings API takes this workflowId as-is — no stripping needed. Pass the envelope value straight through, prefix and all:
curl -X GET "https://api.pointservices.com/riskinsight-services-ws/resources/v1/findings/workflows/wfpop_8f14e45f-ceea-467a-9c1a-2b6d3e7f4a91/findings" \
-H "Authorization: Bearer your_access_token_here"
correlationId
correlationId belongs to the run, not the workflow. It is the value you supplied when starting that run, so it is your own handle on one execution and it goes with runId. It is the only value on the envelope that came from you, and it is present only when you supplied one.
What the envelope does not carry
There is no loan reference and no borrower details. The envelope tells you which workflow changed and how. The loan association is yours already, through the correlationId you supplied and through workflowId. For the result itself, read the findings for the workflow; for its state, read the workflow.
Parse leniently
Fields are added to data over time, and absent values are omitted rather than sent as null. Ignore keys you do not recognize, at the top level as well as inside data. The four top-level keys above are the ones PPS sends today, so requiring type and eventId is safe — eventId is your idempotency key and type is how you route. Treat everything inside data as optional. A delivery replayed from before a field existed will not carry it, because a replay sends the stored bytes again verbatim.
correlationId is the one you will meet first: it is present only when you supplied one when starting the run, so a parser that requires it breaks on every run started without one.
Responding
Answer within 30 seconds, and preferably within one. PPS allows 15 seconds to open the connection and 30 seconds between bytes once it is open. An attempt that exceeds either is abandoned and recorded as a transport failure, and the delivery then retries as if your server had been down. These are the same bounds the test operation reports, because a test is the real delivery path run synchronously.
Do not treat 30 seconds as a budget to spend. Return a 2xx as soon as you have verified the signature and durably recorded the event, then do the real work asynchronously. A handler that stays inside the limit today will exceed it the first time a dependency is slow, and every second you hold the connection open is a second PPS spends waiting on you instead of delivering your other events.
| Your response | What PPS does |
|---|---|
| Any 2xx | The delivery is DELIVERED. The failure run resets to zero. |
| Any 3xx | Counts as a failure. The delivery retries. |
429 Too Many Requests | Counts as a failure. The delivery retries, and a Retry-After you send is honored in place of the usual interval. |
| Any 5xx | Counts as a failure. The delivery retries. |
| No response, or a transport error | Counts as a failure. The delivery retries. |
410 Gone | Ends the delivery on this response. It is FAILED with no further attempt, and the circuit opens. |
| Any other 4xx | Ends the delivery on this response. It is FAILED with no further attempt. |
Anything outside 2xx is a failure, but only 3xx, 429, 5xx and transport failures are retried.
A 4xx other than 429 ends the delivery on the first response, with no retry. An identical retry of a request your server called unacceptable cannot become acceptable, so PPS does not attempt one. If you answer 400 to a payload your parser chokes on, you get exactly one attempt and no window in which to fix the parser — return a 5xx for a fault on your side that you intend to recover from, and reserve 4xx for a request you will never accept.
If your handler cannot authorize a delivery, that is a verification failure — reject it before you accept the body, rather than answering 2xx and dropping it.
Idempotency
Delivery is at least once, so the same event can arrive more than once and denotes the same event every time. Deduplicate on the event id, which reaches you in two places carrying the same value: the webhook-id header and the eventId field of the body. It is stable across every attempt of one business event, including across a replay.
A dedupe check on the header lets you discard a repeat without parsing the body.
const seen = new Set(); // In production, use a durable store with a TTL.
function shouldProcess(webhookId) {
if (seen.has(webhookId)) {
return false; // A redelivery of an event already handled.
}
seen.add(webhookId);
return true;
}
// Inside your verified handler:
const webhookId = req.get('webhook-id');
if (shouldProcess(webhookId)) {
enqueue(JSON.parse(req.body.toString('utf8')));
}
return res.status(200).json({ received: true });
Answer 2xx either way. A repeat you have already handled is a success, not a failure.
Repeats are not duplicates
workflow.updated may fire several times for one workflowId, and more than once for a single runId, each time genuinely new data becomes available. Each is a distinct event with its own webhook-id, so a consumer deduplicating on webhook-id processes each of them. Only a repeat of the same webhook-id is a redelivery.
Ordering
Events are not guaranteed to arrive in the order they occurred.
Neither topic implies the other follows, and neither is terminal. A run that failed can later be updated, and a run can be updated more than once. Do not build a state machine that assumes a sequence.
There is also no completion event, by design. Once a run is ready its results are available to fetch, and nothing further happens that an integration needs to know about, so act on workflow.updated rather than waiting for something that follows it.
Entitlement is decided on the workflow
Entitlement is keyed on the root workflow’s product and the org unit that started it, so an entitlement for the workflow you started extends to every run inside it — including a reconfirm placed later by another org unit, which is announced to the endpoints entitled by the tenant the workflow originated in. An entitlement for only a sub-product of a workflow matches nothing and delivers nothing.
Retries and the circuit breaker
A delivery that fails on a retryable response is attempted again on the schedule below. Any of three things opens the endpoint’s circuit: a run of consecutive failures, your endpoint answering 410 Gone, or a single delivery exhausting its full retry schedule.
A run of consecutive failures means five failed attempts in a row with no success between them. The unit is the attempt, not the delivery, so one delivery retried five times without succeeding opens the circuit on its own. There is no time window, and the run may span minutes or days: five consecutive failures mean the same thing whatever their spacing, which is what lets a low-volume endpoint be detected as broken at all.
While the circuit is OPEN, deliveries are held rather than dropped. Nothing is lost, and they are attempted again once it closes. Five minutes after the circuit opens PPS admits one delivery as a probe, one in flight at a time, so a broken endpoint receives roughly one request every five minutes rather than a retry storm. If the probe succeeds the circuit closes immediately and the held deliveries resume.
A success resets the failure run to zero, so an endpoint that fails intermittently but succeeds in between never accumulates five in a row.
The retry schedule
A delivery that fails gets ten attempts in total, including the first. If all ten fail, the delivery becomes FAILED and stops.
| Attempt | Sent after the previous one | Elapsed since the first |
|---|---|---|
| 1 | immediately | 0s |
| 2 | 5 seconds | 5s |
| 3 | 5 minutes | 5m 5s |
| 4 | 30 minutes | 35m 5s |
| 5 | 2 hours | 2h 35m |
| 6 | 5 hours | 7h 35m |
| 7 | 10 hours | 17h 35m |
| 8 | 14 hours | 31h 35m |
| 9 | 20 hours | 51h 35m |
| 10 | 24 hours | 75h 35m |
That is about 75 hours — a little over three days — from the first attempt to exhaustion, so a consumer you repair within three days loses nothing.
Treat these intervals as lower bounds, not appointments. PPS spreads retries across a window so that every endpoint recovering from the same outage is not hit at the same instant, which adds up to 20% to each interval of five minutes or longer. Attempts land a little later than the table says, never earlier, so in the worst case the full chain runs closer to four days. Alert on a delivery still RETRYING after your own tolerance, not on an attempt arriving a minute late.
Read the live state from the delivery itself rather than computing it: status moves through RETRYING to FAILED, and nextAttemptDateTime is present while another attempt is scheduled and absent once none is.
Some failures skip the schedule
A 410 Gone, and any other 4xx except 429, ends the delivery on the first response. Only 3xx, 429, 5xx and transport failures are retried. If you answer 429 with a Retry-After, PPS honors it in place of the interval above, but it still consumes one of the ten.
Attempts you never answered do not count
A delivery held back because the circuit is open, or parked because the endpoint was disabled or its domain lost authorization, spends no attempt — nothing was sent to you. Those deliveries resume with their budget intact.
A replay restores the full budget
Replaying a FAILED or PARKED delivery enqueues a fresh attempt sequence starting from the first attempt, with all ten available again. It is not a continuation of the exhausted one, and the earlier attempts remain in the delivery’s history.
A bulk replay against a consumer that is still broken buys another full chain of up to ten attempts, over another three days, for every delivery it queues. Fix the consumer first, then replay.
Nothing is lost while you are fixing a consumer. A FAILED delivery stays replayable for as long as its row exists, which is the delivery retention window, which is 90 days, so the repair window is bounded by retention rather than by the retry schedule. Recover with a bulk replay over the affected window. See Troubleshooting.
Endpoints API Reference has the full circuit breaker table and the notReceivingReason vocabulary.
Keeping test traffic out of production handling
A test delivery is recognizable three ways, any one of which is enough.
data.test is true and never appears on a production envelope. The request carries a webhook-test: true header, which lets you reject test traffic at the edge without parsing the body at all. And workflowId and runId on a test envelope carry their normal prefixes but are random and resolve to nothing on any PPS API.
app.post('/pps/orders', express.raw({ type: 'application/json' }), (req, res) => {
if (req.get('webhook-test') === 'true' && process.env.NODE_ENV === 'production') {
// Acknowledge so the test reports success, but do no production work.
return res.status(200).json({ received: true, ignored: 'test' });
}
// Verify, then handle normally.
});
Simulated deliveries are different. They carry simulated: true on the delivery row but are real in every other respect: signed the same way, retried on the same schedule, counted against the circuit breaker, and replayable.
Handling API errors in your integration
The calls you make back to PPS — listing deliveries, replaying one, editing an endpoint — return RFC 9457 problem documents on failure. Branch on type, and fall through to a default for a type you do not recognize so a new one does not break your client.
import requests
BASE = "https://api.pointservices.com/riskinsight-services-ws/resources"
PROBLEMS = "https://pointservices.com/problems/"
def call_webhooks_api(token, method, path, body=None):
response = requests.request(
method,
f"{BASE}{path}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=body,
timeout=60,
)
if response.ok:
return response.json()
problem = response.json()
problem_type = problem.get("type", "about:blank")
# Every 400 carries `about:blank`, so there is nothing to branch on but the
# status; `detail` says what went wrong but is prose, not a stable code.
if response.status_code == 400:
raise ValueError(problem.get("detail", "The request body is invalid."))
# A 403 IS typed. It means the org unit you named is not one you may act on.
if problem_type == PROBLEMS + "not-authorized":
raise PermissionError(problem.get("detail", "Not permitted."))
if problem_type == PROBLEMS + "invalid-token" or response.status_code == 401:
raise PermissionError("Obtain a fresh access token and retry.")
if problem_type == PROBLEMS + "not-found":
raise LookupError(problem.get("detail", "No such resource."))
if problem_type == PROBLEMS + "concurrent-modification":
raise RuntimeError("Read the endpoint again and retry.")
if problem_type == PROBLEMS + "conflict":
raise RuntimeError(problem.get("detail", "The endpoint's state refuses this."))
if problem_type == PROBLEMS + "too-many-requests":
raise RuntimeError("Rate budget spent. Retry in about a second.")
raise RuntimeError(
f"Unrecognized problem type {problem_type} "
f"(status {problem.get('status')}, instance {problem.get('instance')})"
)
Never branch on the text of title or detail. Both are written for a person to read and neither is a stable code. Every 400 carries about:blank, so for those the status is what you branch on; a 403 does carry a dedicated type.
Errors lists every problem type this API emits.
What to read next
- Troubleshooting — when an event does not arrive.
- Deliveries API Reference — inspecting and replaying delivery rows.