import { Septor } from '@insureco/septor'
const septor = new Septor({
apiUrl: process.env.SEPTOR_URL,
namespace: process.env.SERVICE_NAME || 'my-service',
})
// Fire-and-forget — NEVER await septor on the critical path
septor.emit('payment.created', {
entityId: orgSlug,
data: { amount, referenceId: paymentId },
metadata: { who: userId },
}).catch((err) => logger.error({ err }, 'Septor emit failed — audit event lost'))
Declare nothing. SEPTOR_URL is auto-injected on every deploy, and since
iec-builder 9e0a2d0 (2026-06-22) the builder also applies the
tawa.pro/direct-dep.septor=true NetworkPolicy label on every deploy, whether or
not you declare septor. The same holds for iec-queue.
In the builder, ALWAYS_DIRECT_DEP_SERVICES = ['septor', 'iec-queue'] is unioned into
the namespace's direct-dep labels before the catalog is even consulted, so
computeDirectDepServices(undefined) still returns both.
If you declare septor anyway, the builder warns and ignores the entry:
septor is an auto-injected platform service — SEPTOR_URL and its NetworkPolicy direct-dep label are applied on every deploy. Declaring it in spec.dependencies is harmless but unnecessary; the entry will be ignored.
That warning is correct — act on it.
Superseded guidance. Before
9e0a2d0this page said you MUST declare septor ininternalDependenciesor lose the audit trail, and told you to ignore the builder's warning. That was true until 2026-06-22 and is now wrong on both counts. The declaration is dead weight.One caveat: the label is applied at deploy time. A service that never declared septor and has not deployed since 2026-06-22 does not carry the label yet — its next deploy adds it.
Septor verifies a Bio-ID token on every emit/query. The SDK (≥ 1.4.0) handles
this automatically: when no apiKey is passed, it mints a client_credentials
token from BIO_CLIENT_ID / BIO_CLIENT_SECRET / BIO_ID_URL and refreshes it
ahead of expiry. No per-app token code is needed.
This means your service MUST have Bio-ID credentials. Declare spec.auth in
catalog-info.yaml so the builder injects BIO_CLIENT_ID / BIO_CLIENT_SECRET:
spec:
auth:
mode: sso # or: service-only (no user login, creds still injected)
Without spec.auth, those vars are absent and every Septor emit fails with
Authorization header required (the audit trail is silently lost since emits
are fire-and-forget). See https://tawa.insureco.io/reference/convention-septor
Pin
@insureco/septorto^1.5.0. Earlier versions do not authenticate and will be rejected by Septor.
Pin an explicit version — never latest. A latest dependency silently changes
under the service on the next image build. bio-graph depended on latest, which
resolved to 0.1.1 at build time (a version that no longer authenticates and is no
longer published) while the registry's latest tag had long since moved to 1.5.0.
Verify at startup, not on the request path. SDK 1.5.0 adds assertConfigured(),
which throws when the client cannot authenticate. Call it from a boot check or health
probe so a missing credential is loud immediately — never from a request handler.
A service that does not run as a K8s pod receives none of the builder-injected env, so nothing above happens for free. It must set all four itself:
| Var | Value for a non-K8s host |
|---|---|
SEPTOR_URL | https://septor.tawa.pro — the external URL. Cluster DNS (septor.septor-prod.svc.cluster.local) is NOT routable off-cluster. |
BIO_ID_URL | https://bio.insureco.io |
BIO_CLIENT_ID / BIO_CLIENT_SECRET | a Bio-ID OAuth client with the client_credentials grant and an orgSlug — Septor derives the event's owning org from that claim, and rejects a token without one. |
iec-builder is the reference case: it runs under PM2 on the tawa-builder host and
had none of these, so it recorded zero audit events from inception until 2026-08-31.
A green deploy is not proof. Confirm the namespace appears:
koko query events -s septor -e prod --json -f '{"namespace":"<your-namespace>"}' -l 3
A first-ever event returns chainIndex: 0. If the namespace is absent entirely, the
service has never successfully authenticated.
A Septor outage must NEVER break your service. Always fire-and-forget:
// ✅ CORRECT
septor.emit('payment.created', { ... })
.catch((err) => logger.error({ err }, 'Septor emit failed — audit event lost'))
// ❌ WRONG: blocking your user on an audit write
await septor.emit('payment.created', { ... })
The InsurEco system wallet pays the gas for audit events (decision, 4 Oct 2026). Janus treats
POST /v1/events/emit on septor as a system-paid route (the gas.systemPaidRoutes setting): the emitting
service and its org are never billed and an emit is never refused for lack of gas. Do not add gas
handling, caching or batching to your emit calls for billing reasons.
Not live yet. Services call Septor directly through
SEPTOR_URL, a cluster URL that bypasses Janus, so today an emit costs the emitter nothing and the system wallet is not charged either. The system-paid rule takes effect when a builder change routesSEPTOR_URLthrough Janus. Nothing changes in your code when it does. See Gas & Wallet.
POST /v1/events/emit is limited per authenticated caller, not per IP. The
key is the caller's Bio-ID identity (orgSlug + sub), so it is stable across
pod restarts and rescheduling, and two services sharing an egress path never
share a bucket.
| Ceiling | 600 emits/min per caller (SEPTOR_EMIT_RATE_LIMIT) |
| Window | 60s, fixed |
| On rejection | 429 + Retry-After + RateLimit (draft-7) headers |
| Response code | rate_limit_exceeded |
600 is derived from observed production load — the busiest caller peaks at ~46 emits in its worst minute — so normal fan-out bursts never approach it. Anonymous traffic is rejected by auth before the limiter, so this ceiling only ever bounds a runaway first-party client.
If Septor does refuse an emit it logs AUDIT_EVENT_REJECTED server-side with the
namespace, event type, entity ID and caller, so a dropped compliance record can
be identified and replayed. Do not rely on your own logs to catch this — the
fire-and-forget .catch() in your service is the only signal you get, and the
SDK does not retry a 429 (see below).
The SDK does not honour
Retry-After.@insureco/septor(through 1.4.0) surfaces a 429 as a thrown error with no retry. If you emit in a tight fan-out loop, stagger it or batch it rather than assuming the client backs off.
| Category | Required Events |
|---|---|
| Payments | payment.created, payment.completed, payment.failed, payment.refunded |
| Policies | policy.bound, policy.endorsed, policy.cancelled, policy.renewed |
| Compliance | ofac.cleared, ofac.flagged, kyc.verified, kyc.failed |
| Authorization | user.login, permission.granted, permission.revoked |
| Data Access | record.accessed, report.exported, data.modified |
Automatically written by the platform (no code needed):
Use {resource}.{action} dot notation, lowercase:
payment.created ✅
policy.bound ✅
ofac.screen.cleared ✅ (nested resource.sub.action is OK)
payment-created ❌ (hyphens — use dots)
createPayment ❌ (camelCase)
PAYMENT_CREATED ❌ (uppercase)
const { data } = await septor.query({
entityId: orgSlug,
eventType: 'payment.created', // optional filter
from: '2024-01-01',
to: '2024-01-31',
limit: '100',
})
for (const event of data.events) {
console.log(event.eventType, event.createdAt, event.metadata.who)
}
// Run in background jobs, not request handlers — this is O(n)
const { data } = await septor.verify(orgSlug)
if (!data.valid) {
logger.error({ brokenAt: data.brokenAt }, 'Audit chain broken!')
}
Septor's hash chain is tamper-evident, but only to someone who trusts Septor's database. So every
night Septor builds a Merkle root over every event recorded the previous UTC day and anchors it
on both PolicyBlockchain chains — Settlement 1117710 and InsureBio 1140615 — in
AnchorRegistry at 0x16d42af095560822034255b22360c5f9BEBafE80 (same address on both). Only
the root goes on chain, never event data. Nothing to declare: every namespace is included.
GET /v1/proofs/:eventId service (septor:query)
→ { eventHash, leaf, leafIndex, proof[], root, batchId,
anchors: [{ chainId, txHash, blockNumber, explorerUrl }] }
GET /v1/batches/:batchId service — one batch with its leaf list
GET /v1/anchors?from=&to= public — anchored roots and tx hashes, no event data
An event becomes provable after the next nightly batch (00:10 UTC; retried hourly to 03:10; a
04:05 check emits anchor.missed if a chain was not reached). Before that, /v1/proofs answers
404 not_batched.
Verify without trusting Septor: call AnchorRegistry.verify(batchId, eventHash, proof) on
either chain, or run backend/scripts/verify-proof.mjs from the septor repo, which recomputes the
root from the proof and compares it with getAnchor(batchId) over RPC. Leaf =
keccak256(keccak256(abi.encode(bytes32 eventHash))); internal nodes are sorted pairs, so
OpenZeppelin MerkleProof.verify accepts the proof as is.
verify returns false for a batch the admin has invalidated (a compromised publisher key).https://scan.policyblockchain.com/tx/<hash>, InsureBio
https://scan.insurebio.com/tx/<hash>.Source: septor#21/#22, iec-contracts#7.
entityId is the primary index — use a stable identifier (orgSlug or userId).catch() is a compliance gap — failures must be logged// ❌ WRONG: blocking on audit write
await septor.emit(...)
// ❌ WRONG: silent failure
septor.emit(...).catch(() => {}) // swallows the error — compliance gap
// ❌ WRONG: vague event type
septor.emit('event.happened', ...)
// ✅ CORRECT
septor.emit('payment.refunded', {
entityId: orgSlug,
data: { amount, reason, refundId },
metadata: { who: userId, why: 'Customer requested refund' },
}).catch((err) => logger.error({ err }, 'Septor emit failed — audit event lost'))
Last updated: October 6, 2026