Custom Events
Tenant-defined events — register a typed schema, emit payloads, and route them through the bus.
The client.events.custom namespace lets tenants register their own typed event shapes with PlatformXe, emit payloads against them, and let the bus route to subscribers (webhooks, workflows, federation peers). Schemas are JSON Schema 2020-12 (OpenAPI 3.1 component schemas accepted as a strict superset). Available in TypeScript, Python, Go, and Terraform.
| SDK | Install | Namespace |
|---|---|---|
| TypeScript | npm install @caldera/platformxe-sdk | client.events.custom |
| Python | pip install platformxe | client.events.custom |
| Go | go get github.com/calderax/platformxe-go | client.Events.Custom |
| Terraform | source = "calderax/platformxe" | platformxe_custom_event |
Plan tiers
| Tier | Register your own | Emit generic | Emit registered | Monthly emits | Versioning |
|---|---|---|---|---|---|
| Free | — | ✅ | — | 1,000 | — |
| Basic | up to 5 | ✅ | ✅ | 10,000 | latest only |
| Pro | up to 25 | ✅ | ✅ | 100,000 | full semver |
| Enterprise | unlimited | ✅ | ✅ | unlimited | full semver + federation |
Every organisation is auto-seeded with the platform-provided platformxe.generic catch-all event so even Free-tier tenants can experiment with the engine without registering anything first. Use the kind field on the generic payload to route to the correct subscriber.
Quick start — emit the generic event (Free-tier friendly)
await client.events.custom.emit({
name: 'platformxe.generic',
payload: {
kind: 'user.signed_up',
summary: 'New user joined',
data: { userId: 'usr_123', plan: 'free' },
},
});
client.events.custom.emit(
name="platformxe.generic",
payload={
"kind": "user.signed_up",
"summary": "New user joined",
"data": {"userId": "usr_123", "plan": "free"},
},
)
_, err := client.Events.Custom.Emit(platformxe.EmitCustomEventInput{
Name: "platformxe.generic",
Payload: map[string]interface{}{
"kind": "user.signed_up",
"summary": "New user joined",
"data": map[string]interface{}{"userId": "usr_123", "plan": "free"},
},
})
Register a typed event (Basic+)
Schemas are immutable per (namespace, name, version). Evolve a shape by registering a new version — bump rules: patch must be byte-equal except for description; minor must be backwards-compatible; major is free-form.
await client.events.custom.register({
namespace: 'lettings',
name: 'property.favorited',
version: '1.0.0',
status: 'published',
description: 'Fired when a customer favorites a property',
payloadSchema: {
type: 'object',
properties: {
propertyId: { type: 'string', maxLength: 64 },
subjectId: { type: 'string', maxLength: 64 },
favoritedAt: { type: 'string', format: 'date-time' },
},
required: ['propertyId', 'subjectId'],
additionalProperties: false,
},
});
resource "platformxe_custom_event" "property_favorited" {
namespace = "lettings"
name = "property.favorited"
version = "1.0.0"
status = "published"
payload_schema = jsonencode({
type = "object"
properties = {
propertyId = { type = "string", maxLength = 64 }
subjectId = { type = "string", maxLength = 64 }
favoritedAt = { type = "string", format = "date-time" }
}
required = ["propertyId", "subjectId"]
additionalProperties = false
})
}
Emit a registered payload
const result = await client.events.custom.emit({
name: 'lettings.property.favorited',
payload: {
propertyId: 'p_123',
subjectId: 'sub_456',
favoritedAt: '2026-05-04T10:00:00Z',
},
// Optional: pin to a specific version. Omit to route to latest published.
version: '1.0.0',
});
result.canonicalName; // 'TENANT_CUSTOM:org_x:lettings.property.favorited@1.0.0'
result.status; // 'queued'
The bus relays under the canonical name; subscribers (webhooks, workflows, federation peers) match by exact string. Idempotency: pass idempotencyKey to dedupe replays — the audit row's unique index prevents duplicate inserts.
Validate a template before persisting (dry-run)
const verdict = await client.events.custom.dryRun({
namespace: 'lettings',
name: 'property.favorited',
version: '1.0.0',
payloadSchema: { /* … */ },
});
verdict.valid; // true
verdict.semverBump; // 'initial' | 'patch' | 'minor' | 'major'
verdict.schemaHash; // sha-256 hex
verdict.schemaFlavour; // 'json-schema-2020-12' | 'openapi-3.1'
Used by terraform plan previews and the SDK type-generation workflow to catch schema problems before they reach the registry.
Health & usage
const h = await client.events.custom.health();
h.plan; // 'BASIC'
h.registrations.used; // 4
h.registrations.limit; // 5 (or null when plan is unlimited)
h.emits.thisMonth; // 9_812
h.emits.monthlyLimit; // 10_000 (or null)
h.recentFailures; // last 10 emits with deliveryStatus='FAILED'
Sub-resources
Two opt-in sub-namespaces extend the base client.events.custom surface:
client.events.custom.marketplace— (Pro+) publish a registered event for other tenants to fork into their own org. Browsing is open to all plans; publishing and forking are Pro+. See Marketplace.client.events.custom.federation— (Enterprise only) push live emit traffic to peer Enterprise orgs through bus-level fan-out. An owner creates a group, invites peers, and declares per-version pushes; the platform re-emits on each accepted peer's canonical name automatically. Since 1.5.0 — Pattern 3: peers can also be arbitrary HTTPS endpoints addressed by URL + HMAC secret, useful when the receiving system isn't on PlatformXe. See Federation.
These are entirely separate flows from client.permissions.federation (the v1.x.x admin permissions cross-app sync) — different concept, different access path.
Reference — namespaces & naming
- Namespace —
^[a-z][a-z0-9_-]{1,30}$. Reserved namespaces (identity,fraud,permissions,messaging,webhooks,workflows,auth,platform,platformxe,events,subscriptions,domains,templates,generic) are blocked globally. Tenants can only register under namespaces on their org's allowlist — seeded with the org slug at onboarding; ops grants additional namespaces. - Name —
^[a-z][a-z0-9_.-]{1,60}$. Dots in names are allowed (e.g.property.favorited). - Version — strict
MAJOR.MINOR.PATCH(no prereleases or build metadata). Single-step bumps only. - Schema caps — depth ≤ 5, total properties ≤ 50 (across all nested levels). Payload size ≤ 256 KB.
- Canonical name —
TENANT_CUSTOM:<organizationId>:<namespace>.<name>@<version>. Org id in the bus name guarantees strict isolation between tenants.
Reference — error codes
| Code | Status | Cause |
|---|---|---|
invalid_namespace | 400 | Namespace fails the regex |
reserved_namespace | 400 | Tried to use a globally-reserved namespace |
namespace_not_allowed | 400 | Namespace not on this org's allowlist |
invalid_name / invalid_version_format | 400 | Shape error |
cannot_register_generic_event | 400 | Tenants cannot self-register platformxe.generic |
version_already_exists / version_downgrade / version_invalid_jump | 400 | Semver bump issue |
patch_must_be_byte_equal | 400 | Patch bump changed the schema beyond description |
minor_must_be_backwards_compatible | 400 | Minor bump is breaking |
schema_too_deep / schema_too_wide | 400 | DoS guard tripped |
invalid_json_schema | 400 | Ajv compile error |
payload_example_does_not_match_schema | 400 | Example doesn't satisfy own schema |
registration_quota_exceeded | 402 | Plan cap hit |
event_not_registered | 404 | Emit target not found / not published |
event_not_published | 409 | Emit target is draft or archived |
payload_invalid | 400 | Emit payload fails the registered schema |
payload_too_large | 413 | Payload > 256 KB |
chain_depth_exceeded | 422 | Workflow chain hit the platform's 50-deep cap |
monthly_emit_quota_exceeded | 402 | Monthly emit ceiling reached |
bus_relay_failed | 502 | Audit row written but the bus push errored |
cannot_archive_generic_event | 403 | Generic catch-all is system-managed |
Lifecycle — what fires when
Every state transition emits a platform event so subscribers (drift detection, federation push, audit, billing meter, ops alerts) stay in sync. The full catalog is in docs/platformxe-tenant-custom-events-lifecycle-events.md. At a glance:
- Registration:
CUSTOM_EVENT_REQUESTED→CUSTOM_EVENT_VALIDATED→CUSTOM_EVENT_PERSISTED→CUSTOM_EVENT_MATERIALISED→CUSTOM_EVENT_REGISTERED. - Emit:
CUSTOM_EVENT_EMIT_REQUESTED→CUSTOM_EVENT_EMIT_RELAYED→CUSTOM_EVENT_EMIT_DELIVERED(or…_DELIVERY_FAILED). - Failure:
CUSTOM_EVENT_VALIDATION_FAILED/CUSTOM_EVENT_EMIT_REJECTED/CUSTOM_EVENT_QUOTA_EXCEEDED. - Infra:
CUSTOM_EVENT_GENERIC_SEEDED,CUSTOM_EVENT_INVALIDATED,CUSTOM_EVENT_CHAIN_TRUNCATED.