Connect ComplianceOS
to the systems you already run
Push transactions from your ERP, employees from payroll and documents from your DMS. Read VAT, Corporate Tax and screening results back. Get signed webhooks when a deadline nears or a sign-off lands.
REST + JSON
One base URL, predictable routes
API keys
Roles and entity limits per key
Signed webhooks
HMAC-SHA256 on every delivery
— operations
Documented in OpenAPI 3.1
Getting started
From no key to your first response in a few minutes.
- 1
Create an API key
An organisation admin opens Settings → API keys & audit log → Manage API Keys and selects Create Key. Name it after the system that will use it ("SAP ERP", "Payroll").
- 2
Choose roles and entities
Pick the roles the key carries (viewer, editor or auditor) and, if it should only reach some companies, the entities. Optionally set an expiry date.
- 3
Store the key
The full key (cos_live_…) is shown once. Keep it in your secrets manager; ComplianceOS stores only a hash and shows the first 12 characters afterwards.
- 4
Make a request
Send the key in the X-API-Key header. Start by listing entities: almost every other route is under /api/v1/entities/{entity_id}.
Base URL
https://api.complianceos.me/api/v1All routes are under /api/v1. Requests and responses are JSON (UTF-8) except file uploads (multipart/form-data) and downloads. Dates are ISO 8601 (YYYY-MM-DD), amounts are decimal AED unless a field says otherwise.
Your first request
curl https://api.complianceos.me/api/v1/entities \
-H "X-API-Key: $COMPLIANCEOS_API_KEY"Authentication
Every request carries an API key in the X-API-Key header.
Keys are organisation-scoped: a key belongs to one organisation and reaches only its data. A missing, unknown, expired or revoked key gets 401. Requests with an API key are not subject to the browser CSRF checks, so server-to-server calls need no extra headers.
Roles a key can carry
| Role | What it allows |
|---|---|
viewer | Read and export across modules. |
editor | Read, create and edit across modules: push transactions, upload documents and evidence, screen names, sync employees. |
auditor | Read-only access to the audit set: tax, financial reports, evidence, contracts, screening, workforce. |
Keys cannot carry admin roles, and a key can only carry permissions its creator holds. Deleting records and organisation settings (users, webhooks, keys) stay with people in the app.
Entity access
A key limited to some entities gets 403 on any other entity, and lists show only its entities. Use GET /api/v1/me/permissions?entity_id=… to see what a key may do on an entity.
Decisions stay with people
Signing off a classification, filing or locking a return, clearing a sanctions match, approving a SAR, making a tax election or completing an obligation are a person's decision. API keys get 403 with "Only people can …" for those; the work then waits in the app for the right person.
Rotation
Create the new key, deploy it, then revoke the old one on the API Keys page. Revocation takes effect on the next request. Each key's last-used time is shown in the list.
Errors & status codes
Errors are JSON with a human-readable detail you can log or show.
detail is usually a string. Validation errors (422) give a list of {loc, msg, type} objects naming each bad field. A few business rules return an object with a machine-readable code and a message, for example {"code": "AI_CLASSIFICATIONS_PENDING_REVIEW", "message": "…", "count": 3}.
| Status | Meaning |
|---|---|
200 | OK. |
201 | Created. The body is the new record. |
202 | Accepted: a background job started; the body has a task_id. |
204 | Done, no body. |
400 | The request is malformed or not allowed in the record's current state. |
401 | Missing, unknown, expired or revoked API key. |
402 | The organisation's AI credit budget is used up (routes that use AI). |
403 | The key lacks the permission, or the action is a person's decision. |
404 | Not found, or not in your organisation. |
409 | Conflict: a duplicate, or the period is filed, locked or closed. |
413 | The uploaded file is too large. |
422 | Validation failed: see detail for the fields. |
429 | Rate limit exceeded: wait Retry-After seconds. |
500 | Unexpected error on our side. Safe to retry with backoff. |
503 | A dependency is unavailable (for example, no watchlist loaded yet). Retry later. |
Examples
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"detail": "Permission denied: tax.transaction:create"}HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"detail": [
{
"type": "missing",
"loc": ["body", "amount_aed"],
"msg": "Field required"
}
]
}Rate limits
Limits protect the expensive operations: AI, screening and file processing.
Limits are counted per API key over a fixed one-minute window. Routes without a limit below have no per-route cap; please keep bulk work to a few concurrent requests and prefer the file imports for large volumes.
Over a limit you get 429 with a Retry-After header (seconds). Wait that long, then continue.
HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json
{
"type": "https://complianceos.me/errors/rate-limit-exceeded",
"title": "Rate limit exceeded",
"code": "rate_limited",
"detail": "Too many requests. Try again in 42 seconds.",
"retry_after": 42
}Pagination
List routes page with page and page_size.
page starts at 1. page_size defaults to 20 and is capped at 500 on most routes (200 on the calendar, which defaults to 50). Responses wrap the rows in an envelope:
{
"items": [ … ],
"total": 1342,
"page": 1,
"page_size": 100,
"total_pages": 14
}Stop when page reaches total_pages (or items comes back empty). Filters such as date_from / date_to apply before paging, so totals reflect the filter.
import os
import requests
BASE = "https://api.complianceos.me/api/v1"
HEADERS = {"X-API-Key": os.environ["COMPLIANCEOS_API_KEY"]}
ENTITY_ID = "your-entity-id"
def all_employees():
page = 1
while True:
resp = requests.get(
BASE + "/entities/" + ENTITY_ID + "/employees",
headers=HEADERS,
params={"page": page, "page_size": 100},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
yield from body["items"]
if page >= body["total_pages"]:
break
page += 1
print(sum(1 for _ in all_employees()), "employees")Duplicates & retries
There is no Idempotency-Key header. Instead, records have natural keys that make a retry safe.
- Transactions: a second POST with the same invoice_number and transaction_type for the entity returns 409 instead of a duplicate. Send your ERP document number as invoice_number.
- File imports skip rows already imported (same date, amount, type, counterparty and invoice number) and report them in duplicate_count. Use ?dry_run=true to check a file first.
- WPS salary records are one per employee per month: posting the same month again updates it.
- Employees: employee_number is unique within an entity; a repeat gets 409.
Retrying safely
Retry 429 (after Retry-After), 500, 502, 503 and timeouts with exponential backoff. Do not retry other 4xx responses: fix the request instead.
import time
import requests
def call(method, url, **kwargs):
"""Retry 429 and 5xx with backoff; never retry other 4xx."""
for attempt in range(5):
resp = requests.request(method, url, timeout=30, **kwargs)
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", "30")))
continue
if resp.status_code >= 500:
time.sleep(2 ** attempt)
continue
return resp
return respBackground jobs
Long work runs in the background and returns 202 with a task_id.
Batch classification and similar jobs answer 202 Accepted with {task_id, status, message}. Poll GET /api/v1/tasks/{task_id}/status every few seconds until status is SUCCESS or FAILURE; result holds the job's output and error the reason it failed. AI work uses your organisation's AI credits.
Webhooks
ComplianceOS calls your HTTPS endpoint when something happens, so you don't have to poll.
Register an endpoint
- 1
An organisation admin opens Settings → Webhooks and selects Add Endpoint.
- 2
Enter an HTTPS URL (private and internal addresses are refused) and choose the events to receive.
- 3
Copy the signing secret: it is shown once. Store it next to your receiver.
- 4
Select Send test event to receive a test delivery (data.test is true) and check your verification.
What a delivery looks like
Each event is a POST with a JSON body. Deliveries go out after the change is committed, so the data is already readable through the API when you get the event.
| Header | Value |
|---|---|
X-Webhook-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your endpoint's secret. |
X-Webhook-Event | The event type, also in the body's event field. |
X-Webhook-Delivery | The delivery id shown in the delivery log; a manual retry keeps it. |
User-Agent | ComplianceOS-Webhooks/1.0 |
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: ComplianceOS-Webhooks/1.0
X-Webhook-Event: compliance.deadline.approaching
X-Webhook-Delivery: 0b6f8a52-3c1e-4d0b-9a57-2f1c7e9d4a10
X-Webhook-Signature: sha256=5d2c4f…Payload
Every event has the same envelope: id (unique per event), event, tenant_id (your organisation), timestamp (UTC, ISO 8601) and data, which holds the event's fields — usually entity_id and the id of the record it is about. Fetch the record through the API when you need more.
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"event": "compliance.deadline.approaching",
"tenant_id": "3f2b8c1e-9d4a-4e6b-8f1a-2c5d7e9b0a13",
"timestamp": "2026-10-25T06:00:04.512331+00:00",
"data": {
"entity_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"calendar_entry_id": "d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f80",
"framework": "UAE_VAT",
"due_date": "2026-10-28",
"days_remaining": 3,
"title": "VAT return Q3 2026",
"source_type": "VAT_RETURN"
}
}Verify the signature
Compute HMAC-SHA256 over the raw request body with your secret and compare it, in constant time, with X-Webhook-Signature. Reject the request if they differ.
Use the bytes exactly as received. Parsing the JSON and serialising it again changes the bytes and the signature will not match.
import hashlib
import hmac
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""True when X-Webhook-Signature matches the raw request body.
Use the body exactly as received (before any JSON parsing)."""
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header or "")Responses, retries and ordering
- Answer with any 2xx within 10 seconds. Do slow work after responding, from a queue.
- A non-2xx answer, a timeout or a connection error is retried: 3 attempts in all — immediately, about 5 minutes later and about 10 minutes after that.
- After the last attempt the delivery stays failed in the delivery log, where an admin can retry it.
- Retries carry the same body and id. Deduplicate on id, and don't rely on events arriving in order: compare timestamps or re-read the record.
Event catalogue
Subscribe an endpoint to the events your system needs. GET /api/v1/settings/webhooks/events lists every event type.
| Event | Sent when |
|---|---|
| Compliance & sign-off | |
compliance. | A statutory or manual deadline is coming up and its reminder went out (data: calendar_entry_id, framework, due_date, days_remaining). |
approval. | A sign-off request got its final decision (data: request_id, action, resource_type, resource_id, status). |
workflow. | A compliance workflow finished all its steps. |
escalation | An overdue item was escalated to the people responsible. |
policy. | A custom policy rule raised a violation alert. |
report. | A compliance report was generated. |
entity. | A new entity (company) was added. |
entity. | An entity's profile changed. |
frameworks. | An entity's compliance frameworks were mapped or re-mapped. |
learning. | Learning from past filings finished for an entity. |
| Tax | |
tax. | A transaction got its VAT / CT classification. |
tax. | A person signed off (approved or rejected) classifications. |
tax. | A VAT return draft was computed (data: period, output_vat, input_vat, net_vat_due). |
tax. | A Corporate Tax return draft was computed. |
tax. | Revenue neared or crossed a VAT, CT or Small Business Relief threshold. |
tax_group. | A Tax Group's consolidated CT return was computed. |
tax_group. | An entity joined or left a Tax Group. |
excise. | An excise return draft was computed. |
excise. | An excise return was handed to a reviewer. |
consistency. | A cross-module check found a mismatch that blocks filing. |
| Accounting | |
bank. | Bank-statement auto-matching finished. |
bill. | A supplier bill was verified. |
accounting. | An accounting period was closed. |
accounting. | An accounting period was locked. |
accounting. | An accounting period was reopened. |
journal. | A journal entry was withdrawn. |
journal. | A journal entry was deleted. |
anomaly. | An anomaly scan found a new anomaly. |
| Invoicing & customs | |
invoice. | An invoice was created. |
invoice. | An invoice passed validation. |
invoice. | An invoice was sent to the customer. |
invoice. | An invoice passed its due date unpaid. |
customs. | A customs declaration was created. |
| Evidence & audit | |
evidence. | Transactions are missing evidence they need. |
evidence. | Evidence is nearing the end of its retention period. |
audit. | An audit finding was raised in the Audit Hub. |
| Sanctions & AML | |
sanctions. | Screening found a possible match (data: check_id, subject_name, match_score, status). |
sanctions. | A person cleared a screening match. |
sanctions. | A person escalated a screening match. |
sanctions. | Periodic re-screening found a new match for an existing counterparty. |
aml. | Customer due diligence was completed. |
aml. | Transaction monitoring raised an AML alert. |
aml. | A suspicious activity report was drafted. |
aml. | A suspicious activity report was filed. |
| Workforce | |
employee. | An employee was added to the register. |
employee. | An employee's employment was terminated. |
workforce. | An employee's visa, Emirates ID, labour card or passport expires soon. |
workforce. | An employee document has expired. |
| Contracts | |
contract. | AI extraction of a contract finished. |
contract. | A contract was risk-scored. |
contract. | A contract scored as high risk. |
contract. | A contract reached the end of its lifecycle. |
| Data protection (PDPL) | |
dsr. | A data subject request was received. |
pdpl. | A privacy complaint was received. |
pdpl. | A privacy complaint's response deadline is near. |
data_breach. | A personal-data breach was logged. |
breach. | A personal-data breach was logged (same moment as data_breach.reported). |
pdpl. | A breach's 72-hour notification deadline is near. |
breach. | Same as pdpl.breach.deadline_approaching (earlier name). |
pdpl. | A breach's 72-hour notification deadline has passed. |
breach. | Same as pdpl.breach.72hr_overdue (earlier name). |
dpia. | A DPIA was approved. |
pdpl. | A DPIA was approved (PDPL module name). |
pdpl. | A DPIA was rejected. |
task. | A DPIA risk action was assigned to someone. |
task. | A DPIA risk action was completed. |
pdpl. | Personal data passed its retention period. |
pdpl. | A data subject withdrew consent. |
pdpl. | A privacy policy is due for review. |
| Regulatory & risk | |
regulatory. | A regulatory change relevant to an entity was published. |
regulatory. | A critical regulatory change was published. |
regulatory. | A regulatory change takes effect soon. |
risk. | Risk analysis raised a new insight. |
risk. | A risk treatment passed its due date. |
| Free zones | |
freezone. | A free-zone licence was registered. |
freezone. | A free-zone licence was updated. |
freezone. | A free-zone substance assessment was completed. |
freezone. | A free-zone obligation is overdue. |
The list also includes agent.* events used by the ComplianceOS AI team; they are not covered here.
Integration recipes
Working patterns for the integrations customers build most.
Push transactions from an ERP
Send each posted sales or purchase document as a transaction, with the ERP document number as invoice_number so a retry returns 409 instead of a duplicate. Attach the PDF as evidence. Classification runs in ComplianceOS and people sign it off before returns are filed.
import os
import requests
BASE = "https://api.complianceos.me/api/v1"
HEADERS = {"X-API-Key": os.environ["COMPLIANCEOS_API_KEY"]}
ENTITY_ID = "your-entity-id"
# One row from your ERP's sales or purchase ledger.
invoice = {
"transaction_date": "2026-09-14",
"transaction_type": "INCOME",
"description": "Consulting services - September",
"amount_aed": "18500.00",
"invoice_number": "INV-2026-0912",
"vendor_customer": "Gulf Trading LLC",
"vendor_country": "AE",
"counterparty_role": "CUSTOMER",
"document_type": "INVOICE",
}
resp = requests.post(
BASE + "/entities/" + ENTITY_ID + "/tax/transactions",
headers=HEADERS,
json=invoice,
timeout=30,
)
if resp.status_code == 409:
print("Already in ComplianceOS:", invoice["invoice_number"])
else:
resp.raise_for_status()
tx = resp.json()
print("Created", tx["id"])
# Attach the invoice PDF as evidence for the transaction.
with open("INV-2026-0912.pdf", "rb") as pdf:
ev = requests.post(
BASE + "/entities/" + ENTITY_ID + "/tax/transactions/" + tx["id"] + "/evidence",
headers=HEADERS,
files={"file": ("INV-2026-0912.pdf", pdf, "application/pdf")},
timeout=60,
)
ev.raise_for_status()Bulk import a ledger file
For month-end loads, send the CSV or XLSX export. dry_run=true checks it without saving; the real import skips rows already imported. Then start classification and poll the task.
# Check the file first: nothing is saved with dry_run=true
curl -X POST "https://api.complianceos.me/api/v1/entities/$ENTITY_ID/tax/transactions/import?dry_run=true" \
-H "X-API-Key: $COMPLIANCEOS_API_KEY" \
-F "file=@ledger-2026-09.xlsx"
# Then import it. Rows already imported are skipped and counted in duplicate_count.
curl -X POST "https://api.complianceos.me/api/v1/entities/$ENTITY_ID/tax/transactions/import" \
-H "X-API-Key: $COMPLIANCEOS_API_KEY" \
-F "file=@ledger-2026-09.xlsx"
# Classify the new transactions (uses AI credits), then poll the task
curl -X POST "https://api.complianceos.me/api/v1/entities/$ENTITY_ID/tax/transactions/classify-batch" \
-H "X-API-Key: $COMPLIANCEOS_API_KEY"
curl "https://api.complianceos.me/api/v1/tasks/$TASK_ID/status" \
-H "X-API-Key: $COMPLIANCEOS_API_KEY"Upload documents as evidence
Licences, resolutions and filings go into the document library, or straight into the Evidence Vault, which hashes each file (SHA-256) and verifies it on every download.
# Into the document library
curl -X POST "https://api.complianceos.me/api/v1/entities/$ENTITY_ID/documents" \
-H "X-API-Key: $COMPLIANCEOS_API_KEY" \
-F "file=@trade-licence-2026.pdf" \
-F "title=Trade licence 2026" \
-F "category=LICENCE" \
-F "folder=Corporate"
# Straight into the Evidence Vault (SHA-256 hashed, verified on every download)
curl -X POST "https://api.complianceos.me/api/v1/entities/$ENTITY_ID/evidence/upload" \
-H "X-API-Key: $COMPLIANCEOS_API_KEY" \
-F "file=@board-resolution.pdf" \
-F "source_type=COMPLIANCE_REPORT"Screen a customer before onboarding
Call screening from your CRM or onboarding flow. CLEARED means no match above the threshold; PENDING is a possible match a person reviews in ComplianceOS — subscribe to sanctions.cleared and sanctions.escalated to learn the outcome.
import os
import requests
BASE = "https://api.complianceos.me/api/v1"
HEADERS = {"X-API-Key": os.environ["COMPLIANCEOS_API_KEY"]}
ENTITY_ID = "your-entity-id"
resp = requests.post(
BASE + "/entities/" + ENTITY_ID + "/sanctions/screen",
headers=HEADERS,
json={
"subject_name": "Orion Logistics FZE",
"subject_type": "CUSTOMER",
"subject_nationality": "AE",
},
timeout=60,
)
resp.raise_for_status()
check = resp.json()
# CLEARED: no match above the threshold. PENDING: a possible match that a
# person must review in ComplianceOS before you proceed.
if check["status"] == "CLEARED":
print("No sanctions match")
else:
print("Hold onboarding - review check", check["id"], check["matched_entry_name"])Sync employees for WPS
Create employees from your HR system, then record each month's salary payment. ComplianceOS tracks WPS compliance, Emiratisation and document expiries from the register.
import os
import requests
BASE = "https://api.complianceos.me/api/v1"
HEADERS = {"X-API-Key": os.environ["COMPLIANCEOS_API_KEY"]}
ENTITY_ID = "your-entity-id"
# 1. Create the employee (once; employee_number must be unique in the entity)
resp = requests.post(
BASE + "/entities/" + ENTITY_ID + "/employees",
headers=HEADERS,
json={
"full_name": "Sample Employee",
"employee_number": "E-1042",
"employment_date": "2025-03-01",
"job_title": "Accountant",
"department": "Finance",
"nationality": "India",
"basic_salary_aed": "12000",
"allowances_aed": "3000",
"is_wps_enrolled": True,
},
timeout=30,
)
resp.raise_for_status()
employee = resp.json()
# 2. Each month, record the salary payment. Sending the same month again
# updates that month's record instead of adding a second one.
resp = requests.post(
BASE + "/entities/" + ENTITY_ID + "/employees/" + employee["id"] + "/wps",
headers=HEADERS,
json={
"period_month": 9,
"period_year": 2026,
"paid_date": "2026-09-28",
"salary_paid_aed": "15000",
"status": "COMPLIANT",
},
timeout=30,
)
resp.raise_for_status()Receive deadline and approval events
Subscribe to compliance.deadline.approaching and approval.decided to open tickets in your service desk and sync sign-off status back to your ERP.
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["COMPLIANCEOS_WEBHOOK_SECRET"]
seen = set() # use your database in production
@app.post("/complianceos/webhook")
def complianceos_webhook():
raw = request.get_data() # raw bytes, not request.json
if not verify_signature(raw, request.headers.get("X-Webhook-Signature", ""), SECRET):
abort(401)
event = json.loads(raw)
if event["id"] in seen: # retries carry the same id
return "", 200
seen.add(event["id"])
if event["event"] == "compliance.deadline.approaching":
create_ticket(event["data"]["title"], event["data"]["due_date"])
elif event["event"] == "approval.decided":
sync_status(event["data"]["resource_id"], event["data"]["status"])
return "", 200API reference
Every documented operation, generated from the live OpenAPI document.
Loading the API reference…
Building an integration?
Tell us what you're connecting and we'll help with the mapping, test data and go-live.