ImportOS · Docs

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_changeda deal moved to another stage
payment.receiveda payment was received
document.signeda document was signed
lead.createda new lead was created
shipment.updateda 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:

HeaderValue
X-Signaturet=<unix seconds>,v1=<hex>: the signature
X-Event-Ida unique event identifier (equals id in the body)
User-AgentRVAuto-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:

Eventdata fields
deal.stage_changeddealId, number, from, to, kind
payment.receivedpaymentId, dealId, amount, currency, purpose, paidAt, provider
document.signeddocumentId, dealId, kind, number, signMethod, signedAt
lead.createdcontactId, requestId, source
shipment.updatedshipmentId, 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 hex

The 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:

  1. Sign the raw body (the bytes as received), not re-serialized JSON.
  2. Compare with a constant-time comparison (timingSafeEqual).
  3. Check that |now − t| ≤ 300 seconds: this rejects replays of intercepted requests.
  4. Remember processed X-Event-Id values 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 dead with 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.

On this page