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' })
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.
Every call through Janus is metered. Who pays is controlled by charge:
charge | Who pays | How Janus determines it |
|---|---|---|
callerOrg (default) | The calling user's org | orgSlug from the user's JWT |
service | The service owner's org | orgSlug 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 billedcharge: 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.
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_insufficient_gas | 402 | Caller org has no gas balance | Run tawa wallet buy to top up |
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) for 5 gas tokensLast updated: July 15, 2026