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 withsource+ 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 fromdataat read time.data— opaque JSON object. Stored as Postgresjsonb. No schema enforcement; query downstream with ad-hoc paths.
Response codes
200{ "id": "...", "status": "stored" }— event written200{ "status": "duplicate" }— samesource+external_idalready received in this workspace. Idempotent, safe to retry. Stop retrying.400{ "error": "missing_source" }— payload missing requiredsource400{ "error": "invalid_payload" }— body wasn't valid JSON401{ "error": "invalid_token" }— token unknown, malformed, or revoked401{ "error": "invalid_signature" }— token requires HMAC, and theX-Mgmt-OS-Signatureheader is missing or did not match401{ "error": "stale_timestamp" }— token requires a timestamp, and theX-Mgmt-OS-Timestampheader is missing or older than 5 minutes413{ "error": "payload_too_large" }— request body exceeds 256KB429{ "error": "rate_limited", "retry_after_seconds": N }— exceeded per-token or per-IP rate limit. Response carries aRetry-Afterheader 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
5xxresponses with exponential backoff (e.g. 1s, 5s, 30s, 5min, 30min, 6hr) - Do NOT retry on
4xxresponses except429(rate limited) - On
429, honor theRetry-Afterheader if present - Always send
external_idso 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-Timestampheader. 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.