PlatformXeDocs
Get API Key

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 flowsLive tenant emit messagesAdmin permission modules / role grants
Granted atevents.custom.federationpermissions.federation
Bus prefixCUSTOM_EVENT_FEDERATION_*FEDERATION_*
PlanEnterprise onlyEnterprise only
SDKInstallNamespace
TypeScriptnpm install @caldera/platformxe-sdk@^1.5.0client.events.custom.federation
Pythonpip install platformxe>=1.5.0client.events.custom.federation
Gogo get github.com/calderax/platformxe-go@v1.5.0client.Events.Custom.Federation
Terraformsource = "calderax/platformxe" ≥ 1.5.0platformxe_event_federation_group, platformxe_event_federation_push, platformxe_event_federation_external_peer

Plan tiers

CapabilityFreeBasicProEnterprise
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: pendingaccepted → (paused | removed). Only accepted members 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):

HeaderValue
Content-Typeapplication/json
X-Caldera-Event-Type<namespace>.<name>@<version> (canonical name)
X-Caldera-Signaturesha256=<hex> — HMAC-SHA256 of the raw body bytes
X-Caldera-Federation-Push-IdThe push id (cefp_…)
X-Caldera-Emit-IdThe emit id (evt_…), idempotency key
User-AgentPlatformXe-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 with HMAC-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)
AddressOther PlatformXe org idHTTPS endpoint URL
Onboardinginvite → peer acceptsowner adds, auto-accepted
Receiving planENTERPRISEany (no platform account required)
AuthenticationPeer's own API keyHMAC-SHA256 on X-Platformxe-Signature
Static headersn/aoptional map (e.g. Authorization)
DiscoverableYes — peer sees the group via listGroups()No — owner-managed only
memberOrganizationIdpopulatednull
externalWebhookUrl / externalWebhookLabel / externalWebhookHeaderNamesnullpopulated

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

CodeStatusCause
FEDERATION_NOT_AVAILABLE402Caller's org is not on Enterprise plan
invalid_request400Group name out of bounds (3–80 chars), missing required field, or owner trying to invite self
group_not_found404Unknown group, or caller is not a member (leak-resistant)
group_archived409Mutating an archived group
forbidden403Non-owner attempting an owner-only mutation, or wrong API key for accept/leave
duplicate409Group name collides; member already present; push already declared
registration_not_found404Push references a registration not owned by the calling org
invalid_state409Accepting 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:

EventPayload highlights
CUSTOM_EVENT_FEDERATION_GROUP_CREATEDownerOrganizationId, groupId, name, createdBy
CUSTOM_EVENT_FEDERATION_GROUP_ARCHIVEDgroupId, archivedBy
CUSTOM_EVENT_FEDERATION_MEMBER_INVITEDgroupId, memberOrganizationId, invitedBy
CUSTOM_EVENT_FEDERATION_MEMBER_ACCEPTEDgroupId, memberOrganizationId, acceptedBy
CUSTOM_EVENT_FEDERATION_MEMBER_REMOVEDgroupId, memberOrganizationId, removedBy, reason (owner_removed / member_left / plan_downgraded)
CUSTOM_EVENT_FEDERATION_PUSH_DECLAREDgroupId, pushId, sourceRegistrationId, namespace, name, version
CUSTOM_EVENT_FEDERATION_PUSH_UNDECLAREDgroupId, pushId, unpushedBy
CUSTOM_EVENT_FEDERATION_RELAYEDpushId, emitId, peerOrganizationId, peerCanonicalName, relayedAt
CUSTOM_EVENT_FEDERATION_RELAY_FAILEDpushId, 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_log row regardless of outcome — a failed row 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.