Skip to content
mittr

Embedded Sub-Account Portal

Mittr’s embedded portal is a drop-in UI you hand to your end customers so they can manage their own webhook endpoints and inspect delivery activity without leaving your product. Under the hood it runs on a separate tenancy primitive called a sub-account: a child identity scoped to your parent client, with its own endpoints, events, and capability-scoped session tokens.

The word “portal” only appears in the URL you embed (/portal/:token) and the UI your customers see. Everywhere else (API paths, data models, SDKs) uses sub-account.

Embedded portal sequence flow: parent app creates a sub-account, mints a session token, embeds the portal iframe, the portal exchanges the token for a session cookie, serves the customer until the session expires, then hands back to the parent for a refresh.
Terminal window
curl -X POST https://app.mittr.io/api/v1/sub-accounts \
-H "X-API-Key: $MITTR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalId": "cust_001",
"name": "Acme Merchant",
"email": "[email protected]",
"metadata": { "tier": "gold" }
}'

externalId is your own identifier for this customer and must be unique per parent client. Everything else is optional. The response carries the Mittr UUID you’ll pass back when minting sessions.

A return URL is where Mittr sends the customer when their session expires or is revoked. Your app handles the URL by authenticating the customer and minting a fresh session link. This is a standard “re-auth bounce” flow.

You can set it once per parent client and have every session inherit it, or override per mint. Set it via the dashboard (Sub-accounts → “Set URL” banner) or the API:

Terminal window
curl -X PATCH https://app.mittr.io/api/v1/sub-account-settings \
-H "X-API-Key: $MITTR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "defaultReturnUrl": "https://your-app.example/mittr/return" }'

Without a default (and without a per-session override), POST /sub-accounts/{id}/sessions rejects with MISSING_RETURN_URL.

Terminal window
curl -X POST https://app.mittr.io/api/v1/sub-accounts/{id}/sessions \
-H "X-API-Key: $MITTR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "expiresIn": 3600 }'

Response:

{
"token": "eyJhbGciOi...",
"expiresAt": "2026-04-18T20:00:00Z"
}

Hand the customer https://app.mittr.io/portal/{token}.

The token is:

  • HMAC-SHA256 signed. Mittr verifies integrity on every request.
  • Scope-limited. Claims carry endpoints:read, endpoints:write, events:read, events:retry, deliveries:read. Pass a narrower scope array to restrict further.
  • Stateful. A sub_account_sessions row tracks it server-side. You can revoke, list, or audit from the dashboard.
  • Single-use at the URL. On first open the SPA swaps it for an httpOnly cookie and strips the token from the address bar. Opening the same URL a second time returns 409 ALREADY_CONSUMED.

Point an iframe (or full navigation) at the link:

<iframe
src="https://app.mittr.io/portal/eyJhbGciOi..."
style="width:100%; height:100%; border:0;"
allow="clipboard-write"
></iframe>

The SPA exchanges the token for a cookie on first load, so the raw JWT never lingers in the URL bar, referer headers, or browser history.

Two flags on your Mittr deployment control how the embed behaves:

Setting (env)Effect
SUBACCOUNT_CROSS_SITE_EMBED=trueSession cookie is issued with SameSite=None; Secure; Partitioned (CHIPS) instead of the default SameSite=Lax. Required whenever the iframe is served from a different eTLD+1 than your app (e.g. iframe on acme.example pointing to mittr.io).
SUBACCOUNT_ALLOWED_FRAME_ANCESTORS=https://acme.example,https://foo.exampleWhitelist of origins allowed to embed /portal/*. Mittr omits X-Frame-Options on portal responses and emits Content-Security-Policy: frame-ancestors <list>. Unset = hard deny (default).

Same-origin setups (portal served from your own subdomain) can leave both off: the default SameSite=Lax cookie and the implicit X-Frame-Options: DENY are the safest posture.

For true third-party embedding:

Terminal window
# Mittr deployment env
SUBACCOUNT_CROSS_SITE_EMBED=true
SUBACCOUNT_ALLOWED_FRAME_ANCESTORS=https://acme.example,https://foo.example

Browser support for partitioned cookies (CHIPS): Chrome 114+, Edge 114+, Firefox (flag), Safari (limited). Browsers that don’t recognise the Partitioned attribute silently ignore it, so the cookie still works in first-party contexts and fails safely in third-party ones.

Sessions slide forward while the customer is active (every request within 30 minutes of expiry bumps expires_at forward by one hour, capped at max_expires_at). When a session does expire, or you revoke it, the next API call returns 401 with:

{
"error": "Your session has expired.",
"code": "SESSION_EXPIRED",
"returnUrl": "https://your-app.example/mittr/return",
"subAccountId": "5f2c4c7d-..."
}

The SPA appends subAccountId and the error code (as reason) to the returnUrl when redirecting:

https://your-app.example/mittr/return?subAccountId=5f2c4c7d-...&reason=SESSION_EXPIRED

So one generic return URL works for every sub-account. You don’t need to register a URL per customer. Your endpoint:

  1. Reads subAccountId from the query string.
  2. Authenticates the customer (cookie, SSO, whatever you normally use).
  3. Verifies the authenticated customer actually owns that sub-account (never trust the query param blindly).
  4. Calls POST /api/v1/sub-accounts/{subAccountId}/sessions with your parent API key to mint a new Mittr session.
  5. Redirects to the new /portal/{newToken} URL.

The SPA also caches the return URL and sub-account id in localStorage on first exchange, so a stranded customer (expired cookie with no valid token in the URL) still gets the “Get a fresh session” button pointing back at your app.

A minimal handler in Go. Adapt to your framework. In production, do not trust the subAccountId query param verbatim: look up the authenticated customer in your own DB and verify they actually own that sub-account before minting.

func mittrReturn(w http.ResponseWriter, r *http.Request) {
// 1. Authenticate the customer against your own session / SSO.
user, ok := currentUser(r)
if !ok {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.String()), http.StatusFound)
return
}
// 2. Verify the requested sub-account belongs to this user.
subID := r.URL.Query().Get("subAccountId")
if !user.OwnsMittrSubAccount(subID) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// 3. Mint a fresh Mittr session with your parent API key.
body, _ := json.Marshal(map[string]any{"expiresIn": 3600})
req, _ := http.NewRequest(http.MethodPost,
"https://app.mittr.io/api/v1/sub-accounts/"+subID+"/sessions",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("MITTR_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil || res.StatusCode >= 300 {
http.Error(w, "mint failed", http.StatusBadGateway)
return
}
defer res.Body.Close()
var out struct{ Token string `json:"token"` }
_ = json.NewDecoder(res.Body).Decode(&out)
// 4. Bounce the customer back into the portal with the fresh token.
http.Redirect(w, r,
"https://app.mittr.io/portal/"+url.PathEscape(out.Token),
http.StatusFound)
}
Terminal window
# single session
curl -X DELETE https://app.mittr.io/api/v1/sub-accounts/{id}/sessions/{sessionId} \
-H "X-API-Key: $MITTR_API_KEY"
# all sessions for a sub-account (log out all devices)
curl -X DELETE https://app.mittr.io/api/v1/sub-accounts/{id}/sessions \
-H "X-API-Key: $MITTR_API_KEY"

Revocation takes effect on the next request: the middleware checks revoked_at on every authenticated call.

The portal surface inherits a handful of CSS custom properties so it blends into your brand without a separate stylesheet. Override them on :root of the document that serves Mittr (or via a reverse-proxy injection) and the SPA picks them up at paint time:

VariablePurpose
--bg-000Top-level chrome (header, footer)
--bg-100Page background
--bg-200Secondary surfaces (tab groups)
--accent-main-100Primary action color
--accent-main-900Primary action tint (icon backgrounds)
--chart-deliveredDelivered status dot
--chart-failedFailed status dot
--chart-deadDead status dot
--warning-100Warning banners (return URL prompts)

The portal also ships with its own light/dark toggle (independent of the parent dashboard’s theme), persisted to the mittr-portal-theme localStorage key.

  • JWT HS256 with a server-side sub_account_sessions registry. Revocation, listing, and activity counters (last_used_at, use_count) are all enforced on the session row, not the token.
  • Single-use handoff: consumed_at is set atomically during the /auth/exchange call. A leaked URL cannot mint cookies on a second device.
  • httpOnly cookie, scoped to /sub-account-api. The raw JWT never reaches JavaScript after the swap. SameSite=Lax by default; SameSite=None; Secure; Partitioned (CHIPS) when cross-site embedding is enabled so each embedding parent gets its own cookie jar.
  • Sliding expiry with an absolute ceiling. Active users don’t see re-auth prompts; idle sessions time out within the refresh threshold.
  • Capability scopes are encoded in signed claims. Request handlers gate every route with claims.HasCapability(...), so a narrower scope at mint time is enforced all the way through.
  • Return URL is frozen at mint time. Updating the default does not retroactively repoint in-flight sessions. Revoke and re-mint if you need a fleet-wide migration.
  • Session sliding is fire-and-forget. The expires_at bump runs in a background goroutine. In the worst case a request right at the edge of expiry may 401 once before the next request picks up the new timestamp; treat it as a normal re-auth.