CRM_WEBHOOK_INTEGRATION — Tenant Developer Reference¶
Audience: The tenant's engineering team wiring their dialer, CRM, and payment processor to Floor OS. Related: PILOT_SCOPE.md (what's in scope), ONBOARDING_CHECKLIST.md (which step this satisfies — Day 3-4 channel wiring).
1. Overview¶
Floor OS accepts portfolio updates from your CRM via webhook. Your dialer calls our gate API before every contact attempt and POSTs the outcome back to one of four webhook adapters. Your payment processor calls our payment gate before authorizing. Every call writes to the unified compliance_event_log, which is the table an examiner reads to reconstruct an account's history.
Two directions of traffic:
- You → us: contact-check (before acting), contact-outcome (after acting), account snapshots (portfolio sync).
- Us → you: nothing in pilot v1. All traffic is initiated by you.
Base URL: https://api.flooros.com (production). https://api.staging.flooros.com (staging).
2. Authentication¶
All requests use the X-API-Key header. Keys are tenant-scoped. A key from tenant A cannot query tenant B's accounts — the server cross-checks the tenant_id in the request body against the key's tenant and returns 403 if they differ.
X-API-Key: flooros_live_7f3a8b1c9e2d4f5a6b8c0d1e2f3a4b5c
Content-Type: application/json
Issue keys in Admin → API Keys. Rotate via the same UI — old key continues working for 24 hours after rotation to avoid mid-deploy outages.
Webhook inbound signature verification uses a separate per-provider HMAC secret. See § 7.
3. Endpoints¶
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/compliance/contact-check |
Pre-contact gate. Call before every dial / SMS / email / letter. |
| POST | /api/v1/compliance/contact-outcome |
First-party outcome receipt. Use when your dialer can POST clean JSON directly. |
| POST | /api/v1/webhooks/five9/{tenant_id} |
Five9 native disposition webhook. |
| POST | /api/v1/webhooks/twilio/{tenant_id} |
Twilio status callback (form-encoded). |
| POST | /api/v1/webhooks/convoso/{tenant_id} |
Convoso disposition webhook. |
| POST | /api/v1/webhooks/generic/{tenant_id} |
Any dialer that can post our documented JSON shape. |
| POST | /api/upload |
Bulk portfolio upload (CSV / XLSX / ZIP multipart). Returns upload_id. |
| POST | /api/upload/confirm?upload_id=... |
Parse + load a previously uploaded file; runs intake compliance scan. |
| GET | /api/v1/ingestion/{upload_id}/intake-report |
Retrieve the full intake compliance scan. |
| GET | /api/v1/compliance/events?account_id=... |
Read back the full event chain for one account. |
| GET | /api/v1/compliance/contact-outcomes?audit_id=... |
Read back outcomes keyed on audit_id. |
Note:
POST /api/v1/accounts/syncis reserved but not yet implemented in pilot v1. Use the ingestion upload path for portfolio sync.
3.1 POST /api/v1/compliance/contact-check¶
Request:
{
"tenant_id": "acme-collections",
"account_id": "A-0000123456",
"channel": "voice",
"phone_number": "+13105550123",
"agent_id": "agent_427"
}
channel must be one of voice | sms | email | letter. phone_number and agent_id are optional but strongly recommended — they end up in the audit trail.
Response — allowed:
{
"allowed": true,
"reasons": [],
"statutes": [],
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"expires_at": "2026-04-23T17:35:12.481Z"
}
Response — blocked:
{
"allowed": false,
"reasons": [
"Account has active bankruptcy stay (Chapter 13, filed 2026-02-14)",
"Outside FDCPA calling hours for state CA (local time 21:14)"
],
"statutes": [
"11 USC 362 (Automatic Stay)",
"15 USC 1692c(a)(1) (FDCPA Hours)"
],
"audit_id": "ccs_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7",
"expires_at": "2026-04-23T17:35:12.481Z"
}
The audit_id is the key you carry forward in the call record. The decision is valid for 5 minutes (expires_at). Re-check after that — a bankruptcy filed mid-shift must not be silently ignored.
3.2 POST /api/v1/compliance/contact-outcome¶
First-party outcome receipt. Use this when your dialer posts clean JSON under your own orchestration (as opposed to the native webhook adapters).
Request:
{
"tenant_id": "acme-collections",
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"account_id": "A-0000123456",
"channel": "voice",
"outcome": "placed_rpc",
"disposition": "RIGHT_PARTY_CONTACT",
"duration_s": 147,
"notes": "Consumer agreed to payment plan, follow-up scheduled.",
"occurred_at": "2026-04-23T17:32:41Z"
}
Valid outcome values:
placed_no_answer, placed_rpc, placed_voicemail, placed_busy,
blocked_by_us, blocked_by_tenant, completed_with_payment,
callback_scheduled, other
Valid channel values: voice, sms, email, letter, other.
Response:
{
"status": "recorded",
"id": 82914,
"tenant_id": "acme-collections",
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"outcome": "placed_rpc",
"occurred_at": "2026-04-23T17:32:41Z"
}
Duplicate (same audit_id + outcome + occurred_at) returns "status": "duplicate" with the existing row id. Safe to retry.
3.3 POST /api/v1/webhooks/five9/{tenant_id}¶
Configure Five9's disposition webhook to point here. We read these fields from Five9's payload:
{
"call_id": "5f9-call-8827491",
"session_id": "sess-abc",
"account_id": "A-0000123456",
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"agent_id": "agent_427",
"disposition_name": "RIGHT_PARTY_CONTACT",
"duration_seconds": 147,
"timestamp": "2026-04-23T17:32:41Z",
"wrap_up_notes": "Consumer agreed to payment plan."
}
We map disposition_name to our outcome enum (RIGHT_PARTY_CONTACT → placed_rpc, NO_ANSWER → placed_no_answer, VOICEMAIL → placed_voicemail, BUSY → placed_busy, PAYMENT / PROMISE_TO_PAY → completed_with_payment, CALLBACK → callback_scheduled, DNC → blocked_by_us). Unmapped dispositions land as other with the original string preserved in disposition.
Signature header options (first present wins): X-Five9-Signature, X-Hook-Signature, X-Webhook-Signature, X-Signature.
3.4 POST /api/v1/webhooks/twilio/{tenant_id}¶
Twilio sends application/x-www-form-urlencoded. Set this URL as the Status Callback URL on your voice resource.
Example form body (parsed):
CallSid=CAabcdef1234567890abcdef1234567890
AccountIdentifier=A-0000123456
AuditId=ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
CallStatus=completed
AnsweredBy=human
CallDuration=147
Timestamp=2026-04-23T17:32:41Z
Status mapping: completed / answered / in-progress → placed_rpc, no-answer → placed_no_answer, busy → placed_busy, failed / canceled → other. If AnsweredBy starts with machine, outcome is forced to placed_voicemail.
Pass AuditId in the original TwiML <Dial> as a custom parameter so it makes it into the status callback. If you cannot, we synthesize an audit_id as twilio:{tenant_id}:{CallSid} for idempotency; the tradeoff is that the pre-dial gate decision cannot be linked to this outcome.
Signature header: X-Twilio-Signature (Twilio's own full-URL HMAC) or X-Webhook-Signature / X-Signature for the simpler shared-secret mode we support.
3.5 POST /api/v1/webhooks/convoso/{tenant_id}¶
Convoso disposition webhook payload:
{
"lead_id": "A-0000123456",
"account_id": "A-0000123456",
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"call_id": "convoso-call-992184",
"status": "A",
"status_name": "Answered",
"disposition": "SALE",
"call_length": 147,
"call_date": "2026-04-23 17:32:41",
"agent_comments": "Consumer agreed to payment plan."
}
Status mapping: A / XFER → placed_rpc, NA / NO_ANSWER → placed_no_answer, AA / AM / VM → placed_voicemail, B / BUSY → placed_busy, SALE / PAID / PTP → completed_with_payment, CB / CALLBK → callback_scheduled, DNC → blocked_by_us.
Signature header: X-Convoso-Signature, X-Webhook-Signature, or X-Signature.
3.6 POST /api/v1/webhooks/generic/{tenant_id}¶
For any dialer not in the list above. Post JSON matching our canonical shape directly.
Request:
{
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"account_id": "A-0000123456",
"channel": "voice",
"outcome": "placed_rpc",
"disposition": "right_party_contact",
"duration_s": 147,
"notes": "Consumer agreed to payment plan.",
"occurred_at": "2026-04-23T17:32:41Z",
"external_call_id": "your-dialer-call-id-12345"
}
Required: audit_id, outcome, occurred_at. If audit_id is absent we synthesize generic:{tenant_id}:{external_call_id}.
Signature header: X-Webhook-Signature, X-Signature, or X-Hook-Signature.
4. Sample Payloads — Portfolio Sync¶
4.1 New account ingestion (POST /api/v1/accounts/sync)¶
{
"tenant_id": "acme-collections",
"event": "account.created",
"occurred_at": "2026-04-23T14:02:11Z",
"account": {
"account_id": "A-0000123456",
"portfolio_id": "P-auto-2026Q1",
"consumer": {
"first_name": "Jane",
"last_name": "Doe",
"address_state": "CA",
"address_zip": "90210",
"date_of_birth": "1984-07-12",
"ssn_last4": "4321"
},
"phones": [
{ "number": "+13105550123", "type": "mobile", "tcpa_consent": false },
{ "number": "+13105550199", "type": "landline", "tcpa_consent": true }
],
"emails": [
{ "address": "jane.doe@example.com", "opt_in": true }
],
"debt": {
"original_creditor": "Example Auto Finance",
"balance_cents": 487332,
"first_delinquency_date": "2023-11-02",
"last_payment_date": "2024-06-14",
"debt_type": "auto_loan"
},
"flags": {
"bankruptcy_stay": false,
"cease_desist": false,
"attorney_rep": false,
"deceased": false,
"fraud": false,
"scra_active": false,
"validation_sent": false
}
}
}
Response:
{
"status": "ingested",
"account_id": "A-0000123456",
"intake_scan": {
"blocked": false,
"warnings": ["no_tcpa_consent_on_mobile"]
}
}
4.2 Account status update — cease flag added¶
{
"tenant_id": "acme-collections",
"event": "account.flag_added",
"occurred_at": "2026-04-23T18:11:04Z",
"account_id": "A-0000123456",
"flag": "cease_desist",
"effective_at": "2026-04-23T18:11:04Z",
"source": "consumer_letter",
"notes": "Cease-and-desist letter received via mail 2026-04-22, scanned into CRM."
}
Response:
{
"status": "applied",
"account_id": "A-0000123456",
"flag": "cease_desist",
"now_blocked_channels": ["voice", "sms", "email"]
}
All subsequent gate calls for this account on those channels return allowed=false with statute 15 USC 1692c(c) until the flag is cleared or the validation-sent-then-one-final-notice path is exercised.
4.3 Bankruptcy filing¶
{
"tenant_id": "acme-collections",
"event": "account.bankruptcy_filed",
"occurred_at": "2026-04-23T19:42:00Z",
"account_id": "A-0000123456",
"bankruptcy": {
"chapter": 13,
"case_number": "26-10483",
"filed_date": "2026-04-22",
"district_court": "CACB",
"attorney_name": "Smith & Partners LLP",
"attorney_phone": "+13105557700",
"stay_active": true
},
"source": "pacer_sync"
}
Response:
{
"status": "applied",
"account_id": "A-0000123456",
"flag": "bankruptcy_stay",
"now_blocked_channels": ["voice", "sms", "email", "letter"],
"payment_gate": "blocked"
}
After this event, both the contact gate and the payment gate refuse all actions on this account. Unblocking requires discharge event or an explicit override with supervisor sign-off.
4.4 Contact outcome — Five9 format¶
Five9 sends directly to /api/v1/webhooks/five9/{tenant_id}. Example full body:
{
"call_id": "5f9-call-8827491",
"session_id": "sess-abc",
"account_id": "A-0000123456",
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"agent_id": "agent_427",
"disposition_name": "NO_ANSWER",
"duration_seconds": 22,
"timestamp": "2026-04-23T17:35:09Z",
"wrap_up_notes": ""
}
Our response:
{
"status": "recorded",
"id": 82915,
"tenant_id": "acme-collections",
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"outcome": "placed_no_answer",
"occurred_at": "2026-04-23T17:35:09Z"
}
4.5 Contact outcome — Twilio format¶
Form-encoded POST to /api/v1/webhooks/twilio/{tenant_id}:
CallSid=CAabcdef1234567890abcdef1234567890&AccountIdentifier=A-0000123456&AuditId=ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6&CallStatus=no-answer&CallDuration=22&Timestamp=2026-04-23T17%3A35%3A09Z
4.6 Contact outcome — Convoso format¶
{
"lead_id": "A-0000123456",
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"call_id": "convoso-call-992184",
"status": "NA",
"status_name": "No Answer",
"call_length": 22,
"call_date": "2026-04-23 17:35:09",
"agent_comments": ""
}
4.7 Contact outcome — generic format¶
{
"audit_id": "ccs_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"account_id": "A-0000123456",
"channel": "voice",
"outcome": "placed_no_answer",
"disposition": "no_answer",
"duration_s": 22,
"notes": "",
"occurred_at": "2026-04-23T17:35:09Z",
"external_call_id": "your-dialer-call-id-12346"
}
5. Signature Verification (HMAC-SHA256)¶
Every inbound webhook is verified if a secret is configured. Missing secret = demo mode, signature check is skipped. Production tenants must configure a secret.
Our per-tenant secret lookup chain (server side):
dialer_configrow for(tenant_id, provider)— preferred. Set via Admin UI orscripts/set_webhook_secret.py.- Env var
FLOOROS_WEBHOOK_SECRET_{PROVIDER}_{TENANT}(e.g.,FLOOROS_WEBHOOK_SECRET_FIVE9_ACME_COLLECTIONS). - Env var
FLOOROS_WEBHOOK_SECRET_{PROVIDER}(generic fallback — discouraged in production).
Signature algorithm:
signature = HMAC-SHA256(secret, raw_request_body)
Send as hex, either bare or prefixed with sha256=. Both of the following are accepted:
X-Webhook-Signature: a1b2c3d4...f9
X-Webhook-Signature: sha256=a1b2c3d4...f9
Example (Python):
import hmac, hashlib, requests, json
secret = b"your-shared-secret"
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
sig = hmac.new(secret, body, hashlib.sha256).hexdigest()
requests.post(
f"https://api.flooros.com/api/v1/webhooks/generic/{tenant_id}",
data=body,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": f"sha256={sig}",
},
)
Example (Node.js):
const crypto = require("crypto");
const body = JSON.stringify(payload);
const sig = crypto.createHmac("sha256", secret).update(body).digest("hex");
await fetch(`https://api.flooros.com/api/v1/webhooks/generic/${tenantId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": `sha256=${sig}`,
},
body,
});
Signature validation uses constant-time comparison (hmac.compare_digest). A mismatch returns HTTP 401 and the event is not recorded.
6. Rate Limits and expires_at Semantics¶
- Gate API: 100 requests per second per tenant, burst 200. Burst through and you get HTTP 429 with
Retry-Afterseconds. Enterprise tier raises this; contact success. - Outcome webhooks: no documented limit — we absorb whatever your dialer produces. If you see 5xx under load, back off exponentially and retry; our idempotency handles duplicates.
expires_at: everycontact-checkresponse carries a UTC ISO-8601expires_at5 minutes in the future. After expiry, you must re-check before acting. A decision older than 5 minutes is advisory only — we will still accept an outcome linked to an expiredaudit_id, but the audit trail will flag the gap.
Why 5 minutes: long enough to cover a dial queue or an SMS send window; short enough that a newly-filed bankruptcy or cease-and-desist cannot be silently ignored between decision and action.
7. Error Codes, Retry Logic, Idempotency¶
7.1 HTTP codes¶
| Code | Meaning | Retry? |
|---|---|---|
| 200 | Success. Response body contains status. |
No. |
| 400 | Malformed body — bad channel, bad outcome, missing audit_id or occurred_at. |
No. Fix and resubmit. |
| 401 | Missing / invalid X-API-Key, or webhook signature mismatch. |
No. Fix credentials. |
| 403 | Key is valid but tenant_id in body does not match the key's tenant. |
No. |
| 404 | Unknown account / upload id. | No. |
| 409 | Rare — concurrent duplicate that lost the idempotency race but still succeeded elsewhere. Treat as success. | No. |
| 422 | Validation error on a specific field. Response body names the field. | No. |
| 429 | Rate limit. Retry-After header present. |
Yes, after the header value. |
| 500 | Our bug or transient dependency failure. | Yes, with exponential backoff (1s, 2s, 4s, 8s, cap 60s). |
| 502/503/504 | Upstream / degraded. | Yes, same backoff. |
7.2 Idempotency¶
Outcome writes are idempotent on the tuple (audit_id, outcome, occurred_at). Posting the same triple twice returns "status": "duplicate" and the original row id. Safe to retry aggressively — you will never produce a double-count.
For gate calls, idempotency is not guaranteed: every contact-check produces a new audit_id. Your client must deduplicate at call start, not at retry.
For account sync events, idempotency is on (tenant_id, account_id, event, occurred_at). Duplicate posts return "status": "duplicate" with no state change.
7.3 Retry policy we recommend¶
attempt = 1
while attempt <= 6:
resp = post(...)
if resp.status in (200, 400, 401, 403, 404, 409, 422):
break
if resp.status == 429:
sleep(int(resp.headers.get("Retry-After", "1")))
else:
sleep(min(60, 2 ** (attempt - 1)))
attempt += 1
If after 6 attempts the outcome is still not recorded, queue it to your own durable queue and page your on-call. Lost outcomes are the one failure mode that breaks the audit trail — treat them at P1.
8. Client Libraries¶
SDKs handle signature generation, retry, and idempotent outcome posting so your integration is a few lines, not a few hundred.
See sdk/python and sdk/javascript for client libraries.