Skip to content

Event Log Embed (Beta)

The Event Log Embed lets you drop a tenant-scoped webhook delivery log into your own product so your end-users can see the deliveries Centrali is sending on their behalf — without you building any of that UI.

You mint a short-lived token on your server, hand it to your frontend, and render an <iframe> pointed at https://embed.centrali.io/v1/event-log. The iframe is hardened: tokens are passed in the URL fragment, which keeps them out of HTTP request lines, server logs, and Referer headers; the iframe also scrubs the fragment immediately after parsing it. Every query is auto-filtered to the tenant you scoped the token to.

Beta

The Event Log Embed is in beta. The token surface, postMessage contract, and capability vocabulary are subject to change before GA. We will call out breaking changes in the migration guides and the changelog.

When to use this

Use the embed when your product already sends outbound webhooks through Centrali and your customers want to debug their own deliveries (status, payload, retry history, replay). For Centrali-internal debugging or for showing the log to your own workspace admins, use the console UI's built-in Event Log instead.

How it works

your end-user's browser
  ├─ your dashboard (e.g. app.yourproduct.com)
  │    └─ <iframe src="https://embed.centrali.io/v1/event-log#token=ev_…&o=https://app.yourproduct.com">
  │         └─ fetches → api.centrali.io   (validated by IAM, tenant filter auto-injected)
  └─ your backend
       └─ mints `ev_…` token via SDK or HTTP → IAM
  1. Your backend asks Centrali to mint an embed token scoped to one of your customer's tenant IDs.
  2. Your frontend renders the embed iframe with the token in the URL fragment (not the query string — fragments don't leak into HTTP logs).
  3. The iframe scrubs the token from location.href immediately, then queries Centrali's API with it. Centrali rejects any query whose target tenant doesn't match the token's claim.
  4. The iframe talks back to your page via postMessage (auto-resize, "please mint me a fresh token", "replay completed").

Prerequisites

You need a service account in your Centrali workspace with these capabilities:

  • event-lineage:issue-embed-token — required to mint any embed token.
  • event-lineage:list + event-lineage:retrieve — required because every minted token implicitly grants events:read.
  • webhook-subscriptions:replay — only required if you want to grant the iframe the events:replay capability (so end-users can click "Replay" on a failed delivery).

The SDK uses your existing service-account clientId + clientSecret to mint a separate, IAM-audienced token under the hood. Publishable keys and dev tokens are rejected by IAM for this endpoint — embed minting must come from a confidential credential.

See Service Accounts for how to provision one.

Step 1 — Mint a token on your server

import { CentraliSDK } from '@centrali-io/centrali-sdk';

const centrali = new CentraliSDK({
  baseUrl: 'https://centrali.io',
  workspaceId: 'your-workspace-slug',
  clientId: process.env.CENTRALI_CLIENT_ID!,
  clientSecret: process.env.CENTRALI_CLIENT_SECRET!,
});

// In your "give me an embed URL for this customer" route:
const { token, sessionId, expiresAt } = await centrali.embed.eventLog.issueToken({
  tenantId: customerOrgId,                // your end-user's tenant id
  capabilities: ['events:read'],          // omit for read-only; add 'events:replay' to enable replay
  ttlSeconds: 600,                         // optional, defaults to 600s, max 3600s
});

return { token, sessionId, expiresAt };

First exchange your service-account clientId + clientSecret for a workspace JWT audienced for IAM:

curl -X POST https://auth.centrali.io/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d "grant_type=client_credentials" \
  -d "client_id=$CENTRALI_CLIENT_ID" \
  -d "client_secret=$CENTRALI_CLIENT_SECRET" \
  -d "resource=https://auth.centrali.io"

Warning

The resource parameter must be the auth URL, not the API URL. The embed endpoint rejects data-audienced tokens with audience_mismatch.

Then mint the embed token:

curl -X POST https://auth.centrali.io/workspace/your-workspace-slug/embed/event-log/issue-token \
  -H "Authorization: Bearer $WORKSPACE_JWT" \
  -H 'Content-Type: application/json' \
  -d '{
    "tenantId": "customer-org-id",
    "capabilities": ["events:read"],
    "ttlSeconds": 600
  }'

Response:

{
  "token": "ev_…",
  "sessionId": "ses_…",
  "expiresAt": "2026-06-01T12:34:56.000Z"
}

Tip

Never ship clientSecret to a browser. Mint tokens in a server route your frontend calls — the response is what's safe to give your frontend.

Step 2 — Render the iframe

Drop the token into the URL fragment with your parent origin as o:

<iframe
  src="https://embed.centrali.io/v1/event-log#token=ev_…&o=https://app.yourproduct.com&theme=auto&accent=%23E45A2B"
  style="width:100%; border:none;"
  title="Webhook deliveries"
></iframe>
Fragment key Required Description
token Yes The ev_… token returned by Step 1. The iframe scrubs it from location.href once it's parsed.
o Yes Your parent origin (scheme + host + port). The iframe validates postMessage source against this and will not respond to messages from any other origin.
theme No auto (default), light, or dark. auto follows the user's prefers-color-scheme.
accent No URL-encoded hex color (#RGB or #RRGGBB) used for primary buttons and focus rings. Invalid colors fall back to Centrali's default accent.

Step 3 — Listen for postMessage events

The iframe sends a small set of structured messages back to the parent. Always validate event.origin before reacting.

const EMBED_ORIGIN = 'https://embed.centrali.io';
const iframe = document.querySelector<HTMLIFrameElement>('iframe.event-log')!;

window.addEventListener('message', async (event) => {
  if (event.origin !== EMBED_ORIGIN) return;

  switch (event.data?.type) {
    case 'cev:ready':
      // First paint complete.
      break;

    case 'cev:resize':
      // Auto-grow: the iframe is telling you how tall its body is.
      iframe.style.height = `${event.data.height}px`;
      break;

    case 'cev:auth-stale': {
      // The current token expired or was revoked. Mint a fresh one on your
      // server, then post it back. The iframe swaps tokens without reloading.
      const fresh = await fetch('/api/embed/event-log/token', { method: 'POST' });
      const { token } = await fresh.json();
      iframe.contentWindow?.postMessage({ type: 'cev:token', token }, EMBED_ORIGIN);
      break;
    }

    case 'cev:replay-completed':
      // Your end-user clicked "Replay" and the new delivery was queued.
      // event.data.deliveryId / event.data.replayedFrom are available.
      toast.success('Delivery replayed.');
      break;

    case 'cev:error':
      // Anything else worth surfacing (event.data.code, event.data.message).
      console.warn('Event log embed error:', event.data);
      break;
  }
});
Message Direction When it fires
cev:ready iframe → parent After first paint completes.
cev:resize iframe → parent Whenever the iframe's body changes height.
cev:auth-stale iframe → parent The token's exp has fired, or the session was revoked.
cev:replay-completed iframe → parent A "Replay" action returned 202 from the data plane.
cev:error iframe → parent Generic error surface for anything else (e.g. network failure).
cev:token parent → iframe Hand the iframe a fresh token, typically in response to cev:auth-stale. Payload: { token: string }.
cev:theme parent → iframe Update the iframe's theme and/or accent without reloading. Payload: { theme?: 'auto'\|'light'\|'dark'; accent?: '#RRGGBB' \| null }. Useful if your dashboard has a runtime theme toggle.
cev:refresh parent → iframe Ask the iframe to refetch the event-log list, typically after your app observes a new delivery.
cev:destroy parent → iframe Tell the iframe the host is unmounting it so in-flight requests can be cancelled during teardown.

Step 4 (optional) — Revoke on logout

If you mint embed tokens per-session, you can revoke every token sharing a sessionId when your user signs out. The token can still be exchanged for one full denylist TTL (max 60 minutes) of dead-on-arrival, but the data plane rejects all of them.

await centrali.embed.eventLog.revokeSession({ sessionId });

If you don't supply your own sessionId at mint time, the server generates one and returns it in the response — store it alongside whatever you persist for the session.

Capability reference

Capability What it grants
events:read List + retrieve the event log rows for the token's tenant. Always granted; cannot be opted out of.
events:replay Allow the iframe's "Replay" action on a failed delivery. The issuing service account must also hold webhook-subscriptions:replay.

If you omit capabilities entirely, the iframe gets ['events:read'] only — no replay button.

Token lifecycle

  • TTL — default 600 seconds (10 minutes), capped at 3600 (1 hour). Tune via ttlSeconds.
  • Rate limits — issuance is bucketed per (serviceAccount, tenantId); revoke is bucketed per service account. Sustained issuance for hundreds of concurrent tenants is fine, but issuing the same (sa, tenant) pair in a tight loop will surface 429s.
  • Audience — the embed JWT carries aud=embed.centrali.io. Centrali's data plane is the only consumer; you should never need to decode or inspect it.

What the iframe can't do

  • It cannot read any data outside the token's tenantId.
  • It cannot mint new tokens or escalate capabilities.
  • It cannot navigate the parent page (it's iframed cross-origin).
  • It cannot persist user state across token rotations — each token rotation drops the prior tenant's cached query data in the same render commit, so a logout-then-login as a different tenant never paints stale rows.

Troubleshooting

audience_mismatch when minting: your workspace JWT was minted with resource=<data URL>. The embed endpoint requires resource=<IAM URL> (https://auth.centrali.io). The Node SDK handles this automatically; if you're using raw HTTP, see Step 1.

iframe shows "Authentication required": the token's exp has fired, the token's sessionId has been revoked, or the parent never posted a fresh token after cev:auth-stale. Confirm your cev:auth-stale handler is posting cev:token to the iframe with the correct origin.

iframe is the wrong height: confirm you're handling cev:resize and applying event.data.height to the iframe's style.height. The iframe does not size itself.

Replay button doesn't appear: the token was minted without events:replay capability, or the issuing service account lacks webhook-subscriptions:replay. Both are required.