Custom Events Federation
Push your registered custom events to peer Enterprise organisations through bus-level fan-out. Enterprise-only.
The client.events.custom.federation namespace lets ENTERPRISE tenants share live event traffic — not just schemas — with peer ENTERPRISE orgs. An owner organisation creates a federation group, invites peer orgs, and declares per-version pushes. When the owner emits a pushed event, the platform automatically re-emits on each accepted peer's canonical name so peer subscribers (webhooks, workflows) receive the message on their own bus channel.
This is distinct from client.permissions.federation, which is the v1.x.x admin-permission cross-app sync surface. Different concept, different access path:
| Custom Event Federation (this page, Phase 9D) | Permission Federation (v1.x.x) | |
|---|---|---|
| What flows | Live tenant emit messages | Admin permission modules / role grants |
| Granted at | events.custom.federation | permissions.federation |
| Bus prefix | CUSTOM_EVENT_FEDERATION_* | FEDERATION_* |
| Plan | Enterprise only | Enterprise only |
| SDK | Install | Namespace |
|---|---|---|
| TypeScript | npm install @caldera/platformxe-sdk@^1.5.0 | client.events.custom.federation |
| Python | pip install platformxe>=1.5.0 | client.events.custom.federation |
| Go | go get github.com/calderax/platformxe-go@v1.5.0 | client.Events.Custom.Federation |
| Terraform | source = "calderax/platformxe" ≥ 1.5.0 | platformxe_event_federation_group, platformxe_event_federation_push, platformxe_event_federation_external_peer |
Plan tiers
| Capability | Free | Basic | Pro | Enterprise |
|---|---|---|---|---|
| Create a group | — | — | — | ✅ (owner) |
| Accept an invitation | — | — | — | ✅ (member) |
| Receive relays | — | — | — | ✅ (member) |
| Declare a push | — | — | — | ✅ (owner) |
| Browse / introspect | — | — | — | ✅ (owner + member) |
Federation requires ENTERPRISE on both sides — the owner and every member. A non-Enterprise org calling any federation route receives 402 FEDERATION_NOT_AVAILABLE.
Concepts
┌─────────────────────────────────────────────────────────┐
│ Owner org (Enterprise) │
│ │
│ 1. createGroup(...) → cefg_xyz │
│ 2. invite(group, peer) → cefm_… (pending) │
│ 3. declarePush(group, regId) → cefp_… (active) │
│ │
│ emit("lettings.property.favorited") ───────┐ │
└────────────────────────────────────────────────│──────────┘
│ bus listener
│ + Inngest fan-out
▼
┌────────────────────────────────────────────────────────┐
│ Peer org (Enterprise, has accepted the invitation) │
│ │
│ client.events.custom.subscribe(...) │
│ ↳ peer subscribers receive on their own canonical│
│ name: TENANT_CUSTOM:peer:lettings.property.favorited@1.0.0 │
└────────────────────────────────────────────────────────┘
- Group: an owner-org-scoped container of peers + pushes. One owner, many members.
- Member: a peer org's relationship to a group. Lifecycle:
pending→accepted→ (paused|removed). Onlyacceptedmembers receive relays. - Push: a declaration that a specific
(namespace, name, version)of one of the owner's registrations should be relayed to every accepted member of the group. Per-version — pushing v1.0.0 doesn't push v1.1.0. - Relay: one re-emit attempt to one peer. Audited per-attempt in
custom_event_federation_relay_log(90-day retention). - Schema snapshot: when a push is declared, the source schema is copied onto the push row. Members see a stable view even if the owner archives or replaces the registration.
Quick start — owner side
// 1. Create a group
const group = await client.events.custom.federation.createGroup({
name: 'Trusted partners',
description: 'Lettings partners receiving live property events',
});
// 2. Invite a peer Enterprise org by id
await client.events.custom.federation.invite(group.id, {
memberOrganizationId: 'org_partner_123',
});
// — peer side calls .accept(group.id) on their own API key —
// 3. Declare a per-version push (your registration must be status='published')
await client.events.custom.federation.declarePush(group.id, {
registrationId: 'cer_propertyfavorited_v1',
});
// 4. Emit normally — fan-out is automatic
await client.events.custom.emit({
name: 'lettings.property.favorited',
payload: { propertyId: 'p_1', userId: 'u_1' },
});
group = client.events.custom.federation.create_group(
name="Trusted partners",
description="Lettings partners receiving live property events",
)
client.events.custom.federation.invite(group["data"]["id"], "org_partner_123")
# peer accepts via their own client:
# peer_client.events.custom.federation.accept(group["data"]["id"])
client.events.custom.federation.declare_push(group["data"]["id"], "cer_propertyfavorited_v1")
client.events.custom.emit("lettings.property.favorited", {"propertyId": "p_1"})
group, _ := client.Events.Custom.Federation.CreateGroup(platformxe.CreateEventFederationGroupInput{
Name: "Trusted partners",
Description: ptr("Lettings partners receiving live property events"),
})
_, _ = client.Events.Custom.Federation.Invite(group.ID, platformxe.InviteEventFederationMemberInput{
MemberOrganizationID: "org_partner_123",
})
_, _ = client.Events.Custom.Federation.DeclarePush(group.ID, platformxe.DeclareEventFederationPushInput{
RegistrationID: "cer_propertyfavorited_v1",
})
resource "platformxe_event_federation_group" "partners" {
name = "Trusted partners"
description = "Lettings partners receiving live property events"
}
resource "platformxe_event_federation_push" "favorited" {
group_id = platformxe_event_federation_group.partners.id
registration_id = platformxe_custom_event.property_favorited.id
}
Quick start — member side
// You received an invitation (notify out-of-band: email, dashboard, etc.).
// Accept it from your own API key:
await client.events.custom.federation.accept('cefg_xyz');
// Subscribe to the canonical name on your bus channel:
await client.events.custom.subscribe({
name: 'lettings.property.favorited', // owner's namespace.name
version: '1.0.0', // pinned version that was pushed
webhookUrl: 'https://your-app.com/webhooks/federation',
});
// You'll receive deliveries with the original payload + a top-level
// `isFederationRelay: true` discriminator + `sourceOrganizationId`
// pointing at the owner.
To leave a group voluntarily:
await client.events.custom.federation.leave('cefg_xyz');
Browse + introspect
// Groups you own + groups you're a member of
const { groups } = await client.events.custom.federation.listGroups();
for (const g of groups) {
console.log(g.id, g.callerRole, g.memberCount, g.pushCount);
// callerRole is 'owner' | 'pending' | 'accepted' | 'paused' | 'removed'
}
// Full detail (members + active pushes)
const detail = await client.events.custom.federation.getGroup('cefg_xyz');
detail.members.forEach((m) => console.log(m.memberOrganizationId, m.status));
detail.pushes.forEach((p) => console.log(p.sourceCanonicalName, p.isActive));
// Pushes alone
const { pushes } = await client.events.custom.federation.listPushes('cefg_xyz', {
includeInactive: true, // include unpushed history
});
Non-members receive 404 group_not_found (rather than 403) — leak-resistant by design. Owners always see the full detail of groups they own.
Stop pushing / archive
// Stop relaying a specific version (audit trail preserved)
await client.events.custom.federation.undeclarePush('cefp_xyz');
// Owner removes a member
// (member can also leave voluntarily — see above)
// Archive the entire group (stops all fan-out, preserves history)
await client.events.custom.federation.archiveGroup('cefg_xyz');
Archived groups cannot accept new members or declare new pushes; existing data stays in place for audit + reactivation isn't currently supported (create a new group instead).
Pattern 3 — external webhook peers (added in 1.5.0)
By default a federation peer is another PlatformXe ENTERPRISE tenant org (peer ID + accept handshake). Pattern 3 lets the owner add an arbitrary HTTPS endpoint as a peer instead — useful when the receiving system isn't on PlatformXe (a partner SaaS, a Booking.com integration, an on-prem listener).
The owner adds the peer with a label + URL + optional static headers. The platform returns a one-time HMAC-SHA256 secret (whsec_…) that the receiver uses to verify the X-Platformxe-Signature header on inbound POSTs. Static headers are stored encrypted server-side (KMS envelope, external_webhook_secret_encrypted column added in migration 0029) and replayed on every relay; only the header NAMES are echoed back on read.
The secret is shown ONCE. Capture it at create time — it cannot be retrieved later. Rotate by removing and re-adding the peer.
const result = await client.events.custom.federation.addExternalPeer(group.id, {
label: 'Booking.com',
webhookUrl: 'https://booking.example.com/inbound/platformxe',
headers: { Authorization: 'Bearer xyz' }, // optional, replayed on every relay
});
console.log(result.secret); // 'whsec_KQYf_w-Z2jL4P6n1B…' — store immediately
console.log(result.peer.peerType); // 'external_webhook'
console.log(result.peer.status); // 'accepted' (auto-accepted, no handshake)
// Remove later:
await client.events.custom.federation.removeExternalPeer(result.peer.id);
result = client.events.custom.federation.add_external_peer(
group["data"]["id"],
label="Booking.com",
webhook_url="https://booking.example.com/inbound/platformxe",
headers={"Authorization": "Bearer xyz"},
)
secret = result["data"]["secret"] # whsec_… — store immediately
client.events.custom.federation.remove_external_peer(result["data"]["peer"]["id"])
result, _ := client.Events.Custom.Federation.AddExternalPeer(
group.ID,
platformxe.AddEventFederationExternalPeerInput{
Label: "Booking.com",
WebhookURL: "https://booking.example.com/inbound/platformxe",
Headers: map[string]string{"Authorization": "Bearer xyz"},
},
)
fmt.Println(result.Secret) // whsec_… — store immediately
fmt.Println(result.Peer.PeerType) // platformxe.EventFederationPeerTypeExternalWebhook
_, _ = client.Events.Custom.Federation.RemoveExternalPeer(result.Peer.ID)
resource "platformxe_event_federation_external_peer" "bookingcom" {
group_id = platformxe_event_federation_group.partners.id
label = "Booking.com"
webhook_url = "https://booking.example.com/inbound/platformxe"
headers = {
Authorization = var.bookingcom_inbound_token
}
}
# secret is computed + sensitive — encrypt your state at rest.
output "bookingcom_signing_secret" {
value = platformxe_event_federation_external_peer.bookingcom.secret
sensitive = true
}
Wire format — what your endpoint receives
Each relay arrives as a POST with the canonical JSON body:
{
"namespace": "lettings",
"name": "property.favorited",
"version": "1.0.0",
"payload": { "propertyId": "p_1", "userId": "u_1" },
"federationPushId": "cefp_…",
"emitId": "evt_…",
"isFederationRelay": true,
"timestamp": "2026-05-06T21:40:48.000Z"
}
…and these headers (in addition to any static headers you supplied at create time, e.g. Authorization):
| Header | Value |
|---|---|
Content-Type | application/json |
X-Caldera-Event-Type | <namespace>.<name>@<version> (canonical name) |
X-Caldera-Signature | sha256=<hex> — HMAC-SHA256 of the raw body bytes |
X-Caldera-Federation-Push-Id | The push id (cefp_…) |
X-Caldera-Emit-Id | The emit id (evt_…), idempotency key |
User-Agent | PlatformXe-Federation-Relay/1.0 |
This is the same X-Caldera-Signature: sha256=<hex> convention used by PlatformXe event-subscription webhooks — same verification helper works on both surfaces.
Verifying the inbound signature
Recompute HMAC-SHA256(secret, rawBody) and compare in constant time. Use the X-Caldera-Emit-Id as your idempotency key — at-least-once delivery means the same emit may arrive more than once.
import crypto from 'node:crypto';
function verifyCalderaSignature(req: Request, secret: string, rawBody: string): boolean {
const header = req.headers.get('x-caldera-signature');
if (!header || !header.startsWith('sha256=')) return false;
const provided = header.slice('sha256='.length);
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// timingSafeEqual requires equal-length buffers
if (provided.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
}
import hmac, hashlib
def verify_caldera_signature(headers: dict, secret: str, raw_body: bytes) -> bool:
header = headers.get("X-Caldera-Signature", "")
if not header.startswith("sha256="):
return False
provided = header[len("sha256="):]
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(provided, expected)
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
)
func VerifyCalderaSignature(r *http.Request, secret string, rawBody []byte) bool {
h := r.Header.Get("X-Caldera-Signature")
if !strings.HasPrefix(h, "sha256=") {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(strings.TrimPrefix(h, "sha256=")), []byte(expected))
}
Legacy peers (those whose row was created before migration
0029_federation_webhook_secret_encrypted) signed withHMAC-SHA256(body, utf8(hex(SHA256(secret))))instead of the raw secret. Remove + re-add such peers to rotate them onto the standard scheme.
Pattern 3 vs tenant peers — at a glance
peerType='tenant_org' (Phase 9D) | peerType='external_webhook' (1.5.0, Pattern 3) | |
|---|---|---|
| Address | Other PlatformXe org id | HTTPS endpoint URL |
| Onboarding | invite → peer accepts | owner adds, auto-accepted |
| Receiving plan | ENTERPRISE | any (no platform account required) |
| Authentication | Peer's own API key | HMAC-SHA256 on X-Platformxe-Signature |
| Static headers | n/a | optional map (e.g. Authorization) |
| Discoverable | Yes — peer sees the group via listGroups() | No — owner-managed only |
memberOrganizationId | populated | null |
externalWebhookUrl / externalWebhookLabel / externalWebhookHeaderNames | null | populated |
Both peer types receive the SAME relays for any push declared on the group; the lifecycle events on the bus are identical.
Reference — wire shapes
CreateFederationGroupRequest:
{
name: string; // 3–80 chars, owner-scope-unique
description?: string;
}
FederationGroupSummary:
{
id: string; // 'cefg_…'
ownerOrganizationId: string;
name: string;
description: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
callerRole: 'owner' | 'pending' | 'accepted' | 'paused' | 'removed';
memberCount: number; // total members regardless of status
pushCount: number; // active pushes
}
FederationGroupDetail extends summary with:
{
members: FederationMemberSummary[];
pushes: FederationPushSummary[];
}
FederationMemberSummary (polymorphic on peerType):
{
id: string; // 'cefm_…'
groupId: string;
peerType: 'tenant_org' | 'external_webhook';
memberOrganizationId: string | null; // populated when peerType='tenant_org'
externalWebhookUrl: string | null; // populated when peerType='external_webhook'
externalWebhookLabel: string | null; // ditto
externalWebhookHeaderNames: string[] | null; // header NAMES only — values are secret
status: 'pending' | 'accepted' | 'paused' | 'removed';
invitedBy: string;
invitedAt: string;
acceptedBy: string | null;
acceptedAt: string | null;
pausedBy: string | null;
pausedAt: string | null;
removedBy: string | null;
removedAt: string | null;
}
peerType='external_webhook' rows are auto-accepted at create time and have a null memberOrganizationId. Both shapes flow through the same lifecycle states; both audit + bus events fire the same way regardless of peer type.
FederationPushSummary:
{
id: string; // 'cefp_…'
groupId: string;
sourceRegistrationId: string | null; // null if source registration was deleted
sourceOrganizationId: string;
namespace: string;
name: string;
version: string;
sourceCanonicalName: string; // TENANT_CUSTOM:owner-org:ns.name@v
pushedBy: string;
pushedAt: string;
unpushedBy: string | null;
unpushedAt: string | null;
isActive: boolean;
}
Reference — error codes
| Code | Status | Cause |
|---|---|---|
FEDERATION_NOT_AVAILABLE | 402 | Caller's org is not on Enterprise plan |
invalid_request | 400 | Group name out of bounds (3–80 chars), missing required field, or owner trying to invite self |
group_not_found | 404 | Unknown group, or caller is not a member (leak-resistant) |
group_archived | 409 | Mutating an archived group |
forbidden | 403 | Non-owner attempting an owner-only mutation, or wrong API key for accept/leave |
duplicate | 409 | Group name collides; member already present; push already declared |
registration_not_found | 404 | Push references a registration not owned by the calling org |
invalid_state | 409 | Accepting an already-accepted invitation, leaving an already-removed group, declaring a push for a draft registration, or undeclaring an already-inactive push |
Reference — bus events
Every state transition emits a typed event on the bus (@caldera/events@^1.25.0). Subscribers (audit feed, ops alerting, the relay-failure dashboard) use the bus catalog. The 9 federation lifecycle events:
| Event | Payload highlights |
|---|---|
CUSTOM_EVENT_FEDERATION_GROUP_CREATED | ownerOrganizationId, groupId, name, createdBy |
CUSTOM_EVENT_FEDERATION_GROUP_ARCHIVED | groupId, archivedBy |
CUSTOM_EVENT_FEDERATION_MEMBER_INVITED | groupId, memberOrganizationId, invitedBy |
CUSTOM_EVENT_FEDERATION_MEMBER_ACCEPTED | groupId, memberOrganizationId, acceptedBy |
CUSTOM_EVENT_FEDERATION_MEMBER_REMOVED | groupId, memberOrganizationId, removedBy, reason (owner_removed / member_left / plan_downgraded) |
CUSTOM_EVENT_FEDERATION_PUSH_DECLARED | groupId, pushId, sourceRegistrationId, namespace, name, version |
CUSTOM_EVENT_FEDERATION_PUSH_UNDECLARED | groupId, pushId, unpushedBy |
CUSTOM_EVENT_FEDERATION_RELAYED | pushId, emitId, peerOrganizationId, peerCanonicalName, relayedAt |
CUSTOM_EVENT_FEDERATION_RELAY_FAILED | pushId, emitId, peerOrganizationId, peerCanonicalName, errorMessage |
Every relay attempt — successful or failed — is also persisted to the custom_event_federation_relay_log table for 90-day audit retention.
Operational notes
- Delivery semantics: at-least-once. The bus emit is fire-and-forget but each Inngest worker writes a
relay_logrow regardless of outcome — afailedrow is the operator's signal to investigate. - Loop prevention: the relayed payload carries
isFederationRelay: true. The bus listener's federation hook short-circuits when this flag is set, so peer subscribers can't accidentally trigger a second-level fan-out. - Schema drift: the schema is snapshotted at push-declaration time (
custom_event_federation_pushes.payload_schema). Members see a stable view; if you bump the source registration to v2.0.0 you must declare a new push for the new version. - Plan downgrades: if an owner or member drops to non-Enterprise, federation routes return 402. Active pushes don't auto-archive — a future v2 cron will reconcile.
- Group + member retention: archived groups and removed members stay in the database forever for audit. The relay log has 90-day retention.