Skip to main content

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

  1. An event occurs in Rhino (for example, a policy is created).
  2. Rhino finds every active endpoint for your property owner that is subscribed to that event, and records one delivery per endpoint.
  3. Each delivery is POSTed to your endpoint URL with a signature header.
  4. Your server verifies the signature and processes the event.
  5. Any 2xx response 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:

HeaderDescription
Content-TypeAlways application/json
X-Webhook-EventThe event name, matching event in the body
X-Webhook-DeliveryDelivery ID — stable across retries, use it for idempotency
X-Webhook-TimestampUnix timestamp (seconds) of this attempt
X-Webhook-SignatureSignature 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

BehaviorValue
Connect and read timeout10 seconds
SuccessAny 2xx response
Maximum attempts25
Backoff2^attempt seconds, capped at 1 hour
Total retry windowRoughly 15 hours
Per-endpoint rate limit1,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 POST requests over HTTPS
  • Respond within 10 seconds — queue slow work rather than doing it inline
  • Return a 2xx status 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 active and the event name is in its events array
  • Confirm the URL is HTTPS and publicly reachable
  • Check the deliveries endpoint above for failed records and their last_attempt_failure_reason

Duplicate events

  • Expected on retry. Deduplicate on X-Webhook-Delivery.

Next steps