Webhooks
Rubi calls your endpoint when interviews move and when integrations sync. Every delivery is signed and retried.
Events#
| Type | When | data |
|---|---|---|
interview.created | An interview and its invite link were created. | { interview } |
interview.started | The trader started recording. | { interview } |
interview.submitted | The trader finished. | { interview } |
interview.report_ready | The AI report is ready for review. | { interview } |
interview.decided | An analyst recorded a decision. | { interview, decision } |
integration.sync_succeeded | A sync run finished. | { integration_id, resource, error } |
integration.sync_failed | A sync run failed. | { integration_id, resource, error } |
ping | Sent by the test button or the test endpoint. | {} |
interview is an InterviewListItem, the same object as in list responses.
Delivery#
Each delivery is a POST with a JSON body:
{
"id": "evt_4c7e1d2a-9b0f-4f7e-8a51-0d2c6b3e9f10",
"type": "interview.report_ready",
"created_at": "2026-09-23T10:00:00Z",
"organization_id": "0b8e...",
"data": { "interview": { "id": "8f0b1c52-...", "status": "report_ready", "risk_score": "medium" } }
}| Header | Value |
|---|---|
Rubi-Event | The event type. |
Rubi-Delivery | A uuid, unique per delivery attempt group. Use it to deduplicate. |
Rubi-Signature | t=<unix>,v1=<hex> |
Verifying signatures#
v1 is HMAC_SHA256(secret, "<t>.<raw body>") in hex, where the key is your full signing secret including the whsec_ prefix. Always compute it over the raw bytes, before any JSON parsing, compare in constant time and reject old timestamps.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.RUBI_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_S = 300;
app.post("/webhooks/rubi", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Rubi-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return res.sendStatus(400);
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
const valid = given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(expected, "hex"));
if (!valid) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "interview.report_ready") {
// enqueue your own processing, then answer fast
}
res.sendStatus(200);
});Retries#
Answer with any 2xx within a few seconds. Otherwise Rubi retries with exponential backoff: after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. Every attempt is visible in the dashboard under Developers, with the status code and response body, and can be retried by hand.
Managing endpoints#
Endpoints can be managed from the dashboard or with a key holding webhooks:manage. Secrets are shown once, on creation and rotation.
GET/v1/webhookswebhooks:manage
Returns { data: WebhookEndpoint[] }
GET/v1/webhooks/eventswebhooks:manage
Returns { data: { type, description }[] }
POST/v1/webhookswebhooks:manage
Returns WebhookCreated
Body { "url": "https://...", "events": ["interview.report_ready"] }. Returns the endpoint and its secret.
PATCH/v1/webhooks/{id}webhooks:manage
Returns WebhookEndpoint
Update url, events or is_active.
DELETE/v1/webhooks/{id}webhooks:manage
POST/v1/webhooks/{id}/testwebhooks:manage
Returns WebhookDelivery
POST/v1/webhooks/{id}/rotate-secretwebhooks:manage
Returns WebhookCreated
GET/v1/webhooks/{id}/deliverieswebhooks:manage
Returns Page<WebhookDelivery>
POST/v1/webhooks/deliveries/{deliveryId}/retrywebhooks:manage
Returns WebhookDelivery