Webhooks
Webhooks let your server receive a signed HTTP POST the moment a screening,
payment, income/employment report, or report-sharing event completes on your
account, instead of polling for it.
Webhooks are an opt-in, server-to-server complement to the other two ways you can learn what happened on a screening:
| Signal | Where it fires | Covers |
|---|---|---|
| Embedded SDK host callbacks | Synchronously, in the applicant's browser | UI-local events only — nothing after the applicant closes the tab |
| Polling | GET /screening/{id}/status, GET /voie/sessions/{id}/status | Everything, but you have to ask |
| Webhooks | Server-to-server, asynchronously | The terminal events listed below, pushed to you |
A webhook is not a replacement for polling — delivery is at-least-once and best-effort (see Delivery semantics), so a production integration should still be able to reconcile via polling if a delivery is ever missed.
Webhooks are not a 1:1 mirror of the SDK's host callbacks. The SDK surface includes UI-local events (a resize, a step transition, a form-field change) that have no server-side counterpart, and the webhook event set is scoped to server-side terminal state transitions only:
| SDK callback | Webhook equivalent |
|---|---|
onReady, onError, onWarning, onResize, onChange, onStepStart/onStepComplete, handlePrintRequested/handleDownloadRequested | none — UI-local |
onReportShareDeclined | none — declining creates no server-side record |
onPaymentSuccess | payment.succeeded |
onScreeningSuccess | screening.completed |
onReportShared | report.shared |
onReportRevoked | report.revoked |
onVerificationSuccess | closest analog: screening.completed or screening.step_up_required, depending on whether 2FA was already satisfied — not a documented 1:1 guarantee, since identity verification is an intermediate step and not always terminal |
onVerificationFailure | closest analog: screening.blocked or screening.step_up_required — same caveat |
onIncomeReportComplete / onIncomeReportFailure | income_report.completed / income_report.failed |
This page covers outbound events Intellirent sends to your server. It is unrelated to inbound Stripe webhooks — there is no client-facing endpoint for receiving Stripe events directly; see Architecture Overview for how payment status reaches your integration today.
Registering an endpoint
Calls in this section use your TSP dashboard bearer token
(Authorization: Bearer <token>) and require the webhooks:manage
permission (TSP role OWNER or ADMINISTRATOR — DEVELOPER cannot manage
webhook endpoints).
You may register at most one endpoint per account. A second attempt
while an endpoint is active or failing returns
409 WEBHOOK_ENDPOINT_EXISTS — this is deliberate: silently replacing an
endpoint would rotate its signing secret out from under a working
integration. Update the existing endpoint with PUT instead.
Your endpoint URL must be https:// and resolve to a public host.
Intellirent will not deliver to http://, to a private/link-local/metadata
IP range, or to a host it cannot resolve at send time.
Create — POST /tsp/webhooks
// Request
{
"url": "https://example.com/webhooks/intellirent",
"enabled_events": ["screening.completed", "payment.succeeded"],
"notify_email": "integrations@example.com"
}
// 201 Response — "secret" is shown ONCE. Store it now; it is never returned again.
{
"id": "8f0a1c2d-3e4f-4a5b-9c6d-7e8f9a0b1c2d",
"url": "https://example.com/webhooks/intellirent",
"status": "active",
"enabled_events": ["screening.completed", "payment.succeeded"],
"notify_email": "integrations@example.com",
"secret": "whsec_YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=",
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"disabled_reason": null,
"created_at": "2026-08-25T00:00:00.000Z",
"updated_at": "2026-08-25T00:00:00.000Z"
}
enabled_events is optional — omit it (or pass null) to subscribe to
every current and future v1 event type. notify_email is optional and is
used only for endpoint-health notifications (see
Endpoint health), never per-delivery.
List — GET /tsp/webhooks
Returns the same shape as the create response, minus secret — the secret
is never included in list or update responses, only in the POST/rotate
responses, once.
Update — PUT /tsp/webhooks/{id}
{ "url": "https://example.com/webhooks/intellirent-v2", "enabled_events": null }
Any of url, enabled_events, notify_email may be supplied; omitted
fields are unchanged. A PUT is also how you reactivate a disabled
endpoint — calling it clears the endpoint's automatic-failure
disablement.
Rotate secret — POST /tsp/webhooks/{id}/rotate
// Request (both fields optional)
{ "previous_valid_for_seconds": 86400 }
// Response — new secret shown ONCE; the old one keeps signing in parallel
// until previous_expires_at.
{
"secret": "whsec_YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=",
"previous_expires_at": "2026-08-26T00:00:00.000Z"
}
Default overlap is 24 hours; maximum is 72 hours. See Rotating secrets for how to use the overlap window without downtime. There is no "hard" rotation that invalidates the old secret immediately — that would drop any delivery already in flight, signed with the old secret.
Delete — DELETE /tsp/webhooks/{id}
204 No Content. This is a soft delete — the endpoint stops receiving
new deliveries, but its delivery history is retained.
Verifying signatures
Headers (all lowercase)
| Header | Value |
|---|---|
webhook-id | The event id, e.g. evt_01J6Q2Z6X7Y8Z9A0B1C2D3E4F5 — your idempotency key |
webhook-timestamp | Unix seconds at send time (this attempt, not when the event occurred) |
webhook-signature | v1,<base64>; space-separated list of one entry per currently-honoured secret, current first |
intellirent-event-type | The event type, e.g. screening.completed — route before parsing the body if convenient |
intellirent-delivery-id | Our internal delivery id — quote this when contacting support |
content-type | application/json |
user-agent | Intellirent-Webhooks/1 |
The signature
key = base64decode(secret with the "whsec_" prefix removed)
signed = `${webhook-id}.${webhook-timestamp}.${rawBody}` // RAW bytes as received
sig = base64(HMAC-SHA256(key, signed))
header = "v1," + sig // one entry per active secret
To verify a delivery:
- Reject if
|now − webhook-timestamp| > 300seconds (replay protection). - Compute the HMAC over the raw request body bytes, decoded as UTF-8 —
not a re-serialized object, and before any body-parsing middleware
touches it. Re-serializing (
JSON.stringify(JSON.parse(body))) changes key order, whitespace, and Unicode escaping, which changes the bytes and breaks the signature even though the data is "the same."- Express: mount
express.raw({ type: 'application/json' })on the webhook route (or the whole app before your JSON parser, scoped to this path) soreq.bodyis aBuffer, not a parsed object. - Flask: use
request.get_data()(bytes), notrequest.get_json()re-dumped. - Spring: read the raw request body directly in a filter or
controller method that runs before Spring's
@RequestBodyJSON conversion, or capture the raw bytes with a content-caching request wrapper before any body access.
- Express: mount
- Accept the delivery if any
v1,...entry in the header matches your computed signature, using a constant-time comparison. During a secret rotation you may have two secrets to try — try both, accept if either matches.
This is intentionally compatible with the Standard Webhooks
spec, so an off-the-shelf verifier
library (e.g. the standardwebhooks package for your language) should work
against it directly, once you supply the raw body and the three headers
above. webhook-signature uses the same v1,<base64>, space-separated,
multi-secret shape the spec defines.
Worked example
secret = whsec_YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=
id = evt_01J6Q2Z6X7Y8Z9A0B1C2D3E4F5
timestamp = 1756162800
body = {"id":"evt_01J6Q2Z6X7Y8Z9A0B1C2D3E4F5","type":"ping","schema_version":1,"occurred_at":"2025-08-26T00:20:00.000Z","environment":"test","account_id":"acct_test000000000000","data":{"endpoint_id":"7f0b1a4e-3c3d-4d7c-9b2e-6a1f2c3d4e5f"}}
webhook-signature: v1,9w/e8sLLAzbGkmxVlsfnxrPYm3KkzfVNH1yZpIlLs+w=
Reference implementation (Node.js)
node:crypto only, no dependencies:
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyWebhookSignature(
secrets: string[], // e.g. [current, previous] during a rotation overlap
webhookId: string,
webhookTimestamp: string,
rawBody: Buffer | string,
signatureHeader: string,
): boolean {
const timestamp = Number(webhookTimestamp);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody.toString('utf8')}`;
const candidates = signatureHeader
.split(' ')
.map((entry) => entry.split(',')[1])
.filter((sig): sig is string => Boolean(sig));
return secrets.some((secret) => {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(signedContent).digest('base64');
return candidates.some((sig) => {
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
});
}
Port the same three steps (tolerance check, HMAC over id.timestamp.body,
constant-time compare against every currently-honoured secret) to any other
language — the algorithm has no Node-specific dependency.
Rotating secrets
POST /tsp/webhooks/{id}/rotate issues a new secret immediately and keeps
the old one valid for an overlap window (previous_valid_for_seconds,
default 24 h, max 72 h).
During the overlap, every delivery is signed with both secrets — the
webhook-signature header carries two v1,... entries, space-separated,
current secret first. To cut over without dropping any deliveries:
- Call rotate. Note the new
secretandprevious_expires_at. - Deploy your receiver accepting either secret — try the new one, fall back to the old one, until you're confident the new secret is live everywhere you verify (e.g. all instances behind a load balancer).
- Once
previous_expires_athas passed, the old secret is no longer honoured server-side; you may remove it from your verifier at any point after that.
Delivery semantics
- At-least-once, unordered. A retry, a manual redelivery (see
Debugging), or an internal reconciliation pass can all
cause the same event to arrive more than once, or out of the order it
occurred. Dedupe on
id(thewebhook-idheader / envelopeid) — do not assume arrival order reflects event order. - Respond
2xxwithin 8 seconds. Do your processing asynchronously (enqueue it, then return) rather than inline in the request handler. Anything else — a non-2xx status, a timeout, or a3xx(redirects are not followed) — counts as a failed attempt. - No backfill. If you register an endpoint after an event already happened, you will not receive it retroactively — silence for pre-registration events is expected, not a bug.
Retry ladder
Attempt 1 is made immediately on the triggering event. If it fails, later
attempts are scheduled on the ladder below (± 10% jitter), until the
delivery is exhausted:
| Attempt | Delay before this attempt | Cumulative time since attempt 1 |
|---|---|---|
| 1 | — (immediate) | 0 |
| 2 | 60 s | ~1 min |
| 3 | 300 s | ~6 min |
| 4 | 1,800 s | ~36 min |
| 5 | 7,200 s | ~2.6 h |
| 6 | 18,000 s | ~7.6 h |
| 7 | 21,600 s | ~13.6 h |
| 8 | 21,600 s | ~19.6 h |
| 9 | 21,600 s | ~25.6 h → exhausted |
Retry defaults may change server-side over time — treat this table as the current default, not a contractual ceiling.
Endpoint health
| Transition | Trigger |
|---|---|
active → failing | 3 consecutive deliveries reach exhausted |
failing → active | any delivery succeeds |
failing → disabled | 72 h with no successful delivery |
disabled is terminal for automation — nothing will retry it further. A
PUT (typically after fixing whatever was rejecting deliveries) reactivates
it.
What you're told: an endpoint state transition may trigger a
notification to notify_email, if set — there is no per-attempt or
per-failure email. Poll GET /tsp/webhooks or
GET /tsp/webhooks/{id}/deliveries (see Debugging) to see
current status and history — don't rely on inbox notifications as your only
signal.
Event reference
All events share this envelope (snake_case on the wire — deliberately
different from the SDK's camelCase callbacks):
{
"id": "evt_01J6Q2Z6X7Y8Z9A0B1C2D3E4F6",
"type": "screening.completed",
"schema_version": 1,
"occurred_at": "2025-08-26T00:19:58.000Z",
"environment": "live",
"account_id": "acct_live000000000000",
"data": { "...": "event-specific, see below" }
}
occurred_at is when the underlying state changed, not when this attempt
was sent (that's webhook-timestamp). data payloads are deliberately
thin — identifiers, enums, timestamps, and occasionally a resource URL —
and never contain PII: the signature authenticates a delivery, it does
not encrypt it, and payload contents land in your own logs and monitoring.
They also never contain the internal screening-id hash used elsewhere in
this API — only the session UUID (session_id).
| Event | Fires when | data shape |
|---|---|---|
screening.completed | Screening reaches its verified/terminal-success state | session_id, user_type (AGENT/PMC/CONSUMER), optional transaction_id (Experian transaction id, omitted if not yet known), optional report_url |
screening.blocked | Screening reaches a terminal blocked state | session_id, user_type, reason_code (our reason code, e.g. IDENTITY_NOT_FOUND — never a raw vendor message) |
screening.step_up_required | Screening needs a second factor before it can complete | session_id, user_type, methods (array of OTP/KBA) |
payment.succeeded | A Stripe PaymentIntent for a screening succeeds | session_id, stripe_payment_intent_id, amount_cents, currency (lowercase ISO-4217, e.g. usd) |
report.shared | A report is shared with another account | report_id, session_id, recipient_account_id, shared_at (ISO-8601 UTC) |
report.revoked | A previously shared report is revoked | report_id, session_id, recipient_account_id, revoked_at (ISO-8601 UTC) |
income_report.completed | An income/employment report session reaches a successful terminal state, on whichever rail (route) produced the report | income_session_id, route (PAYROLL/BANKING), optional screening_session_id (present only when the income report was ordered alongside a screening), optional report_url |
income_report.failed | An income/employment report session reaches a failed terminal state with no rail left to try | income_session_id, route, optional screening_session_id, reason_code (our reason code, e.g. NO_MATCH, VENDOR_ERROR — never a raw vendor message) |
ping | You trigger POST /tsp/webhooks/{id}/test (see Debugging) | endpoint_id |
income_report.completed/.failed fire once per income/employment session
outcome — never once per rail crossing. A session that falls back from
payroll to banking mid-flow does not fire an event at the crossing, only at
whichever rail ultimately succeeds or the point every rail has been
exhausted.
Fetching the resource behind an event
Several events reference a session or report by id — the same session_id
(or income_session_id) carried in the event's data. Fetch the resource
in one call, using the sk_ secret key you already hold — no grant, no
token exchange, no browser JWT:
| Resource | Route |
|---|---|
| Screening status | GET /v1/screenings/{session_id}/status |
| Report | GET /v1/reports/{session_id} — this is the pull; a repeat read is served without re-billing |
| Income session status | GET /v1/income-sessions/{income_session_id}/status |
| Income report | GET /v1/income-sessions/{income_session_id}/report |
curl "https://api.relayscreen.com/v1/reports/$SESSION_ID" \
-H "X-Secret-Key: sk_live_..."
curl "https://api.relayscreen.com/v1/screenings/$SESSION_ID/status" \
-H "X-Secret-Key: sk_live_..."
curl "https://api.relayscreen.com/v1/income-sessions/$INCOME_SESSION_ID/status" \
-H "X-Secret-Key: sk_live_..."
curl "https://api.relayscreen.com/v1/income-sessions/$INCOME_SESSION_ID/report" \
-H "X-Secret-Key: sk_live_..."
Use sk_live_.../sk_test_... to match the event's own environment field —
a sk_test_ key can only read test-environment sessions and vice versa
(a mismatch returns 404, not a cross-environment leak).
Which sessions you can read. You may read a session whose owning account
is your own account, or a direct (one-hop) child of it — the same rule
POST /v1/sessions uses to mint a grant on behalf of a named child account.
Anything else — a sibling account, a grandchild, a session under an
unrelated account entirely — returns 404, never 403: these routes never
disclose whether a session id exists under an account you don't own.
report_url is exactly this shortcut, pre-built. screening.completed
and income_report.completed carry an optional report_url pointing at the
routes above. When present, GET it with X-Secret-Key and you're done —
no need to build the URL yourself. The field is omitted (never an empty
string) on stages where it isn't yet configured; when absent, build the URL
from the event's id and the route table above — the result is identical.
POST /reports/{id}/pdf is a render service for the embedded UI, not a way
to retrieve a report. It requires a JSON body containing report HTML you
have already rendered and returns a short-lived presigned URL to a rendered
PDF, not report data. To get report data after a report.* or
screening.completed event, use GET /v1/reports/{session_id} above.
Environments
You may register one endpoint per account. environment (live or
test) travels in every event's envelope rather than being a
registration-time choice — your one endpoint receives both live and test
mode traffic, distinguished by that field. Filter on environment in your
handler if you only want to act on one.
Debugging
-
Send a test event:
POST /tsp/webhooks/{id}/testtriggers a synchronous, fully-signedpingevent and returns{ delivery_id, http_status?, error_code?, latency_ms }— use this to confirm your receiver is reachable and your verifier accepts a real signature before you rely on production traffic. A test delivery is not retried on failure — a failed ping is a diagnostic answer, not a delivery still owed to you. -
List delivery history:
GET /tsp/webhooks/{id}/deliveries?status=&event_type=&cursor=&limit=(default page size 20, maximum 100) returns{ items: [{ delivery_id, event_id, event_type, occurred_at, status, attempt_count, last_attempt_at, last_http_status, last_error_code, next_attempt_at }], next_cursor }.GET /tsp/webhooks/{id}/deliveries/{deliveryId}returns the same delivery plus its individual attempts (attempted_at,http_status,error_code,latency_ms,response_excerpt). -
Redeliver:
POST /tsp/webhooks/{id}/deliveries/{deliveryId}/redeliverre-sends the same event id (so it's safe for your dedupe logic) —202 Accepted, at most once per 60 seconds per delivery,409if the endpoint is currently disabled. -
response_excerpt: each delivery attempt records the first 4 KB of your response body, sanitized, so you can see what your server actually sent back. -
Egress IP allowlisting: delivery attempts come from a fixed set of IPs per environment. If your receiver sits behind a firewall, allowlist the IPs for the environment your endpoint receives traffic from (one endpoint receives both live and test events, and both currently come from the production IPs):
Environment Source IPs Production (live and test events) 54.158.137.7,44.198.31.105,35.169.11.114Preprod 44.207.117.11Staging 3.235.218.49These change only if the underlying network infrastructure is rebuilt; a change will be announced ahead of time and this table updated.
Versioning
Safe (non-breaking, no action needed on your end):
- A new member added to the event-type set
- A new optional field added to an existing event's
datashape - A new header
Breaking (ships as a new schema_version, opt-in per endpoint — your
existing endpoint keeps receiving the current version until you explicitly
opt in):
- Renaming or removing a field, or changing its type or meaning
- Changing the signature construction or header names
- Making an existing optional field required
There is no v2 schema today; schema_version is 1 for every event. If a
v2 is ever introduced, it is opted into per endpoint, not a global cutover —
your endpoint keeps receiving schema_version: 1 payloads until you update
your registration to request the new version.