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
- Your backend asks Centrali to mint an embed token scoped to one of your customer's tenant IDs.
- Your frontend renders the embed iframe with the token in the URL fragment (not the query string — fragments don't leak into HTTP logs).
- The iframe scrubs the token from
location.hrefimmediately, then queries Centrali's API with it. Centrali rejects any query whose target tenant doesn't match the token's claim. - 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 grantsevents:read.webhook-subscriptions:replay— only required if you want to grant the iframe theevents:replaycapability (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:
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.
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.
Related¶
- Outbound Webhooks — what's being logged
- Webhook Ingestion — the other end of the pipe
- Service Accounts — how to provision the credential that mints embed tokens