Developers · REST API & webhooks

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. 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. 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. 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. 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}.

Already signed in? Open API keys

Base URL

Base URL
https://api.complianceos.me/api/v1

All 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

RoleWhat it allows
viewerRead and export across modules.
editorRead, create and edit across modules: push transactions, upload documents and evidence, screen names, sync employees.
auditorRead-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}.

StatusMeaning
200OK.
201Created. The body is the new record.
202Accepted: a background job started; the body has a task_id.
204Done, no body.
400The request is malformed or not allowed in the record's current state.
401Missing, unknown, expired or revoked API key.
402The organisation's AI credit budget is used up (routes that use AI).
403The key lacks the permission, or the action is a person's decision.
404Not found, or not in your organisation.
409Conflict: a duplicate, or the period is filed, locked or closed.
413The uploaded file is too large.
422Validation failed: see detail for the fields.
429Rate limit exceeded: wait Retry-After seconds.
500Unexpected error on our side. Safe to retry with backoff.
503A dependency is unavailable (for example, no watchlist loaded yet). Retry later.

Examples

403
HTTP/1.1 403 Forbidden
Content-Type: application/json

{"detail": "Permission denied: tax.transaction:create"}
422
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.

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

JSON
{
  "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.

Python
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.

Python
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 resp

Background 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. 1

    An organisation admin opens Settings → Webhooks and selects Add Endpoint.

  2. 2

    Enter an HTTPS URL (private and internal addresses are refused) and choose the events to receive.

  3. 3

    Copy the signing secret: it is shown once. Store it next to your receiver.

  4. 4

    Select Send test event to receive a test delivery (data.test is true) and check your verification.

Already signed in? Open Webhooks

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.

HeaderValue
X-Webhook-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your endpoint's secret.
X-Webhook-EventThe event type, also in the body's event field.
X-Webhook-DeliveryThe delivery id shown in the delivery log; a manual retry keeps it.
User-AgentComplianceOS-Webhooks/1.0
HTTP
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.

JSON
{
  "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.

EventSent when
Compliance & sign-off
compliance.deadline.approachingA statutory or manual deadline is coming up and its reminder went out (data: calendar_entry_id, framework, due_date, days_remaining).
approval.decidedA sign-off request got its final decision (data: request_id, action, resource_type, resource_id, status).
workflow.completedA compliance workflow finished all its steps.
escalationAn overdue item was escalated to the people responsible.
policy.alert.createdA custom policy rule raised a violation alert.
report.generatedA compliance report was generated.
entity.createdA new entity (company) was added.
entity.profile.updatedAn entity's profile changed.
frameworks.mappedAn entity's compliance frameworks were mapped or re-mapped.
learning.job.completedLearning from past filings finished for an entity.
Tax
tax.transaction.classifiedA transaction got its VAT / CT classification.
tax.classifications.reviewedA person signed off (approved or rejected) classifications.
tax.vat_return.computedA VAT return draft was computed (data: period, output_vat, input_vat, net_vat_due).
tax.ct_return.computedA Corporate Tax return draft was computed.
tax.threshold.crossedRevenue neared or crossed a VAT, CT or Small Business Relief threshold.
tax_group.ct_return.computedA Tax Group's consolidated CT return was computed.
tax_group.member.changedAn entity joined or left a Tax Group.
excise.return.computedAn excise return draft was computed.
excise.return.under_reviewAn excise return was handed to a reviewer.
consistency.blocking_mismatchA cross-module check found a mismatch that blocks filing.
Accounting
bank.auto_match.completedBank-statement auto-matching finished.
bill.verifiedA supplier bill was verified.
accounting.period.closedAn accounting period was closed.
accounting.period.lockedAn accounting period was locked.
accounting.period.reopenedAn accounting period was reopened.
journal.withdrawnA journal entry was withdrawn.
journal.deletedA journal entry was deleted.
anomaly.detectedAn anomaly scan found a new anomaly.
Invoicing & customs
invoice.createdAn invoice was created.
invoice.validatedAn invoice passed validation.
invoice.sentAn invoice was sent to the customer.
invoice.overdueAn invoice passed its due date unpaid.
customs.declaration.createdA customs declaration was created.
Evidence & audit
evidence.gap.detectedTransactions are missing evidence they need.
evidence.retention.approachingEvidence is nearing the end of its retention period.
audit.finding.createdAn audit finding was raised in the Audit Hub.
Sanctions & AML
sanctions.match_foundScreening found a possible match (data: check_id, subject_name, match_score, status).
sanctions.clearedA person cleared a screening match.
sanctions.escalatedA person escalated a screening match.
sanctions.rescreen_matchPeriodic re-screening found a new match for an existing counterparty.
aml.cdd.completedCustomer due diligence was completed.
aml.alert.createdTransaction monitoring raised an AML alert.
aml.sar.createdA suspicious activity report was drafted.
aml.sar.filedA suspicious activity report was filed.
Workforce
employee.createdAn employee was added to the register.
employee.terminatedAn employee's employment was terminated.
workforce.document_expiringAn employee's visa, Emirates ID, labour card or passport expires soon.
workforce.document_expiredAn employee document has expired.
Contracts
contract.extractedAI extraction of a contract finished.
contract.risk_scoredA contract was risk-scored.
contract.high_riskA contract scored as high risk.
contract.completedA contract reached the end of its lifecycle.
Data protection (PDPL)
dsr.receivedA data subject request was received.
pdpl.complaint.receivedA privacy complaint was received.
pdpl.complaint.deadline_approachingA privacy complaint's response deadline is near.
data_breach.reportedA personal-data breach was logged.
breach.loggedA personal-data breach was logged (same moment as data_breach.reported).
pdpl.breach.deadline_approachingA breach's 72-hour notification deadline is near.
breach.72hr_deadline_approachingSame as pdpl.breach.deadline_approaching (earlier name).
pdpl.breach.72hr_overdueA breach's 72-hour notification deadline has passed.
breach.72hr_overdueSame as pdpl.breach.72hr_overdue (earlier name).
dpia.approvedA DPIA was approved.
pdpl.dpia.approvedA DPIA was approved (PDPL module name).
pdpl.dpia.rejectedA DPIA was rejected.
task.assignedA DPIA risk action was assigned to someone.
task.completedA DPIA risk action was completed.
pdpl.retention.expiredPersonal data passed its retention period.
pdpl.consent.withdrawnA data subject withdrew consent.
pdpl.policy.review_dueA privacy policy is due for review.
Regulatory & risk
regulatory.alert.createdA regulatory change relevant to an entity was published.
regulatory.critical_alertA critical regulatory change was published.
regulatory.effective_date.approachingA regulatory change takes effect soon.
risk.insight.createdRisk analysis raised a new insight.
risk.treatment_overdueA risk treatment passed its due date.
Free zones
freezone.license.registeredA free-zone licence was registered.
freezone.license.updatedA free-zone licence was updated.
freezone.substance.assessedA free-zone substance assessment was completed.
freezone.obligation.overdueA 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.

Python
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.

cURL
# 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.

cURL
# 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.

Python
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.

Python
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 "", 200

API 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.