Webhooks Overview
Rhino webhooks notify your systems when something changes on the Rhino side: a prospect becomes eligible to enroll, an application is quoted or declined, a policy is issued or canceled, or a policy goes delinquent.
This page covers outbound webhooks, which Rhino pushes to you. Events flowing the other way — the ones you emit and Rhino subscribes to in order to ingest prospects — are a separate mechanism documented in Partner Webhook Events. The two are independent: the signing, retry, and payload rules below apply only to what Rhino sends you.
If you ingest prospects through partner webhook events, subscribing here is not optional. One-way ingestion gives you no response to read, so these events are the only way to learn that a prospect became eligible and what its enrollment URL is.
How webhooks work
- An event occurs in Rhino (for example, a policy is created).
- Rhino finds every active endpoint for your property owner that is subscribed to that event, and records one delivery per endpoint.
- Each delivery is POSTed to your endpoint URL with a signature header.
- Your server verifies the signature and processes the event.
- Any
2xxresponse marks the delivery as delivered. Anything else is retried.
Subscribing to events
Endpoints are managed through the Partner API. Rhino creates a webhook subscriber for your property owner automatically the first time you create an endpoint, and it holds the signing secret shared with you during onboarding.
POST /partners/{owner_slug}/webhooks/endpoints
Content-Type: application/json
{
"endpoint": {
"name": "Production events",
"destination_url": "https://your-server.com/webhooks/rhino",
"events": ["prospect.ready", "policy.created", "policy.canceled"]
}
}
destination_url must be HTTPS. An endpoint only receives the events listed in its events array, so subscribe explicitly to everything you want. See the Event Reference for the full catalog.
Endpoints have a status of active, suspended, or disabled. Only active endpoints receive deliveries. Set the status via PUT /partners/{owner_slug}/webhooks/endpoints/{id}.
If you need Rhino to send extra static headers with every delivery (an API gateway key, for instance), ask your Partner Success Manager. Custom headers are configured by Rhino and cannot be set through the Partner API. They are merged in before the standard headers below, so they cannot override Content-Type or any X-Webhook-* header.
Payload structure
Every webhook body has exactly two top-level keys:
{
"event": "policy.created",
"data": {
"insurance_policy": {
"id": "pol_a1b2c3",
"policy_number": "RH12345678",
"status": "active",
"pms_type": "yardi",
"pms_property_id": "prop-1",
"pms_unit_id": "unit-101",
"pms_resident_id": "t0001234",
"source_prospect_id": "your-prospect-123"
}
}
}
data contains a single key named for the subject of the event: third_party_prospect, insurance_policy, policy_application, or delinquency. There is no event_id, event_at, or metadata field in the body — event context you might expect there is delivered in headers instead.
Headers
Every delivery includes these headers:
| Header | Description |
|---|---|
Content-Type | Always application/json |
X-Webhook-Event | The event name, matching event in the body |
X-Webhook-Delivery | Delivery ID — stable across retries, use it for idempotency |
X-Webhook-Timestamp | Unix timestamp (seconds) of this attempt |
X-Webhook-Signature | Signature in the form t={timestamp},v1={hex} |
Verifying signatures
The signature is an HMAC-SHA256 of {timestamp}.{raw_request_body}, keyed with your signing secret and hex-encoded. The t= value in the header is the same timestamp as X-Webhook-Timestamp.
You must verify against the raw request body bytes. Parsing the JSON and re-serializing it will produce a different string and the signature will not match.
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const v1 = /v1=([0-9a-f]+)/.exec(signatureHeader)?.[1];
const timestamp = /t=(\d+)/.exec(signatureHeader)?.[1];
if (!v1 || !timestamp) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// timingSafeEqual throws rather than returning false on a length mismatch
if (v1.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
// Express.js — note express.raw, not express.json
app.use('/webhooks/rhino', express.raw({ type: 'application/json' }));
app.post('/webhooks/rhino', (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(rawBody, req.headers['x-webhook-signature'], WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
handleWebhookEvent(req.headers['x-webhook-delivery'], JSON.parse(rawBody));
res.status(200).send('OK');
});
import hmac
import hashlib
import re
from flask import Flask, request, abort
app = Flask(__name__)
def verify_webhook_signature(raw_body, signature_header, secret):
v1 = re.search(r"v1=([0-9a-f]+)", signature_header or "")
timestamp = re.search(r"t=(\d+)", signature_header or "")
if not v1 or not timestamp:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp.group(1)}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(v1.group(1), expected)
@app.route('/webhooks/rhino', methods=['POST'])
def handle_webhook():
raw_body = request.get_data(as_text=True)
if not verify_webhook_signature(raw_body, request.headers.get('X-Webhook-Signature'), WEBHOOK_SECRET):
abort(401)
process_webhook_event(request.headers.get('X-Webhook-Delivery'), request.get_json())
return 'OK', 200
Rhino does not currently reject or re-sign deliveries based on timestamp age, so a retried delivery arriving hours later still carries a valid signature for its own attempt timestamp. If you enforce a freshness window of your own, make it generous enough to accommodate the retry schedule below.
Idempotency
A retried delivery reuses the same X-Webhook-Delivery ID, so store it and skip anything you have already processed:
async function handleWebhookEvent(deliveryId, event) {
if (await db.hasProcessedDelivery(deliveryId)) {
return;
}
await db.recordDelivery(deliveryId, event);
switch (event.event) {
case 'policy.created':
await handlePolicyCreated(event.data.insurance_policy);
break;
// ...
}
}
Note that a single Rhino event fans out to one delivery per subscribed endpoint, so two endpoints subscribed to policy.created receive two deliveries with two different IDs for the same underlying event.
Ordering
Deliveries are queued and retried independently, so events can arrive out of order. A retried policy.created can land after the policy.canceled that followed it.
One action on Rhino's side can also produce several events. Revising a policy emits policy.updated, then policy.documents_updated once the document archive finishes regenerating; if the revision also cancels the policy, policy.canceled is emitted as well. These are separate deliveries with no ordering guarantee between them.
Treat each payload as a statement of current state rather than a step in a sequence. Reconcile against the status field in the payload and what you already hold, and do not drive a state machine off arrival order. When you need certainty, GET the prospect or policy from the Partner API.
Timeouts and retries
| Behavior | Value |
|---|---|
| Connect and read timeout | 10 seconds |
| Success | Any 2xx response |
| Maximum attempts | 25 |
| Backoff | 2^attempt seconds, capped at 1 hour |
| Total retry window | Roughly 15 hours |
| Per-endpoint rate limit | 1,000 deliveries per minute |
Delays grow from 4 seconds up to the 1-hour cap by the eleventh attempt, then stay hourly. After 25 attempts the delivery is marked failed and is not retried automatically.
If your endpoint exceeds the rate limit, the delivery is marked rate_limited and retried in 60 seconds without consuming an attempt.
Endpoint requirements
Your webhook endpoint must:
- Accept
POSTrequests over HTTPS - Respond within 10 seconds — queue slow work rather than doing it inline
- Return a
2xxstatus on success - Be publicly reachable (Rhino does not deliver from a fixed IP range)
Inspecting deliveries
The Partner API exposes delivery history so you can debug without asking Rhino:
# List deliveries, optionally filtered by status
GET /partners/{owner_slug}/webhooks/endpoints/{endpoint_id}/deliveries?status=failed
# Inspect one delivery, including the exact payload sent and failure reason
GET /partners/{owner_slug}/webhooks/endpoints/{endpoint_id}/deliveries/{id}
# Re-enqueue a delivery
POST /partners/{owner_slug}/webhooks/endpoints/{endpoint_id}/deliveries/{id}/retry
Each delivery record reports status (pending, delivering, delivered, retrying, rate_limited, or failed), attempt_number, last_attempted_at, next_retry_at, duration_ms, and last_attempt_failure_reason.
Troubleshooting
Signature validation fails
- Verify you are hashing the raw body, not a re-serialized object
- Extract the hex digest from the
v1=portion — the header is not a bare digest - The header name is
X-Webhook-Signature, and the signed string joins timestamp and body with a period
No deliveries arriving
- Confirm the endpoint status is
activeand the event name is in itseventsarray - Confirm the URL is HTTPS and publicly reachable
- Check the deliveries endpoint above for
failedrecords and theirlast_attempt_failure_reason
Duplicate events
- Expected on retry. Deduplicate on
X-Webhook-Delivery.
Next steps
- Event Reference — every event and its payload