Integration reference: APIs, data contracts & events ===================================================== This is the developer/integrator reference for connecting an external system to tigerdistribute — a **Policy Administration System (PAS)**, a producer's own feed, or a downstream **event subscriber**. It documents the HTTP endpoints, the machine credentials, the canonical data contracts, the outbound event catalog and the adapter framework. The *user-facing* behaviour of these features (the Ingestion screen, currency gaps, managing webhook endpoints) lives in :doc:`/user-guide/integrations`; the background jobs that drive delivery are described in :doc:`/operations`. This page is for the engineer building the integration. The integration boundary ------------------------- tigerdistribute does not own policies, quotes or billing — those live in a PAS. The platform integrates to do two things: * **Ingest facts** — bound / endorsed / cancelled **policy** rows and **commission** transactions, which drive ``performance``, ``targets`` and ``commissions``. * **Emit status** — producer, appointment, payability and payout **domain events**, which a subscriber consumes to keep its own copy in sync and to settle. **No PAS is special.** Every system — Guidewire, Sapiens, Socotra, isotope or a carrier's in-house platform — integrates by conforming to the *same* canonical contract. Their differences are absorbed by a thin **coded adapter** per system; the contract itself never names a vendor. isotope is the *reference adapter*, not a privileged one. :: ANY PAS ADAPTER (per PAS) CANONICAL CORE ┌──────────┐ push/file/ ┌───────────────────┐ ┌──────────────────┐ │ Guidewire│ pull/bus │ GuidewireConnector │ → │ PolicyFact │ │ Sapiens │ ──────────────► │ SapiensConnector │ → │ CommissionFact │ → performance │ Socotra │ │ SocotraConnector │ │ (IngestionBatch)│ targets │ isotope │ ◄────────────── │ IsotopeConnector │ ← │ Outbox events │ commissions └──────────┘ status events └───────────────────┘ └──────────────────┘ Every ingress mode converges on one normalized output (``ConnectorResult(policies=[…], commissions=[…])``); from there the persistence and idempotency path is identical, so the core is mode-agnostic. The adapter's only job is translation. Topology: ingress and egress ----------------------------- Every mode lands the **same canonical facts** in the same ``IngestionBatch`` pipeline. .. list-table:: :header-rows: 1 :widths: 14 18 12 56 * - Mode - Direction - Initiator - When to use * - **Push** - PAS → DMS - PAS - PAS has outbound webhooks / an API (modern systems). * - **File** - PAS → DMS - PAS / ops - Bordereaux (CSV / XLSX). The **universal fallback** — anything can export a file. * - **Pull** - DMS → PAS - DMS - PAS has a query API but no outbound events (older systems). * - **Bus** - PAS → DMS - PAS - Carrier runs an event backbone (Redis Streams today; Kafka is a future transport). * - **Status (egress)** - DMS → PAS - DMS - Every subscriber that gates on producer / appointment status or consumes payouts. Authentication -------------- The REST API mounts under ``/api/``. There is **no URL or header API versioning** — the surface is flat and evolves additively (see `Versioning & compatibility`_). Interactive clients (the React SPA) use session auth; machine callers use one of three purpose-built credentials. Tenancy ~~~~~~~ Every request resolves to exactly one tenant, and row-level security is enforced in the database. Most machine credentials require an explicit ``X-Tenant: `` header; the **PAS push token** is the exception — it self-identifies its tenant, so no header is needed. Producer API key ~~~~~~~~~~~~~~~~~ For a producer pushing its **own** policy and commission rows. * Header: ``Authorization: Api-Key tdk__`` **plus** ``X-Tenant: ``. * Every ingested row is forcibly attributed to the key's producer. * Throttled at **120 requests/hour** per key. * Keys are minted, rotated and revoked through ``/api/producer-api-keys/`` by a tenant manager; the secret is shown **once** on create/rotate. PAS push token ~~~~~~~~~~~~~~~ For a PAS delivering webhooks to the universal receiver. * A self-identifying opaque token ``tdp__`` whose globally unique prefix resolves the ``PASConnection`` — **tenant and adapter** — so the PAS needs **no** ``X-Tenant`` header. * Supply it in the URL (``/api/pas/ingest//``) or as ``Authorization: Bearer `` — whichever the PAS's webhook config allows. This is *configuration, not PAS code*. * Minted per connection via ``POST /api/pas-connections//mint-push-token/`` (returns the token and the push URL once); revoked with ``revoke-push-token``. Signed webhook (outbound & inbound callbacks) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Outbound deliveries — and inbound provider callbacks such as screening — are authenticated by an **HMAC-SHA256 signature over the raw body**, carried in ``X-TigerDistribute-Signature: sha256=``. Consumers must verify it before trusting a delivery. See `Outbound: domain events`_. Inbound: ingesting policy & commission facts -------------------------------------------- Endpoints ~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 40 24 36 * - Endpoint - Auth - Payload * - ``POST /api/pas/ingest/`` ``POST /api/pas/ingest//`` - PAS push token - **Any** shape — the connection's adapter normalizes it. * - ``POST /api/ingestion-batches/ingest/`` - Tenant member + ``X-Tenant`` - Canonical batch (staff / trusted callers). * - ``POST /api/producer-ingest/`` - Producer ``Api-Key`` - Canonical batch; all rows forced to the key's producer. * - ``POST /api/bordereaux-uploads/`` - Tenant member + ``X-Tenant`` - Multipart CSV / XLSX (≤ 10 MB) + a mapping template. * - ``POST /api/ingestion-batches/validate/`` - Tenant member + ``X-Tenant`` - Canonical batch — **dry-run**, persists nothing. Ingested batches and their rows are readable at ``/api/ingestion-batches/``, ``/api/ingested-policies/`` and ``/api/ingested-commissions/`` (filter by ``?batch=``, ``?unresolved=true``, ``?status=``, ``?source=``). The universal push receiver ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``/api/pas/ingest/`` is the primary path for a PAS we **cannot modify**. It makes *no assumption about the payload*: it captures the raw body, headers and (when parseable) JSON into a ``PushEnvelope`` and hands it to the connection's adapter, which owns all interpretation — CloudEvents structured (JSON body) or binary (``ce-*`` headers), or a bespoke envelope. * **Lenient.** An event the adapter doesn't consume yields an empty batch and a ``202`` — a PAS webhook is never failed over something we can't fix. Retries are safe because ingestion is idempotent on external ids. * **Optional hardening.** A PAS that *can* sign or send a static secret may be additionally verified inside its adapter — not required. Canonical inbound schema ~~~~~~~~~~~~~~~~~~~~~~~~~~ Whatever the ingress mode, an adapter emits rows in these two shapes. **PolicyFact** — maps to ``integrations.IngestedPolicy``. .. list-table:: :header-rows: 1 :widths: 22 16 8 54 * - Field - Type - Req - Notes * - ``producer`` - ``{scheme, value}`` - ✱ - Producer external id (see `Producer identity`_). *On the wire today:* a ``producer_npn`` string. Omitted when a producer-scoped credential already binds the producer. * - ``external_policy_id`` - string - ✔ - The PAS's stable policy id. Idempotency key. * - ``policy_number`` - string - — - Human policy number. * - ``product_code`` - string - — - Maps to ``reference.Product`` by code. * - ``jurisdiction_code`` - string - — - ISO / state code; global-first (never a bare "state" string). * - ``transaction_type`` - enum - ✔ - ``new_business`` · ``renewal`` · ``endorsement`` · ``cancellation`` — the transaction *event*. * - ``policy_status`` - enum - — - ``in_force`` · ``lapsed`` · ``cancelled`` · ``paid_up`` · ``pending`` · ``expired``. Lifecycle *state*; defaults to ``in_force``. Scopes status-based commission rates. * - ``written_premium`` - decimal(14,2) - ✔ - Native-currency amount. * - ``currency`` - ISO 4217 - ✔ - Defaults to the tenant base; unknown codes rejected. A missing value is **never guessed** — the row is held in the batch's currency-gap queue. * - ``inception_date`` - date - — - Original policy issue date, **carried unchanged across renewals** — the anchor for years-in-force rate bands. New business: equals ``effective_date`` when omitted. Renewals/endorsements: send the original inception, or tenure-scoped rates cannot apply. * - ``effective_date`` - date - ✔ - Effective date *of this transaction* (a renewal has its own). * - ``expiration_date`` - date - — - * - ``as_of_date`` - date - — - Snapshot date; part of the idempotency key. Idempotency: unique on ``(tenant, external_policy_id, transaction_type, as_of_date)``. Re-sending the same tuple **updates** the row — safe to replay. **CommissionFact** — maps to ``integrations.IngestedCommissionTxn``. .. list-table:: :header-rows: 1 :widths: 22 16 8 54 * - Field - Type - Req - Notes * - ``producer`` - ``{scheme, value}`` - ✱ - As above. * - ``external_txn_id`` - string - ✔ - The PAS's stable txn id. Idempotency key. * - ``policy_number`` - string - — - Links the commission to a policy. * - ``kind`` - enum - ✔ - ``base`` · ``override`` · ``bonus`` · ``chargeback``. * - ``amount`` - decimal(14,2) - ✔ - Signed; a chargeback is a negative earning. * - ``currency`` - ISO 4217 - ✔ - Native currency; a batch may freely mix currencies. * - ``transaction_date`` - date - ✔ - Idempotency: unique on ``(tenant, external_txn_id)``. .. note:: A ``CommissionFact`` is the *amount as booked by the PAS's billing* — the settled figure, never a rate. tigerdistribute determines the authoritative commission from its own plans; ``CommissionReconciliation`` ties the two together. The tenant's **commission feed** setting decides whether ingested commissions are verified against the plan or the platform calculates from ingested policies (see :doc:`/user-guide/commissions`). Batch envelope ~~~~~~~~~~~~~~ The canonical batch shape (push & file), what ``/api/ingestion-batches/ingest/`` and ``/api/producer-ingest/`` accept: .. code-block:: json { "source": "guidewire-pc", "external_reference": "nightly-2026-07-08", "policies": [ /* PolicyFact[] */ ], "commissions": [ /* CommissionFact[] */ ] } * ``source`` — free-text feed label (the adapter sets it). * ``external_reference`` — the sender's batch id; **re-sending updates** the batch rather than duplicating it (batch-level idempotency). * Response — an ``IngestionBatch`` with ``policy_count``, ``commission_count`` and ``unresolved_count``. Producer identity ~~~~~~~~~~~~~~~~~ A fact names its producer with a ``(scheme, value)`` external identifier, not a bare number — this is what makes the platform global. .. list-table:: :header-rows: 1 :widths: 14 46 40 * - Scheme - Meaning - Registry * - ``NPN`` - US National Producer Number - NIPR / PDB * - ``FCA`` - UK FCA firm / individual reference - FCA * - ``CODE`` - Tenant-internal ``producer_code`` (fallback where no national registry applies) - — A fact whose identifier matches no producer is **kept, never dropped** — stored with a null producer and surfaced in the batch's *unresolved* queue for an operator to bind later. .. note:: The wire and resolver are **NPN-only today** (a ``producer_npn`` column; ``Producer.objects.filter(npn=…)``). Generalizing to ``(scheme, value)`` is planned work; the schema above shows the target form with the current NPN-only form noted inline. File ingress (bordereaux) ~~~~~~~~~~~~~~~~~~~~~~~~~~ ``POST /api/bordereaux-uploads/`` accepts a multipart CSV / XLSX (≤ 10 MB). The upload is parsed, its detected headers and a sample of rows returned, and a ``BordereauxMappingTemplate`` (source column → canonical field, managed at ``/api/bordereaux-templates/``) maps it onto the canonical schema before it enters the same ingestion pipeline. Stream ingress (event bus) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When a carrier runs an event backbone, a connection consumes it directly. The transport layer is pluggable behind a small ``read`` / ``ack`` interface: * **Redis Streams** is the implemented transport — a consumer group (``tigerdistribute``), at-least-once semantics, one stream per connection (``PASConnection.bus_topic``), each entry a JSON body the adapter's ``consume_message`` normalizes. * **Kafka** is a declared future transport, not yet built. Run the consumer with ``manage.py run_pas_bus_consumer``. A message left unacked (e.g. mid-crash) is redelivered — never dropped. Dry-run validation ~~~~~~~~~~~~~~~~~~~ ``POST /api/ingestion-batches/validate/`` runs a batch through the full normalize/resolve path and reports row counts, unresolved identifiers and currency gaps **without persisting anything** — the conformance check a new integration proves itself against. Outbound: domain events ----------------------- When something notable happens, tigerdistribute records an **outbox event** and delivers it to subscribers. Delivery uses a transactional outbox so an event is never lost if a downstream is briefly unavailable. Envelope & headers ~~~~~~~~~~~~~~~~~~~ Each event is POSTed as a signed JSON envelope: .. code-block:: json { "id": "c1b2a3d4-…", "event_type": "ProducerOnboardedV1", "aggregate_type": "producer", "aggregate_id": "a2d4f6e8-…", "occurred_at": "2026-07-08T02:05:00Z", "payload": { } } .. list-table:: :header-rows: 1 :widths: 34 66 * - Header - Purpose * - ``X-TigerDistribute-Event`` - The event type. * - ``X-TigerDistribute-Delivery`` - The event id — **subscribers dedupe on this**. * - ``X-TigerDistribute-Signature`` - ``sha256=`` — **verify before trusting**. Delivery semantics ~~~~~~~~~~~~~~~~~~ * **At-least-once** per endpoint — the same event can arrive more than once; dedupe on the delivery id. * An event is marked *published* only once **every** active endpoint has accepted it. * On failure, **exponential backoff** (60 s base, 1 h cap) over **8 attempts**, then dead-letter with a manual retry. * With no active endpoints, events wait as *pending* — nothing is lost. Register subscriber URLs at ``/api/webhook-endpoints/`` (the signing ``secret`` is write-only). Inspect and re-drive the queue at ``/api/outbox-events/`` (``relay/`` for an immediate sweep, ``/retry/`` to requeue a dead letter). Event catalog ~~~~~~~~~~~~~ All of the following are emitted today. .. list-table:: :header-rows: 1 :widths: 30 16 54 * - Event - Aggregate - Meaning * - ``ProducerOnboardedV1`` - producer - Producer activated; carries the syncable identity. * - ``ProducerUpdatedV1`` - producer - Name / identifier / status changed. * - ``ProducerTerminatedV1`` - producer - Producer ended. * - ``ContractSignedV1`` - contract - Producer agreement executed. * - ``AppointmentConfirmedV1`` - appointment - Producer may place in a jurisdiction / company. * - ``AppointmentTerminatedV1`` - appointment - Appointment ended. * - ``MarketAccessChangedV1`` - producer - A product-authorization change altered the can-place verdict. * - ``ProducerPayabilityChangedV1`` - producer - Commission hold / release (affects agency-bill netting). * - ``PayoutInstructedV1`` - payout - A payment run produced an instruction (see below). * - ``BackgroundCheckCompletedV1`` - producer - A screening result arrived. Egress mechanisms ~~~~~~~~~~~~~~~~~ The same outbox feeds two independent delivery paths; a connection chooses per tenant: * **Webhook relay** — a signed HTTP POST to every active ``WebhookEndpoint``. This is how a generic subscriber (including a downstream billing/AP system) receives events. * **PAS API-push** — for a PAS whose own API should be called, the connection's adapter translates the canonical event into that PAS's native API calls. Delivery state is tracked per ``(connection, event)`` independently of the webhook relay, with the same retry/backoff. An adapter declares which event types it applies via ``egress_event_types()``; every other event is skipped for that connection (never a failed delivery). Payout instructions ~~~~~~~~~~~~~~~~~~~ A **payment run** consolidates everything payable for a producer into one ``PayoutInstruction`` per ``(producer, currency)`` pot, nets same-currency debt against the ledger, applies the minimum-payout threshold, and publishes ``PayoutInstructedV1``. **tigerdistribute instructs; it does not move money** — a downstream system disburses exactly the net amount and confirms back. The payload carries the instruction id, producer, run date, currency, gross, recovery applied, **net amount**, and a ``sources`` array — one entry per statement or award consolidated (id, amount, period, and for awards the program name). Subscribe, disburse the net, and dedupe on the instruction id. The return leg is exposed on the payout API for the disbursing system's status callbacks: ``acknowledge`` (received), ``mark-paid`` (money went out — records an ``external_reference``) and ``release`` (publish a previously held instruction). Statuses flow ``held → pending → published → acknowledged → paid``. Writing a PAS adapter --------------------- Each PAS gets a first-class ``PASConnector`` subclass under ``integrations/connectors/``. The base class defines the normative interface; an adapter implements only the modes it declares in ``capabilities()`` — every other method stays ``NotImplementedError`` and is never invoked. The connector interface ~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python class PASConnector: source: str # unique key: "isotope", "guidewire", … def capabilities(self) -> set[str]: # subset of {"push", "file", "pull", "bus", # "push_status", "webhook_status"} ... # ---- ingress: each mode → canonical facts ---- def normalize_push(self, envelope) -> ConnectorResult: ... def parse_file(self, name, data) -> ConnectorResult: ... def pull(self, *, since=None) -> ConnectorResult: ... def consume_message(self, message) -> ConnectorResult: ... # ---- egress: apply canonical status via the PAS's own API ---- def egress_event_types(self) -> set[str]: ... def push(self, connection, event_type, payload) -> bool: ... Every ingress method returns the same ``ConnectorResult(policies=[…], commissions=[…])``. ``push()`` returns ``True`` when the PAS consumed the event, ``False`` when it isn't one this PAS applies (a no-op, not a failure). Registering & configuring ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Decorate the class with ``@register_connector`` and load its module in ``connectors/__init__.py``; it is then available by its ``source`` key through the registry. A tenant wires it up as a ``PASConnection`` (``/api/pas-connections/``): choose the ``source``, enable the ingress modes (gated so a mode is on only if **both** the tenant enables it and the adapter declares it), pick the ``egress_mode``, and store encrypted credentials. ``adapters`` lists what's available; ``test`` probes connectivity; ``sync`` pulls deltas. Bundled adapters ~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 20 32 48 * - Source - Status - Declared capabilities * - ``isotope`` - Reference adapter (full) - push · file · pull · push_status · webhook_status * - ``guidewire`` - Stub - pull · file * - ``sapiens`` - Stub - file · pull * - ``socotra`` - Stub - push · bus · webhook_status * - ``duck_creek`` - Stub - file · pull Stubs declare their typical integration shape but do not yet translate payloads — they are the starting point for a real adapter. Versioning & compatibility -------------------------- * **Events are versioned in the type name** (``…V1``). A breaking change ships as ``…V2``; both run in parallel until subscribers migrate. * **Inbound schema is additive-only within v1** — a new optional field never breaks a sender. A breaking change bumps the contract major. * **Identifier schemes are open-ended** — adding ``FCA`` / ``CODE`` / others is not a breaking change. * The REST surface itself is **unversioned in the URL** (flat ``/api/``); rely on the additive discipline above rather than a ``/v1/`` prefix. Conformance checklist --------------------- An integration is "done" when, against a sandbox tenant, it can: #. **Ingest** a PolicyFact + CommissionFact batch (any mode) and see counts with zero unexpected unresolved rows. #. **Resolve identity** — its producer identifiers match tigerdistribute producers (or are surfaced as unresolved and bindable). #. **Receive & verify** a signed webhook envelope (signature valid; dedupes on the delivery id). #. **Reconcile** — its commission facts tie to a statement via ``CommissionReconciliation``. #. **(If gating)** consume ``AppointmentConfirmedV1`` / ``MarketAccessChangedV1`` and gate binding on them. Use ``POST /api/ingestion-batches/validate/`` to prove step 1 without persisting. The live OpenAPI schema ----------------------- The inbound REST surface is described by an auto-generated OpenAPI schema (drf-spectacular): * ``/api/schema/`` — the raw schema. * ``/api/docs/`` — Swagger UI. * ``/api/redoc/`` — ReDoc. .. note:: These are currently served to admin users only. Relaxing the serve permission for a sandbox environment is what lets an external integrator self-serve the endpoint reference. Where the code lives -------------------- .. list-table:: :header-rows: 1 :widths: 40 60 * - Concern - Module * - Canonical inbound models - ``integrations/models.py`` — ``IngestedPolicy``, ``IngestedCommissionTxn``, ``IngestionBatch`` * - Ingestion pipeline - ``integrations/services/ingestion_service.py`` * - Inbound API - ``integrations/api/views.py`` — ``PASPushView``, ``IngestionBatchViewSet``, ``ProducerIngestView``, ``BordereauxUploadViewSet`` * - Adapter base & registry - ``integrations/connectors/base.py`` — ``PASConnector``, ``ConnectorResult``, ``PushEnvelope`` * - Reference adapter - ``integrations/connectors/isotope.py`` * - Bus transport - ``integrations/bus/`` — transport ABC + Redis Streams; ``services/bus_consumer_service.py`` * - Outbound publish - ``integrations/services/outbox_service.py`` * - Outbound relay & egress - ``integrations/services/relay_service.py``, ``pas_egress_dispatcher.py``, ``pas_egress_service.py`` * - Payout / settlement - ``commissions/services/payment_run_service.py`` * - Durable orchestration - ``temporal/workflows.py``, ``temporal/activities.py`` (see :doc:`/operations`)