Webhooks
Tenant events to your HTTPS endpoint: the body format, the X-Signature (t, v1) signature, verification code, retries and idempotency.
Webhooks notify your systems about events in ImportOS: a deal stage change, a payment received, a document signed, a new lead, a shipment update.
In development: registering a webhook through the API or Desk
Delivery, signing, retries and the delivery log are implemented in the core (@core/services:
createWebhook, rotateWebhookSecret, listDeliveries, redeliverWebhook and others). But there are no
/v1/webhooks endpoints in the API and no screen in Desk yet: they are not in the OpenAPI spec either.
For now the platform operator creates a webhook and hands you the secret once. The format, signature and
behavior below are final.
Events
Event (type) | When |
|---|---|
deal.stage_changed | a deal moved to another stage |
payment.received | a payment was received |
document.signed | a document was signed |
lead.created | a new lead was created |
shipment.updated | a shipment status changed |
You subscribe to a list of types. Event data is public: no margin, lot prices or clients' personal data.
The request
A POST to your URL with Content-Type: application/json. The address must be HTTPS and must not point at private networks; redirects are not followed. The response timeout is 8 seconds.
Headers:
| Header | Value |
|---|---|
X-Signature | t=<unix seconds>,v1=<hex>: the signature |
X-Event-Id | a unique event identifier (equals id in the body) |
User-Agent | RVAuto-Notify/1.0 |
Body:
{
"id": "0b1c2d3e-...",
"type": "deal.stage_changed",
"tenantId": "...",
"createdAt": "2026-10-18T09:30:00.000Z",
"recipient": {},
"message": { "title": "deal.stage_changed", "text": "deal.stage_changed", "buttons": [] },
"data": {
"entity": "deal",
"entityId": "...",
"occurredAt": "2026-10-18T09:29:58.000Z",
"dealId": "...",
"number": "<deal number>",
"from": "won",
"to": "paid_to_auction"
}
}The recipient and message fields are technical: ignore them. Everything useful is in data. Besides the shared entity, entityId and occurredAt, each event has its own fields:
| Event | data fields |
|---|---|
deal.stage_changed | dealId, number, from, to, kind |
payment.received | paymentId, dealId, amount, currency, purpose, paidAt, provider |
document.signed | documentId, dealId, kind, number, signMethod, signedAt |
lead.created | contactId, requestId, source |
shipment.updated | shipmentId, dealId, status, containerNo, vessel, portTo, eta, ata |
Stage values (from, to) are codes from deal stages. Some fields may be absent if the event did not carry them.
The signature
The X-Signature header looks like t=1760000000,v1=<64 hex>. The signature:
v1 = HMAC_SHA256( secret, "<t>.<raw request body>" ) // as hexThe secret looks like whsec_... and is shown once when created or rotated. During rotation the header may carry several v1= values (old and new secret): any one matching is enough.
Verification rules:
- Sign the raw body (the bytes as received), not re-serialized JSON.
- Compare with a constant-time comparison (
timingSafeEqual). - Check that
|now − t| ≤ 300seconds: this rejects replays of intercepted requests. - Remember processed
X-Event-Idvalues and do not process an event twice: delivery is "at least once".
Verification example (Node.js, Express)
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRETS = [process.env.IMPORTOS_WEBHOOK_SECRET!]; // during rotation add the previous one
const TOLERANCE_SEC = 300;
function verify(header: string | undefined, rawBody: string): boolean {
if (!header) return false;
const parts = header.split(',').map((p) => p.trim());
const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2));
const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!Number.isInteger(t) || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SEC) return false;
for (const secret of SECRETS) {
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
for (const s of signatures) {
const got = Buffer.from(s, 'hex');
if (got.length === expected.length && timingSafeEqual(got, expected)) return true;
}
}
return false;
}
const seen = new Set<string>(); // in production: Redis or a table with a TTL
const app = express();
app.post('/importos/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(req.header('x-signature'), raw)) return res.status(401).end();
const event = JSON.parse(raw);
if (seen.has(event.id)) return res.status(200).end(); // duplicate: already handled
seen.add(event.id);
// ... handle event.type / event.data
res.status(200).end();
});Python
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])(A simplified example: during secret rotation the header carries several v1= values, so parse them as a list, as in the Node.js example.)
Response and retries
- Answer with a 2xx code as quickly as possible (move the work to a queue).
- A 4xx error (except 408 and 429) is considered final: the delivery goes straight to
deadwith no retries. It means "you rejected the request", not a failure. - A timeout, 5xx, 408, 429 and network errors are retried with exponential backoff: 1, 2, 4, … minutes (no more than an hour between attempts), until the attempt limit (6 by default) is used up. After that the delivery becomes
dead. - All attempts are written to the delivery log; a manual retry is available to the company owner.
- Do not follow redirects and do not rely on event order: use
occurredAt.