Skip to main content

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:

SignalWhere it firesCovers
Embedded SDK host callbacksSynchronously, in the applicant's browserUI-local events only — nothing after the applicant closes the tab
PollingGET /screening/{id}/status, GET /voie/sessions/{id}/statusEverything, but you have to ask
WebhooksServer-to-server, asynchronouslyThe 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 callbackWebhook equivalent
onReady, onError, onWarning, onResize, onChange, onStepStart/onStepComplete, handlePrintRequested/handleDownloadRequestednone — UI-local
onReportShareDeclinednone — declining creates no server-side record
onPaymentSuccesspayment.succeeded
onScreeningSuccessscreening.completed
onReportSharedreport.shared
onReportRevokedreport.revoked
onVerificationSuccessclosest 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
onVerificationFailureclosest analog: screening.blocked or screening.step_up_required — same caveat
onIncomeReportComplete / onIncomeReportFailureincome_report.completed / income_report.failed
Payment webhook, different direction

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 ADMINISTRATORDEVELOPER 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)

HeaderValue
webhook-idThe event id, e.g. evt_01J6Q2Z6X7Y8Z9A0B1C2D3E4F5 — your idempotency key
webhook-timestampUnix seconds at send time (this attempt, not when the event occurred)
webhook-signaturev1,<base64>; space-separated list of one entry per currently-honoured secret, current first
intellirent-event-typeThe event type, e.g. screening.completed — route before parsing the body if convenient
intellirent-delivery-idOur internal delivery id — quote this when contacting support
content-typeapplication/json
user-agentIntellirent-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:

  1. Reject if |now − webhook-timestamp| > 300 seconds (replay protection).
  2. 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) so req.body is a Buffer, not a parsed object.
    • Flask: use request.get_data() (bytes), not request.get_json() re-dumped.
    • Spring: read the raw request body directly in a filter or controller method that runs before Spring's @RequestBody JSON conversion, or capture the raw bytes with a content-caching request wrapper before any body access.
  3. 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:

  1. Call rotate. Note the new secret and previous_expires_at.
  2. 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).
  3. Once previous_expires_at has 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 (the webhook-id header / envelope id) — do not assume arrival order reflects event order.
  • Respond 2xx within 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 a 3xx (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:

AttemptDelay before this attemptCumulative time since attempt 1
1— (immediate)0
260 s~1 min
3300 s~6 min
41,800 s~36 min
57,200 s~2.6 h
618,000 s~7.6 h
721,600 s~13.6 h
821,600 s~19.6 h
921,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

TransitionTrigger
activefailing3 consecutive deliveries reach exhausted
failingactiveany delivery succeeds
failingdisabled72 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).

EventFires whendata shape
screening.completedScreening reaches its verified/terminal-success statesession_id, user_type (AGENT/PMC/CONSUMER), optional transaction_id (Experian transaction id, omitted if not yet known), optional report_url
screening.blockedScreening reaches a terminal blocked statesession_id, user_type, reason_code (our reason code, e.g. IDENTITY_NOT_FOUND — never a raw vendor message)
screening.step_up_requiredScreening needs a second factor before it can completesession_id, user_type, methods (array of OTP/KBA)
payment.succeededA Stripe PaymentIntent for a screening succeedssession_id, stripe_payment_intent_id, amount_cents, currency (lowercase ISO-4217, e.g. usd)
report.sharedA report is shared with another accountreport_id, session_id, recipient_account_id, shared_at (ISO-8601 UTC)
report.revokedA previously shared report is revokedreport_id, session_id, recipient_account_id, revoked_at (ISO-8601 UTC)
income_report.completedAn income/employment report session reaches a successful terminal state, on whichever rail (route) produced the reportincome_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.failedAn income/employment report session reaches a failed terminal state with no rail left to tryincome_session_id, route, optional screening_session_id, reason_code (our reason code, e.g. NO_MATCH, VENDOR_ERROR — never a raw vendor message)
pingYou 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:

ResourceRoute
Screening statusGET /v1/screenings/{session_id}/status
ReportGET /v1/reports/{session_id} — this is the pull; a repeat read is served without re-billing
Income session statusGET /v1/income-sessions/{income_session_id}/status
Income reportGET /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.

Not a fetch route

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}/test triggers a synchronous, fully-signed ping event 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}/redeliver re-sends the same event id (so it's safe for your dedupe logic) — 202 Accepted, at most once per 60 seconds per delivery, 409 if 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):

    EnvironmentSource IPs
    Production (live and test events)54.158.137.7, 44.198.31.105, 35.169.11.114
    Preprod44.207.117.11
    Staging3.235.218.49

    These 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 data shape
  • 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.