Skip to content
Updated Jun 9, 2026

Broker API

The Broker API is the external-facing surface for licensed brokers and agents. It lets a broker run a quote through to issuance, see the policies they sold, create client accounts and parties, and track the commission they earned, all scoped to the authenticated broker. Owned by the broker-api service. Most of its work is a thin proxy over Enrollment and Policy Admin; the data it actually owns is commissions and per-broker delegated-authority configuration.

Field reference: column-level detail lives in the catalog under the broker_api schema (broker_configs, commissions). This page is the narrative.

Authentication and scoping

Every request (apart from /healthz and /readyz) goes through two middlewares: JWT validation against Keycloak, then a broker-role check. The role check requires a broker or broker-admin role (read from a flat roles claim or from Keycloak's nested resource_access.<client>.roles) and a non-empty brokerId claim. The brokerId is injected into the request context and is the scoping key for every operation: a broker only ever sees their own commissions, their own authority config, and policies whose broker_locator matches their brokerId. There is no per-route admin gate today; broker and broker-admin are treated identically by every handler.

What it owns

The service owns two tables in the broker_api schema.

TablePurpose
broker_configsOne row per broker (broker_id is UNIQUE). Holds delegated_authority_limit NUMERIC(12,2) and default_commission_rate NUMERIC(5,4), plus locator. default_commission_rate is the contracted rate applied when a commission is auto-recorded on issuance, or when a manual commission omits a rate.
commissionsOne commission per (broker_id, policy_locator). Holds rate NUMERIC(5,4), amount NUMERIC(12,2), and status.

Everything else (quotes, policies, accounts, parties) lives in the upstream services; the Broker API holds none of it.

Commissions

Commission amount is always derived server-side, never supplied by the caller. The amount is round(premium × rate) to whole pence, where premium is resolved by looking up the policy in Enrollment and summing its PREMIUM charges in Billing. The request body for POST /commissions carries only policyLocator and an optional rate; there is deliberately no amount field. When rate is omitted (or zero) the broker's contracted default_commission_rate is used. The rate must be in (0, 1], otherwise the request is rejected with 400.

The primary path is automatic. The service consumes Enrollment's policy.issued events and records the commission for the issuing broker without any manual call (see Events). POST /commissions exists as a manual override for cases the consumer did not cover; it runs the same server-side derivation. The consumer path is idempotent per (broker_id, policy_locator) (it looks up an existing commission first). The manual POST is not: it always inserts, and there is no DB unique constraint on (broker_id, policy_locator), so a repeat call creates a duplicate.

status is constrained by a DB CHECK to exactly two values: PENDING (default on create) and PAID. PATCH /commissions/{locator}/pay is the only transition; calling it on an already-PAID commission returns 422. These statuses are local TEXT enums in the broker_api schema, not shared packages/go/domain enums.

Delegated authority

broker_configs.delegated_authority_limit caps the premium a broker is authorised to bind. GET /authority returns the broker's config; PUT /authority upserts both the limit and the default commission rate. The check is premium <= limit, and a broker with no config row is denied by default.

Enforcement is currently detect-only, not blocking. The authority check runs inside the policy.issued consumer, after the policy is already bound. A breach (premium over the limit, or no config) is logged as a warning for follow-up; it does not reverse the policy or stop the commission being recorded. Bind-time enforcement would need a quote-stage premium and is not wired.

API Routes

All routes below require a valid JWT carrying a broker or broker-admin role and a brokerId claim. /healthz and /readyz are unauthenticated.

MethodPathDescription
POST/quotesCreate a quote for a client (proxies to Enrollment, stamping the broker as brokerLocator)
GET/quotes/{locator}Get a quote
PATCH/quotes/{locator}/pricePrice a quote
PATCH/quotes/{locator}/underwriteUnderwrite a quote
PATCH/quotes/{locator}/issueIssue a quote into a policy
GET/portfolio/policiesList policies in the broker's book (paginated; Enrollment filters by brokerId)
GET/portfolio/policies/{locator}Get one policy, returning 403 if its broker_locator is not the caller's brokerId
POST/clients/accountsCreate a client account (proxies to Policy Admin)
POST/clients/partiesCreate a client party (proxies to Policy Admin)
GET/commissionsList the broker's commissions (paginated)
POST/commissionsManually record a commission (amount derived server-side, never sent by the caller)
GET/commissions/{locator}Get a commission (403 if it belongs to another broker)
PATCH/commissions/{locator}/payMark a PENDING commission PAID
GET/authorityGet the broker's delegated-authority config
PUT/authorityUpsert the broker's authority limit and default commission rate

Events

Broker events use the canonical envelope: eventId, eventType, occurredAt, client lineage (sessionId / activityId / activityName, lifted from W3C baggage), payload, and a state key that freezes the full subject entity as it existed at emit time ({"commission": ...}, {"brokerConfig": ...}, {"policy": ...}). See the Kafka Event Catalog for the envelope and the Event Registry for per-type contracts; the machine-readable contracts and golden examples live in packages/go/domain/eventregistry/registry/<eventType>/.

Consumes

The service consumes the enrollment topic (enrollment-events by default, KAFKA_ENROLLMENT_TOPIC) with group ID broker-api. It filters envelopes for eventType == "policy.issued" and ignores the rest. The producer side is Enrollment (EventPolicyIssued = "policy.issued").

On each policy.issued the consumer fetches the policy from Enrollment. Policies with no broker_locator are skipped (only broker-sold policies earn commission). It then resolves the premium from Billing; because Billing generates the premium charges from the same event, the consumer polls (default 5 attempts, 2s apart) before giving up. If the premium is still unavailable the offset is not committed and the event is reprocessed later. A broker with no contracted rate is logged and skipped (offset committed) rather than retried forever.

Publishes

The service publishes five event types, all to the broker.events topic (pre-created; Kafka auto-create is off), keyed on brokerId. Publishing is direct to Kafka from the emit site, not via an outbox: there is no correlationId stamping, and a publish error is ignored (the state change still commits), so delivery is best-effort.

eventTypeEmitted whenstate subjects
broker.appointedPUT /authority creates the first broker_configs row for a brokerbrokerConfig
broker.authority.updatedPUT /authority amends an existing config rowbrokerConfig
broker.authority.breachedthe policy.issued projector finds the issued premium above the broker's limit, or no config row exists (detect-only, after bind)policy
commission.earneda commission is recorded at PENDING: the policy.issued projector (idempotent) or the manual POST /commissions (not idempotent, can emit duplicates)commission
commission.paidPATCH /commissions/{locator}/pay flips a commission to PAIDcommission

broker.authority.breached and the projector-path commission.earned are consumer-driven, so they carry no client session lineage; the interactive paths (PUT /authority, POST /commissions, PATCH .../pay) carry the broker portal's session baggage when present. The only consumer of broker.events today is the debugger, which validates every event against its registry contract.

Dependencies

ServiceHow used
EnrollmentQuote create/price/underwrite/issue, portfolio policy listing, and policy lookup (broker_locator, policy ID for the premium join). Source of the policy.issued events consumed.
BillingPremium lookup: SumPremiumByPolicyID sums a policy's PREMIUM charges. This premium feeds every commission amount.
Policy AdminClient account and party creation (proxied through).
KafkaSubscribes to the enrollment topic to auto-record commissions on issuance; publishes broker events to broker.events.

Eligibility is wired but not exposed

An EligibilityClient is constructed and placed in the handler dependencies, but no route uses it. No broker can invoke an eligibility check through this service today. Treat client-coverage lookups as planned, not implemented.

Invariants

  • Every request is scoped to the JWT brokerId. Brokers cannot read or modify another broker's commissions or authority config; cross-broker access returns 403.
  • Commission amount is round(premium × rate) and is always derived server-side. The caller never supplies an amount; supplying a rate is optional and falls back to the broker's contracted default_commission_rate.
  • rate must be in (0, 1]; an invalid rate is rejected with 400.
  • Commission recording is idempotent on the consumer path (per (broker_id, policy_locator)); the manual POST is not (it always inserts, no DB unique constraint), so it can create duplicates.
  • commissions.status is PENDING → PAID only, enforced by a DB CHECK. pay on an already-paid commission returns 422.

Caveats

  • Authority is detect-only. The delegated-authority limit is checked after the policy is bound and only logs a breach; it does not block binding or reverse anything. Bind-time enforcement is not implemented.
  • No admin boundary. PUT /authority and every other route accept broker and broker-admin equally. A plain broker can set their own authority limit and default commission rate.
  • Local enums, not shared. commissions.status is a broker_api-local TEXT enum. The broker concept reaches packages/go/domain only as Policy.BrokerLocator (a nullable *string field), not as any shared status enum.
  • Premium timing. Because Billing produces premium charges asynchronously from the same policy.issued event, auto-commissioning depends on the consumer's poll-and-retry. A commission appears once the premium charges exist.

Olly Health Insurance Platform