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' })

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

Every call through Janus is metered. Who pays is controlled by charge:

chargeWho paysHow Janus determines it
callerOrg (default)The calling user's orgorgSlug from the user's JWT
serviceThe service owner's orgorgSlug from the service's JWT

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

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 verifies the token via Bio-ID's JWKS endpoint and bills the user's orgSlug. The token cannot be tampered with — it is cryptographically verified.

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_insufficient_gas402Caller org has no gas balanceRun tawa wallet buy to top up

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) for 5 gas tokens
  8. Janus returns docman's response to byte-mga

Last updated: July 15, 2026