Webhooks · custom code

Developer reference.

Full schema, response codes, idempotency, and language examples. If you're wiring an existing service or building a bespoke bridge, this is the page you want.

Endpoint

POST https://app.mgmt-os.ai/api/webhooks/inbound/{token}

Where {token} is a per-workspace secret generated in Settings → Webhooks. Tokens begin with the prefix wht_ and carry 192 bits of entropy. Per-workspace isolation is enforced at the database layer — a leaked token from one workspace cannot write to any other workspace.

Authentication — URL or Authorization header

Two ways to present the token. Pick whichever fits your sender. If both are present, the header wins.

URL path — easiest, works with any provider that takes a webhook URL field:

POST https://app.mgmt-os.ai/api/webhooks/inbound/wht_xxx...
Content-Type: application/json

Authorization header — preferred for code you control. The token does not appear in access logs, browser history, or referrer headers:

POST https://app.mgmt-os.ai/api/webhooks/inbound/_
Authorization: Bearer wht_xxx...
Content-Type: application/json

When using the header, the URL path segment is ignored — pass any placeholder (e.g. _).

HMAC signatures (opt-in)

For tokens with signing enabled, sign each request with the per-token signing key (generated alongside the token in Settings → Webhooks). Signing keys begin with whs_. The receiver rejects unsigned requests on a token configured with require_signature=true.

Algorithm — HMAC-SHA256 over the concatenated string {timestamp}.{body} (same shape as Stripe). Both the timestamp and the body are included so a captured signature cannot be replayed with a different body or at a different time.

signature = hex(HMAC-SHA256(signing_key, timestamp + "." + body))

POST /api/webhooks/inbound/wht_xxx...
X-Mgmt-OS-Timestamp: 1719624000
X-Mgmt-OS-Signature: 7e5f8b3c4d2a1f6b... (hex, 64 chars)
Content-Type: application/json

{"source":"internal-crm",...}

The receiver compares signatures in constant time so probe attacks cannot leak the expected value bit-by-bit.

Node.js example:

import { createHmac } from "crypto";

const body = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac("sha256", process.env.MGMT_OS_SIGNING_KEY)
  .update(`${timestamp}.${body}`)
  .digest("hex");

await fetch("https://app.mgmt-os.ai/api/webhooks/inbound/wht_xxx...", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Mgmt-OS-Timestamp": timestamp,
    "X-Mgmt-OS-Signature": signature,
  },
  body,
});

Python example:

import hmac, hashlib, json, time, requests, os

body = json.dumps(payload, separators=(",", ":"))  # canonical
timestamp = str(int(time.time()))
signature = hmac.new(
    os.environ["MGMT_OS_SIGNING_KEY"].encode(),
    f"{timestamp}.{body}".encode(),
    hashlib.sha256,
).hexdigest()

requests.post(
    "https://app.mgmt-os.ai/api/webhooks/inbound/wht_xxx...",
    data=body,
    headers={
        "Content-Type": "application/json",
        "X-Mgmt-OS-Timestamp": timestamp,
        "X-Mgmt-OS-Signature": signature,
    },
    timeout=10,
)

Important: sign the EXACT bytes you send. Do not re-serialize the JSON after signing — whitespace and key ordering matter. The Node example above uses one canonical JSON.stringify for both the body and the HMAC input. The Python example pins separators=(",",":") and reuses the same string.

Timestamp freshness (opt-in)

When a token is configured with require_timestamp=true, the receiver rejects requests whose X-Mgmt-OS-Timestamp header is more than 5 minutes old (and more than 1 minute in the future, allowing for clock skew). Combined with HMAC signing, this prevents capture-and-replay attacks: even if an attacker records a valid signed request off the wire, they cannot resend it five minutes later.

Accepts either unix seconds (a 10-digit number, e.g. 1719624000) or ISO 8601 (e.g. 2026-06-29T12:00:00Z).

Request payload

Send a JSON body. Only source is required.

{
  "source": "your-system-id",              // REQUIRED. Lowercase identifier.
  "event_type": "event.name.lowercase",    // optional. Provider's event name.
  "external_id": "your-system-event-id",   // optional, recommended for idempotency.
  "timestamp": 1719624000,                 // optional. ISO 8601 string or unix seconds.
  "subject_email": "buyer@acme.com",       // optional. For CRM contact join.
  "subject_account": "Acme Corp",          // optional. For CRM account join.
  "summary": "12-min call. Disposition: interested.",
  "data": { /* arbitrary opaque object */ }
}

Field guidance:

  • source — choose a stable lowercase identifier for the originating system. Used for filtering and routing downstream. Avoid changing the value once you start sending events.
  • external_id — your originating system's event ID. Combined with source + workspace_id, this is the idempotency key. Strongly recommended for any event that can be retried.
  • subject_email / subject_account — Management OS uses these to join the event to the appropriate CRM record. Optional but unlocks per-account signal surfacing.
  • summary — rendered directly in AMA and Weekly Brief without further processing. If omitted, Management OS will attempt to synthesize one from data at read time.
  • data — opaque JSON object. Stored as Postgres jsonb. No schema enforcement; query downstream with ad-hoc paths.

Response codes

  • 200 { "id": "...", "status": "stored" } — event written
  • 200 { "status": "duplicate" } — same source + external_id already received in this workspace. Idempotent, safe to retry. Stop retrying.
  • 400 { "error": "missing_source" } — payload missing required source
  • 400 { "error": "invalid_payload" } — body wasn't valid JSON
  • 401 { "error": "invalid_token" } — token unknown, malformed, or revoked
  • 401 { "error": "invalid_signature" } — token requires HMAC, and the X-Mgmt-OS-Signature header is missing or did not match
  • 401 { "error": "stale_timestamp" } — token requires a timestamp, and the X-Mgmt-OS-Timestamp header is missing or older than 5 minutes
  • 413 { "error": "payload_too_large" } — request body exceeds 256KB
  • 429 { "error": "rate_limited", "retry_after_seconds": N } — exceeded per-token or per-IP rate limit. Response carries a Retry-After header in seconds. Honor it and back off.
  • 500 { "error": "server_error" } — database or unexpected. Safe to retry; we log to Sentry.

Idempotency

Management OS deduplicates on the unique tuple (workspace_id, source, external_id). If you send the same event twice (e.g., your retry logic fires), the second POST returns { "status": "duplicate" } with HTTP 200 and does not create a second row.

If you omit external_id, no deduplication is performed — every POST creates a new event row.

Examples

cURL

curl -X POST \
  https://app.mgmt-os.ai/api/webhooks/inbound/wht_xxx... \
  -H "Content-Type: application/json" \
  -d '{
    "source": "internal-crm",
    "event_type": "deal.closed_won",
    "external_id": "deal-12345",
    "subject_email": "buyer@acme.com",
    "subject_account": "Acme Corp",
    "summary": "Closed Acme: $50K",
    "data": { "amount": 50000, "rep_id": "u_42" }
  }'

Node.js (fetch)

const res = await fetch(
  "https://app.mgmt-os.ai/api/webhooks/inbound/wht_xxx...",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      source: "internal-crm",
      event_type: "deal.closed_won",
      external_id: `deal-${dealId}`,
      subject_email: customer.email,
      subject_account: customer.company,
      summary: `Closed ${customer.company}: $${amount}`,
      data: { amount, dealId, repId },
    }),
  }
);
const json = await res.json();
console.log(json.status); // "stored" or "duplicate"

Python (requests)

import requests

resp = requests.post(
    "https://app.mgmt-os.ai/api/webhooks/inbound/wht_xxx...",
    json={
        "source": "internal-crm",
        "event_type": "deal.closed_won",
        "external_id": f"deal-{deal_id}",
        "subject_email": customer.email,
        "subject_account": customer.company,
        "summary": f"Closed {customer.company}: ${amount}",
        "data": {"amount": amount, "deal_id": deal_id, "rep_id": rep_id},
    },
    timeout=10,
)
resp.raise_for_status()
print(resp.json()["status"])  # "stored" or "duplicate"

Retry strategy (recommended)

  • Retry on 5xx responses with exponential backoff (e.g. 1s, 5s, 30s, 5min, 30min, 6hr)
  • Do NOT retry on 4xx responses except 429 (rate limited)
  • On 429, honor the Retry-After header if present
  • Always send external_id so retries are idempotent
  • Cap retries — after N failed attempts (say, 24 hours of backoff), log and surface the failure to your operator

Rate limits

The receiver enforces fixed-window rate limits on every inbound POST. Two buckets, two windows each:

  • Per token — 300 requests/minute, 50,000 requests/day. Protects against a runaway integration or a leaked-token abuse case.
  • Per source IP — 1,000 requests/minute, 200,000 requests/day. Higher than the token limit because shared NAT and cloud egress means multiple legitimate workspaces may share an outbound IP. The IP limit is the obvious-abuse backstop.

IP limits are checked BEFORE token validation, so attackers cycling through invalid token guesses still hit the IP limit and get 429'd.

When you're rate-limited the response is HTTP 429 with a Retry-After header (seconds) and a JSON body containing retry_after_seconds. Honor the header — repeated bursts during the cool-down period extend the window. Founder-cohort customers running normal integrations should never see 429; if you do, something is looping.

Signing key lifecycle

Signing keys are managed alongside tokens in Settings → Webhooks. Lifecycle operations available:

  • Generate — creates a new signing key for an existing token. The plain-text key is shown ONCE; afterwards only a masked indicator is visible. Store the key in your secret manager.
  • Require signature — toggle that flips the token into HMAC-mandatory mode. The receiver returns 401 for unsigned requests from that point on. Cannot be set without a signing key first.
  • Require timestamp — independent toggle that requires a fresh X-Mgmt-OS-Timestamp header. Pair with signing for the strongest guarantee (no replay attacks).
  • Rotate — generates a new key and invalidates the previous one. Use after a suspected compromise. The old key stops working immediately, so update the sender first OR coordinate a brief switchover.
  • Clear — removes the signing key and clears require_signature. Token continues to work in unsigned mode.

Need help?

support@mgmt-os.ai — include the response code, response body, and a sanitized copy of the request body if you can.