Skip to main content
Multi-tenant SaaS applications often need to show customers their own activity history — who on their team did what and when. Immutable’s viewer tokens let you securely expose a read-only, tenant-scoped view of the audit log without giving customers access to your API key or other tenants’ data.

How It Works

  1. Your backend creates a viewer token scoped to a specific tenant_id
  2. The token is passed to your frontend
  3. Your frontend either embeds the Immutable viewer iframe or queries the public events endpoint directly
  4. The token expires after the configured TTL

Creating Viewer Tokens

Backend: Generate a Token

import { ImmutableClient } from "getimmutable";

const client = new ImmutableClient({
  apiKey: "imk_sk_a1b2c3d4e5f6g7h8i9j0",
  baseUrl: "https://getimmutable.dev",
});

// Scoped to the customer's organization
const token = await client.createViewerToken({
  tenantId: "org_acme_corp",
  ttl: 3600,
});

// Send token.viewer_token to your frontend
console.log(token.viewer_token);
// "vt_eyJhbGciOiJIUzI1NiIs..."

Scoping by Actor

You can also scope a viewer token to a specific actor, so a user only sees their own activity:
const token = await client.createViewerToken({
  tenantId: "org_acme_corp",
  actorId: "user_5a3c8f",
  ttl: 3600,
});
Viewer tokens are read-only and cannot be used to ingest events. They only grant access to events matching the scoped tenant_id (and optionally actor_id).

Embedding the Viewer

Option 1: Iframe Embed

The simplest integration — embed the Immutable viewer directly in your app:
<iframe
  src="https://getimmutable.dev/viewer?token=vt_eyJhbGciOiJIUzI1NiIs..."
  width="100%"
  height="600"
  frameborder="0"
  style="border-radius: 8px; border: 1px solid #e5e7eb;"
></iframe>

Option 2: React Component

Build a custom activity feed by querying the public events endpoint with the viewer token:
import { useState, useEffect } from "react";

function ActivityFeed({ viewerToken }) {
  const [events, setEvents] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchEvents() {
      const response = await fetch(
        "https://getimmutable.dev/api/v1/events?limit=50",
        {
          headers: {
            Authorization: `Bearer ${viewerToken}`,
          },
        }
      );
      const data = await response.json();
      setEvents(data.data);
      setLoading(false);
    }
    fetchEvents();
  }, [viewerToken]);

  if (loading) {
    return (
      <div className="animate-pulse space-y-3">
        {[...Array(5)].map((_, i) => (
          <div key={i} className="h-12 bg-gray-100 rounded" />
        ))}
      </div>
    );
  }

  return (
    <div className="divide-y divide-gray-100">
      {events.map((event) => (
        <div key={event.id} className="py-3 flex items-start gap-3">
          <div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-sm font-medium text-blue-700">
            {event.actor_name?.charAt(0) || "?"}
          </div>
          <div className="flex-1 min-w-0">
            <p className="text-sm text-gray-900">
              <span className="font-medium">{event.actor_name}</span>{" "}
              {formatAction(event.action)}{" "}
              <span className="font-medium">{event.resource_name}</span>
            </p>
            <p className="text-xs text-gray-500 mt-0.5">
              {new Date(event.occurred_at).toLocaleString()}
            </p>
          </div>
        </div>
      ))}
    </div>
  );
}

function formatAction(action) {
  return action.replace(/\./g, " ").replace(/_/g, " ");
}
When building a custom activity feed, implement cursor-based pagination using the cursor parameter from the API response. This ensures you can load older events without missing any.

TTL Management

Choose a TTL that matches your use case:
ScenarioRecommended TTLReason
Dashboard page view3600 (1 hour)Matches typical session length
Embedded widget900 (15 minutes)Short-lived, refresh on page load
Customer portal28800 (8 hours)Full work day access
Single report view300 (5 minutes)Minimal exposure

Refreshing Tokens

Create a backend endpoint that generates fresh tokens on demand:
app.get("/api/activity-token", authenticate, async (req, res) => {
  const token = await client.createViewerToken({
    tenantId: req.user.organizationId,
    ttl: 3600,
  });
  res.json({ token: token.viewer_token, expires_in: 3600 });
});
On the frontend, refresh the token before it expires:
function useViewerToken() {
  const [token, setToken] = useState(null);

  useEffect(() => {
    async function refresh() {
      const res = await fetch("/api/activity-token");
      const data = await res.json();
      setToken(data.token);

      // Refresh 60 seconds before expiry
      const refreshMs = (data.expires_in - 60) * 1000;
      setTimeout(refresh, refreshMs);
    }
    refresh();
  }, []);

  return token;
}

Querying Events with a Viewer Token

Viewer tokens work with the same events API, but responses are automatically filtered to the token’s scope:
// Using the viewer token directly (no SDK needed on the frontend)
const response = await fetch(
  "https://getimmutable.dev/api/v1/events?action=project.created&limit=10",
  {
    headers: { Authorization: "Bearer vt_eyJhbGciOiJIUzI1NiIs..." },
  }
);
const events = await response.json();
Even if a query doesn’t include tenant_id as a filter, the viewer token enforces it. A viewer token scoped to org_acme_corp can never see events from other tenants.

What’s Next

Embeddable Viewer

Full guide to the Immutable viewer widget.

Events API

API reference for querying events.

Viewer Token API

API reference for creating viewer tokens.

SaaS Activity Tracking

End-to-end example of tracking user activity in a SaaS app.