Integration Guide

Connect Tuli to external systems using REST APIs, webhooks, and the Messy notification API.

Integration Patterns

REST API Integration

Use the Tuli REST API for real-time bidirectional data exchange:

  • Inbound — Push data from external systems into Tuli (e.g., import leads from website forms)
  • Outbound — Pull data from Tuli for external reporting or synchronization
  • Bidirectional — Keep systems in sync with two-way data flow

API Fundamentals

Base URL

https://api.tuli.io/api/v1

Authentication

Include your API token in the Authorization header:

curl -H "Authorization: Bearer tuli_sk_live_..." \
  https://api.tuli.io/api/v1/contracts

Key Identifiers

All entity references use string Keys, not numeric IDs:

{
  "Id": "contract-1CAjiqwbu2g3LkcoByCL4Y",
  "Tenant": "tenant-2DBkjrxcv3h4MldpCzDM5Z"
}

Webhook Integration

Receive real-time events when data changes in Tuli:

POST /api/v1/webhooks
Content-Type: application/json

{
  "Url": "https://your-system.com/tuli-events",
  "Events": [
    "contract.created",
    "contract.updated",
    "payment.received",
    "workorder.completed"
  ],
  "Secret": "your-webhook-secret"
}

Webhook events are signed using HMAC-SHA256. Verify the signature:

const crypto = require('crypto');

function verifyWebhook(payload, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(`sha256=${expected}`)
  );
}

Messy Notification API

Tuli uses the Messy API for email templates and notifications.

Template Management

Templates are managed at https://messy-api.tuli-erp.com.

Template locations:

  • /models/workflows/notifications/ — Workflow notification templates
  • /models/applications/fm/notifications/ — Facility Management templates
  • /models/applications/leasing/notifications/ — Leasing templates

Upload Template

curl -X POST "https://messy-api.tuli-erp.com/templates" \
  -H "Authorization: Bearer API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "template": {
      "name": "Contract Renewal Reminder",
      "trigger": "contract-renewal-reminder",
      "subject": "Contract {{ContractNumber}} expiring soon",
      "body": "<html>...</html>"
    }
  }'

Template triggers use kebab-case: workflow-task-create, service-request-created

Merge Tags

Use merge tags in templates for dynamic content:

<p>Dear {{Tenant.Name}},</p>
<p>Your contract {{ContractNumber}} for unit {{Unit.Number}}
   at {{Building.Name}} is expiring on {{EndDate}}.</p>

File-Based Import

For batch data migration and periodic imports:

  • Excel/CSV — Upload structured files with column mapping
  • JSON — API-based bulk import with validation
  • Scheduled — Configure recurring imports from shared drives or SFTP

Common Integration Scenarios

Scenario Method Direction
Website lead capture REST API Inbound
Accounting sync (QuickBooks, Xero) REST API + Webhooks Bidirectional
Bank statement import File import Inbound
HR system sync REST API Bidirectional
IoT sensor data (facilities) REST API Inbound
Document management (SharePoint) REST API + Webhooks Bidirectional
Payment gateway (Stripe, PayTabs) Webhooks Inbound
Email marketing (Mailchimp) REST API Outbound

Error Handling

Implement retry logic for transient failures:

async function tuliApiCall(endpoint, data, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(`https://api.tuli.io/api/v1/${endpoint}`, {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Authorization': 'Bearer tuli_sk_live_...',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(data)
      });

      if (response.ok) {
        return await response.json();
      }

      // Rate limited - wait and retry
      if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
        await sleep(retryAfter * 1000);
        continue;
      }

      // Client error - don't retry
      if (response.status >= 400 && response.status < 500) {
        throw new Error(`API error: ${response.status}`);
      }

      // Server error - retry with backoff
      throw new Error(`Server error: ${response.status}`);
    } catch (err) {
      if (i === retries - 1) throw err;
      await sleep(1000 * Math.pow(2, i)); // Exponential backoff
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

ClickHouse Analytics

Tuli syncs data to ClickHouse for real-time analytics. The warehouse uses a specific naming convention:

wh_{Application}_{Object}

Examples:

  • wh_Leasing_Contract
  • wh_FacilityManagement_WorkOrder
  • wh_Finance_PurchaseOrder

Standard columns:

  • Key — String identifier
  • IsDeleted — Soft delete flag
  • CreatedAt, ModifiedAt — Timestamps
  • All P9 fields from the object definition

MeiliSearch Integration

Full-text search is powered by MeiliSearch. Search indexes are automatically created for objects with search-field:true attributes.

GET /api/v1/contracts?search=acme

Data Sync Architecture

  1. PostgreSQL — Primary transactional database
  2. ClickHouse — Analytics warehouse (synced via WorkerApp)
  3. MeiliSearch — Full-text search (synced via WorkerApp)

The warehouse-sync worker keeps all systems synchronized:

# Sync all
dotnet run --project api/WorkerApp -- warehouse-sync

# Sync ClickHouse only
dotnet run --project api/WorkerApp -- warehouse-sync clickhouse

# Sync MeiliSearch only
dotnet run --project api/WorkerApp -- warehouse-sync meilisearch

Experience the Platform

See how P9 and the Tuli platform work together

Ready to Build with P9?

Get hands-on with the platform. See how P9 accelerates your development workflow and integrates seamlessly with your existing systems.