Service-to-Service Communication

All inter-service calls on the Tawa platform go through Janus. Direct pod-to-pod calls are blocked by NetworkPolicy. This gives the platform a single point for gas metering, attribution, auth enforcement, and observability.

The Rule

Never hardcode K8s DNS URLs. Always use the {SERVICE}_URL env var injected by the builder.

The env var always points to Janus's internal proxy path (/i/{service}). Hardcoded K8s DNS URLs will fail with a connection refused — NetworkPolicy blocks them.

// CORRECT — use the injected env var
const docman = new DocmanClient({ url: process.env.DOCMAN_URL })

// WRONG — hardcoded K8s DNS, blocked by NetworkPolicy
const docman = new DocmanClient({ url: 'http://docman.docman-prod.svc.cluster.local:3000' })

Only Janus moves money (locked, 2026-09-24)

Every service-to-service call goes through Janus because Janus is the only thing allowed to move money. Your service never calls iec-wallet to debit or credit, never meters gas itself, and never holds wallet credentials. The target service prices its routes in spec.routes (gas, charge); you choose, per call, whether the user's org pays (forward X-Forward-User) or your org pays (send only your service token). Janus charges each hop of a call chain separately.

The full rule, the call-chain pricing model and the known platform gap — a route's charge is not yet carried through (iec-builder#182, iec-koko#38) — are in Gas & Wallet → Only Janus moves money.

Declaring Dependencies

Declare every service you call in catalog-info.yaml. The builder injects the URL and charge mode as env vars:

spec:
  dependencies:
    - service: docman         # injects DOCMAN_URL + DOCMAN_CHARGE_MODE
      scopes: [docman:generate]
      transport: janus
    - service: relay          # injects RELAY_URL + RELAY_CHARGE_MODE
      scopes: [relay:send]
      transport: janus
      charge: service         # this service absorbs the gas cost

See catalog-info.yaml reference for the full dependency format.

Gas Attribution

Correction (2026-10-04): /i/ was never metered before this change (iec-janus#10). A global free-infrastructure marker short-circuited the /i/ gas check, so no /i/ call ever debited a wallet or wrote a gas_events row, and earlier text here saying it "charges a flat 1 gas" was wrong in practice. Calls through /i/ are now charged the target route's declared gas, as below.

Every call through Janus is metered at the price the target route declares. Who pays is controlled by charge:

chargeWho paysHow Janus determines it
service (default)The calling service's orgorgSlug/programId from the service's own JWT
callerOrgThe calling user's orgorgSlug from the user's JWT, forwarded as X-Forward-User

On a dependency, charge is injected as {SERVICE}_CHARGE_MODE (default service — iec-builder src/services/dependency-resolver.ts:166) so your code knows whether to forward the user's token. Janus decides the payer from what the request carries: a verified X-Forward-User bills that user's org; without it, the calling service's org pays (iec-janus src/routes/internalProxyGas.ts). A valid X-Forward-User bills the user's org even when the caller is a platform-owned service. With charge: callerOrg on a priced route the header is required: without it the call is refused 401 proxy_forward_user_required. With charge: service the calling service pays even if a user token is sent.

Correction (2026-09-24): this table previously marked callerOrg as the default. The builder defaults {SERVICE}_CHARGE_MODE to service (verified on origin/main), matching the catalog reference.

Gas is always billed at the org level — never per individual user. The JWT carries both:

  • caller = payload.bioId — the individual (for audit only)
  • callerOrg = payload.orgSlug — the org that is billed

How the price is found

Janus prices an /i/ call by the path the target receives — everything after /i/{service}, query string dropped (/i/relay/send → /send; /i/septor/v1/events/emit → /v1/events/emit) — matched against that service's routes in Koko, method-aware. A literal match wins; otherwise a route registered with stripPrefix is tried with its prefix removed. There are no gateway fallbacks and no default price: a route without gas, an undeclared path or gas: 0 costs nothing, touches no wallet, and is recorded with charged: false. If several stripPrefix candidates carry different prices, the call is treated as undeclared and logged.

Forwarding user context (charge: callerOrg)

When charge: callerOrg, Janus needs the user's JWT to know which org to bill. Forward it via chargeContext in the SDK call:

// byte-mga route handler — user's org should pay for the docman call
app.post('/api/generate', authenticate, async (req, res) => {
  const doc = await docman.generate(
    { templateIds: [...], data: {...}, name: 'report' },
    {
      chargeContext: { userToken: req.headers.authorization }
                                      // ↑ forward exactly as received, including "Bearer " prefix
    }
  )
  res.json(doc)
})

The SDK sends this as X-Forward-User: Bearer {user-jwt}. Janus accepts the token with or without the Bearer prefix, verifies it via Bio-ID's JWKS endpoint and bills the user's orgSlug. A verified token with no orgSlug is refused as invalid rather than billed to the wrong org. The token cannot be tampered with — it is cryptographically verified.

X-Bill-Org — platform callers naming the payer

A platform-owned service client acting for someone else (a cron callback charging an agency) has no user token to forward. It can name the org that pays instead:

X-Bill-Org: acme-agency
  • Platform-owned service clients only. A user token, or a service that is not platform-owned, gets 403 bill_org_not_permitted.
  • It stands in for X-Forward-User: it satisfies charge: callerOrg, and that org pays. The call is recorded with payer bill_org and the sending service.
  • The org must already have a wallet. Janus does not create one: 422 bill_org_unknown.
  • Not combinable with X-Forward-User: 400 payer_ambiguous. A malformed slug is 400 bill_org_invalid.
  • If the org is out of gas the call gets 402, as for any payer.
  • X-Bill-Org: insureco (the platform org) is the same as no header.
  • On a system-paid route the header is ignored; the system wallet pays.

Third-party services do not use this header. Use X-Forward-User or send only your service token.

When to use charge: service instead

Use charge: service when your service absorbs the cost — for example:

  • Freemium features (users get some calls free, your org pays)
  • Internal tooling where your org owns the workflow end-to-end
  • Background jobs triggered by your service's own cron/queue
dependencies:
  - service: relay
    scopes: [relay:send]
    transport: janus
    charge: service   # our org pays for sending emails, not the user's org

No chargeContext needed — the SDK uses the service JWT automatically.

/internal/* Endpoints

Queue worker and cron callback endpoints use the /internal/ prefix by convention:

/internal/jobs/process-claim    ← called by iec-queue
/internal/cron/nightly-sync     ← called by iec-cron

These endpoints are restricted to platform services only. If a non-platform service tries to call another service's /internal/* path, Janus returns 403:

{
  "error": "internal_route_restricted",
  "message": "Internal routes (/internal/*) are reserved for platform services. They bypass gas metering and cannot be called by third-party services. Register a public route (/api/*) if you need to expose this operation.",
  "docs": "https://docs.tawa.insureco.io/reference/service-communication"
}

This prevents bypassing paid public routes via internal paths.

Platform-Initiated Calls (cron and queue)

When iec-cron or iec-queue calls your service, they route through Janus like any other caller. Your /internal/cron/* and /internal/jobs/* endpoints receive a verified service JWT (Authorization: Bearer header) — you can trust that the caller is a platform service.

Headers on cron callbacks

HeaderValue
AuthorizationBearer {platform-service-jwt}
X-Schedule-NameSchedule name from catalog-info.yaml
X-Cron-ExpressionThe cron pattern that fired
X-Fired-AtISO 8601 timestamp
Content-Typeapplication/json

Body: { scheduleName, namespace, firedAt }

Both req.headers['x-schedule-name'] and req.body.scheduleName carry the schedule name. Prefer the header.

Headers on queue callbacks

HeaderValue
AuthorizationBearer {platform-service-jwt}
X-Queue-NameQueue name from catalog-info.yaml
X-Job-IdUnique job identifier
X-Job-AttemptAttempt number (1-indexed)
Content-Typeapplication/json

Body: { jobId, queue, attempt, data }

Gas costs for platform-initiated calls

TriggerProductionSandbox
Cron schedule fires5 tokens (charged upfront to namespace owner)0 tokens
Queue job executed0 tokens (platform cost)0 tokens

Queue execution carries no gas because the cost was already charged when the user triggered the enqueue via a public API route. You do not need to think about gas inside your cron or queue handlers — the platform handles it before your endpoint is called.

SDK Startup Validation

Every system service SDK validates the injected URL at startup. If you see this error:

Error: [docman-sdk] DOCMAN_URL must route through Janus (/i/ path prefix).
Direct K8s DNS calls are blocked by NetworkPolicy.
Redeploy to get the correct URL injected by the builder.

It means DOCMAN_URL is set to a raw K8s DNS URL instead of a Janus proxy URL. Fix: ensure docman is declared under dependencies (default transport: janus) in catalog-info.yaml and redeploy.

If the env var is missing entirely:

Error: [docman-sdk] DOCMAN_URL is not set.
Declare docman under dependencies in catalog-info.yaml and redeploy.

Error Reference

Error codeStatusCauseFix
internal_route_restricted403Non-platform service calling /internal/*Use a public /api/* route instead
proxy_service_not_found404Service not in Koko registryCheck the service is deployed and declared under dependencies
proxy_target_unreachable503Target pod not respondingRun tawa pods to check pod status
proxy_unauthorized401Missing or invalid Authorization JWTEnsure BIO_CLIENT_ID/BIO_CLIENT_SECRET are configured
proxy_forward_user_invalid401X-Forward-User JWT tampered or expiredDo not modify the forwarded token — pass req.headers.authorization directly
proxy_forward_user_required401The route is priced and charge: callerOrg, but no X-Forward-User was sentForward the user's token as X-Forward-User (SDK: chargeContext)
bill_org_not_permitted403X-Bill-Org sent by a caller that is not a platform-owned service clientRemove the header; forward the user's token instead
payer_ambiguous400Both X-Forward-User and X-Bill-Org sentSend one
bill_org_invalid400X-Bill-Org is not an org slugSend the org slug
bill_org_unknown422The named org has no wallet to billCheck the slug; the org needs a wallet first
proxy_insufficient_gas402The paying org has no gas (never returned on a system-paid route)Run tawa wallet buy to top up
proxy_gas_unavailable503Janus could not reach iec-wallet (or Koko, to name the payer or to load route prices) — fails closed, nothing forwardedRetry; check iec-wallet and Koko health

Who pays, and what happens when they cannot (4 Oct 2026, R3 extended)

Janus resolves the payer before forwarding, and never lets a call through unmetered:

CallerPays
An org with a wallet (wallet-{orgSlug})Its own wallet
Any caller on a system-paid route (charge: system on a platform-owned route, or listed in the gas.systemPaidRoutes setting)The InsurEco system wallet, whatever the caller, X-Forward-User or X-Bill-Org says. Never refused for lack of gas
A platform-owned service client sending X-Bill-Org: <org>That org's wallet (payer bill_org); the org must already have a wallet
A platform-owned service client calling without an X-Forward-User — a Tawa service whose Koko owner is insureco, or a client id on Janus's PLATFORM_SERVICE_CLIENTSThe InsurEco system wallet, metered, never exempt
Anyone else with no wallet yetJanus creates the wallet (/ensure); then promo first, purchased Tawa next, then 402
Wallet or Koko unavailable while resolving503 proxy_gas_unavailable (gateway routes: GAS_UNAVAILABLE) — the call is not forwarded

Platform service → platform-owned service over /i/ with no X-Forward-User is internal traffic and stays free; it is recorded with the shadow price, charged: false, reason platform_internal. A valid X-Forward-User overrides that: the user's org pays, whoever the caller is. If route prices cannot be loaded from Koko (boot window or outage), platform → platform calls stay free (platform_internal_price_unavailable) and everything else gets 503 with reason route_price_unavailable. Free calls (zero_price, undeclared_route, platform_internal) and every refusal (including forward_user_invalid and proxy_forward_user_required) are written to Janus's gas_events with charged: false and a reason, so each is visible. /internal/* calls are unchanged and not recorded.

Full Example: byte-mga calling docman

catalog-info.yaml

spec:
  dependencies:
    - service: docman
      charge: callerOrg    # the user's org pays for document generation
      # transport omitted → defaults to janus

Handler

import { DocmanClient } from '@insureco/docman'

const docman = new DocmanClient({ url: process.env.DOCMAN_URL })
// SDK validates DOCMAN_URL contains /i/ at startup — throws if wrong

app.post('/api/generate-report', authenticate, async (req, res) => {
  const doc = await docman.generate(
    {
      templateIds: ['report-template'],
      data: req.body,
      name: 'quarterly-report',
    },
    {
      chargeContext: { userToken: req.headers.authorization },
      // ↑ Janus verifies this JWT and bills req.user.orgSlug
    }
  )
  res.json({ success: true, docId: doc.id })
})

What happens on the wire:

  1. byte-mga POSTs to http://janus.janus-prod.svc.cluster.local:3000/i/docman/api/generate
  2. Janus sees Authorization: Bearer {byte-mga-service-jwt} + X-Forward-User: Bearer {user-jwt}
  3. Janus verifies both tokens via JWKS
  4. Janus looks up docman in Koko → gets K8s DNS URL
  5. Janus forwards to http://docman.docman-prod.svc.cluster.local:3000/api/generate
  6. docman responds 200
  7. Janus bills the user's orgSlug (from X-Forward-User) the price of docman's matching route. If that route declares no gas (or gas: 0) the call is free and recorded charged: false
  8. Janus returns docman's response to byte-mga

Last updated: October 6, 2026