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.
Never hardcode K8s DNS URLs. Always use the
{SERVICE}_URLenv 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' })
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.
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.
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 agas_eventsrow, 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 declaredgas, as below.
Every call through Janus is metered at the price the target route declares. Who pays is
controlled by charge:
charge | Who pays | How Janus determines it |
|---|---|---|
service (default) | The calling service's org | orgSlug/programId from the service's own JWT |
callerOrg | The calling user's org | orgSlug 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
callerOrgas the default. The builder defaults{SERVICE}_CHARGE_MODEtoservice(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 billedJanus 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.
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 payerA 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
403 bill_org_not_permitted.X-Forward-User: it satisfies charge: callerOrg, and that org pays. The call is
recorded with payer bill_org and the sending service.422 bill_org_unknown.X-Forward-User: 400 payer_ambiguous. A malformed slug is 400 bill_org_invalid.402, as for any payer.X-Bill-Org: insureco (the platform org) is the same as no header.Third-party services do not use this header. Use X-Forward-User or send only your service token.
charge: service insteadUse charge: service when your service absorbs the cost — for example:
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/* EndpointsQueue 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.
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.
| Header | Value |
|---|---|
Authorization | Bearer {platform-service-jwt} |
X-Schedule-Name | Schedule name from catalog-info.yaml |
X-Cron-Expression | The cron pattern that fired |
X-Fired-At | ISO 8601 timestamp |
Content-Type | application/json |
Body: { scheduleName, namespace, firedAt }
Both req.headers['x-schedule-name'] and req.body.scheduleName carry the schedule name.
Prefer the header.
| Header | Value |
|---|---|
Authorization | Bearer {platform-service-jwt} |
X-Queue-Name | Queue name from catalog-info.yaml |
X-Job-Id | Unique job identifier |
X-Job-Attempt | Attempt number (1-indexed) |
Content-Type | application/json |
Body: { jobId, queue, attempt, data }
| Trigger | Production | Sandbox |
|---|---|---|
| Cron schedule fires | 5 tokens (charged upfront to namespace owner) | 0 tokens |
| Queue job executed | 0 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.
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 code | Status | Cause | Fix |
|---|---|---|---|
internal_route_restricted | 403 | Non-platform service calling /internal/* | Use a public /api/* route instead |
proxy_service_not_found | 404 | Service not in Koko registry | Check the service is deployed and declared under dependencies |
proxy_target_unreachable | 503 | Target pod not responding | Run tawa pods to check pod status |
proxy_unauthorized | 401 | Missing or invalid Authorization JWT | Ensure BIO_CLIENT_ID/BIO_CLIENT_SECRET are configured |
proxy_forward_user_invalid | 401 | X-Forward-User JWT tampered or expired | Do not modify the forwarded token — pass req.headers.authorization directly |
proxy_forward_user_required | 401 | The route is priced and charge: callerOrg, but no X-Forward-User was sent | Forward the user's token as X-Forward-User (SDK: chargeContext) |
bill_org_not_permitted | 403 | X-Bill-Org sent by a caller that is not a platform-owned service client | Remove the header; forward the user's token instead |
payer_ambiguous | 400 | Both X-Forward-User and X-Bill-Org sent | Send one |
bill_org_invalid | 400 | X-Bill-Org is not an org slug | Send the org slug |
bill_org_unknown | 422 | The named org has no wallet to bill | Check the slug; the org needs a wallet first |
proxy_insufficient_gas | 402 | The paying org has no gas (never returned on a system-paid route) | Run tawa wallet buy to top up |
proxy_gas_unavailable | 503 | Janus could not reach iec-wallet (or Koko, to name the payer or to load route prices) — fails closed, nothing forwarded | Retry; check iec-wallet and Koko health |
Janus resolves the payer before forwarding, and never lets a call through unmetered:
| Caller | Pays |
|---|---|
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_CLIENTS | The InsurEco system wallet, metered, never exempt |
| Anyone else with no wallet yet | Janus creates the wallet (/ensure); then promo first, purchased Tawa next, then 402 |
| Wallet or Koko unavailable while resolving | 503 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.
spec:
dependencies:
- service: docman
charge: callerOrg # the user's org pays for document generation
# transport omitted → defaults to janus
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:
http://janus.janus-prod.svc.cluster.local:3000/i/docman/api/generateAuthorization: Bearer {byte-mga-service-jwt} + X-Forward-User: Bearer {user-jwt}http://docman.docman-prod.svc.cluster.local:3000/api/generateorgSlug (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: falseLast updated: October 6, 2026