Skip to content

Webhooks

Rubi calls your endpoint when interviews move and when integrations sync. Every delivery is signed and retried.

Events#

TypeWhendata
interview.createdAn interview and its invite link were created.{ interview }
interview.startedThe trader started recording.{ interview }
interview.submittedThe trader finished.{ interview }
interview.report_readyThe AI report is ready for review.{ interview }
interview.decidedAn analyst recorded a decision.{ interview, decision }
integration.sync_succeededA sync run finished.{ integration_id, resource, error }
integration.sync_failedA sync run failed.{ integration_id, resource, error }
pingSent 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:

json
{
  "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" } }
}
HeaderValue
Rubi-EventThe event type.
Rubi-DeliveryA uuid, unique per delivery attempt group. Use it to deduplicate.
Rubi-Signaturet=<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.

node
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.

Deliveries can arrive more than once or out of order. Deduplicate on the event id and read the latest state from the API when order matters.

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