Skip to main content

Connecting your app to your own backend

Many IrisX Apps keep some data or logic in the developer's own backend. This page first gives an overview of the ways to connect your app to it, then walks through each one with code.

Overview​

Early Access

Serverside API Extensions are in early access, so details may change.

Instead of calling your backend from the browser, your app calls a serverside function that you write and Trackunit runs. The function calls your backend server-to-server.

  1. Your app calls your function on the Manager host, with its own Trackunit token. That token goes only to Trackunit.
  2. Trackunit verifies the token and invokes your function. Requests that fail verification never reach it.
  3. Your function reads the verified customer account and calls your backend with a request signed by a secret you issued.
  4. Your backend verifies the signature and serves that account.

What this gives you, and what it costs:

  • Trackunit authenticates the user and tells your function which customer the request is for. Your backend never sees a Trackunit token and never has to trust the browser.
  • You store one signing secret in Trackunit yourself, through the public GraphQL API.
  • Function invocations have time and size limits and are retried on failure, so your backend's endpoints must be fast and safe to repeat.

See Serverside function for the implementation.

Complementary: call Trackunit from your backend​

If your backend needs to read from or write to Trackunit on its own schedule, declare a tokenCallback in your manifest. When a customer installs your app, Trackunit sends your backend credentials for that installation, which it exchanges for tokens to call Trackunit's APIs. See Calling the API on behalf of the User for the exchange itself.

The installation callbacks also give your backend a record of which customers have installed your app, which the recommended approach uses to reject unknown accounts. This does not, on its own, authenticate calls from your app to your backend.

The callbacks need care: their Trackunit-Identity header proves less than it may seem, so confirm each callback before acting on it. See Installation callbacks for the implementation.

Your app can also call your backend straight from the browser. Partners usually consider two ways to authenticate those calls, and both have weaknesses that the recommended approach avoids.

Either way, your backend's domain must be declared in the cspHeader of your manifest, and your backend must allow the Manager origin via CORS. These settings only decide whether a browser lets the call through; they do not authenticate it. See Calling external APIs from an Iris App.

Forwarding your app's Trackunit token. The token your app gets from useToken() is issued for Trackunit's APIs, not for your backend.

  • Your backend would rely on a token that was not issued to it.
  • Your backend would hold a credential that can call Trackunit's APIs on the user's behalf.
  • A copied token can be replayed from anywhere until it expires.
  • Trackunit does not support accepting this token outside Trackunit's own APIs.

Your own sign-in. Your app has users sign in to your service with your own identity provider.

  • Users sign in a second time, on top of Trackunit.
  • Your app runs in an iframe inside Trackunit Manager, and many identity providers refuse to show their sign-in pages in a frame, so you need a popup.
  • Your sign-in tells your backend who the user is, but not which Trackunit account they belong to. Trackunit cannot vouch for that link from the browser, so you have to establish it in your own onboarding, for example by having an administrator connect their account in your service to the Trackunit account from the installation callback.

If you use your own sign-in, use the authorization code flow with PKCE, include and check the OAuth state parameter, and with OpenID Connect also use a nonce and validate the ID token. A maintained client library for your provider handles these checks for you. Never accept a Trackunit account or user ID sent by the browser as proof of anything.

Implementation​

Serverside function​

Early Access

Serverside API Extensions are in early access, so details may change.

Call the function from your app​

Call your function at /invoke/@<organization>/<app>/<extension>/<path> on the Manager host, passing your app's token. See Server-side execution.

import { useToken } from "@trackunit/react-core-hooks";

export const useInsights = () => {
const { token } = useToken();

return async () => {
const response = await fetch(`${window.location.origin}/invoke/@my-org/my-app/my-function/insights`, {
headers: { Authorization: `Bearer ${token}` },
});
return response.json();
};
};

Write the function​

import { getAccountId, getSecret } from "@trackunit/serverside-utils";
import { Hono } from "hono";

const PARTNER_API = "https://api.example.com";

const encoder = new TextEncoder();

const toHex = (buffer: ArrayBuffer): string =>
Array.from(new Uint8Array(buffer), byte => byte.toString(16).padStart(2, "0")).join("");

const sha256Hex = async (value: string): Promise<string> =>
toHex(await crypto.subtle.digest("SHA-256", encoder.encode(value)));

const hmacHex = async (secret: string, message: string): Promise<string> => {
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
]);
return toHex(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
};

const app = new Hono();

app.get("/insights", async c => {
// Trackunit verified the user's token before invoking this function, so the
// account comes from verified context, never from the browser's request.
const { accountId } = getAccountId({ context: c });
const secret = await getSecret({ context: c, secretKey: "PARTNER_SIGNING_SECRET" });

// Build the URL once and sign exactly the path and query you send.
const url = new URL(`${PARTNER_API}/insights`);
const target = url.pathname + url.search;
const method = "GET";
const body = "";
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID();
const signature = await hmacHex(
secret,
[timestamp, nonce, method, target, accountId, await sha256Hex(body)].join("\n"),
);

// Send only these headers. Never pass the incoming request's headers through:
// they include the user's Trackunit token.
const response = await fetch(url, {
method,
headers: {
"x-account-id": accountId,
"x-timestamp": timestamp,
"x-nonce": nonce,
"x-signature": signature,
},
});

return new Response(response.body, {
status: response.status,
headers: { "content-type": response.headers.get("content-type") ?? "application/json" },
});
});

export default app.fetch;
  • Declare your backend's domain in the connect-src directive of your manifest's cspHeader. Serverside functions can only reach Trackunit's own domains and the domains listed there.
  • The function also receives the user's Trackunit token and other Trackunit headers. Send your backend only the headers you set yourself; never pass the incoming headers through.
  • Take the account from getAccountId only. accountId is the effective account, which differs from the user's own account when the user is acting on behalf of another account. Never take it from the request your app sent.
  • Knowing the account is not the same as being allowed to act. Guard routes whose actions depend on the user's Trackunit permissions with requireScopes or hasScopes from @trackunit/serverside-utils, and apply your backend's own authorization to restricted data or actions.
  • If your function responds with a 5xx status, Trackunit retries it up to three more times, and each retry sends your backend a new signed request. Make state-changing endpoints idempotent, for example with an idempotency key your app sends, or respond with a non-5xx status for errors that should not be retried.
  • A function invocation times out after 20 seconds, with 256 MB of memory and request bodies of up to 15 MB, so your backend must answer well within that. getSecret calls Trackunit on each use, which adds latency.
  • See Viewing serverside function logs. Never log the signing secret or the signature headers.

Store the signing secret​

The function reads the signing secret with getSecret. Store it as an app secret with the irisAppStoreSecrets mutation in Trackunit's public GraphQL API. The mutation is a preview feature, so send the header TU-PREVIEW: COFFER with the request:

mutation StoreAppSecrets($irisAppId: String!, $secrets: JSON!) {
irisAppStoreSecrets(irisAppId: $irisAppId, secretType: APP, secrets: $secrets) {
success
errorMessage
}
}

Pass secrets as a flat JSON object of string values, for example {"PARTNER_SIGNING_SECRET": "..."}. The call needs the account.iris.app.store-secret scope and admin access to the account that owns your app. It replaces every secret already stored for the app, so include all of them each time. As a preview feature, the mutation may change without notice.

Verify requests in your backend​

import { createHash, createHmac, timingSafeEqual } from "node:crypto";

const MAX_CLOCK_SKEW_SECONDS = 300;

// Nonces already accepted, kept until their timestamp falls outside the window.
// With more than one instance, use a shared store with an atomic set-if-absent
// and expiry instead, for example Redis `SET nonce 1 NX EX 600`.
const acceptedNonces = new Map<string, number>();

type SignedRequest = {
method: string;
/** The raw request target, path and query, as received (Node `req.url`, Express `req.originalUrl`). */
target: string;
/** Lowercased header names, as in Node's `req.headers`. */
headers: Record<string, string | undefined>;
/** The raw body exactly as received, not parsed and serialised again. */
body: string;
};

/**
* Returns the Trackunit account the request is for, or throws if it cannot be trusted.
* `isKnownAccount` must return true only for accounts you have onboarded, for example
* from your installation records: the secret is shared, so a valid signature alone
* does not prove the account is one you serve.
*/
export const verifyFunctionRequest = (
request: SignedRequest,
secret: string,
isKnownAccount: (accountId: string) => boolean,
): string => {
const accountId = request.headers["x-account-id"];
const timestamp = request.headers["x-timestamp"];
const nonce = request.headers["x-nonce"];
const signature = request.headers["x-signature"];
if (!accountId || !timestamp || !nonce || !signature || !/^[0-9a-f]{64}$/.test(signature)) {
throw new Error("Missing or malformed signature headers");
}

const now = Math.floor(Date.now() / 1000);
const signedAt = Number(timestamp);
if (!Number.isInteger(signedAt) || Math.abs(now - signedAt) > MAX_CLOCK_SKEW_SECONDS) {
throw new Error("Stale or invalid timestamp");
}

const bodyHash = createHash("sha256").update(request.body).digest("hex");
const expected = createHmac("sha256", secret)
.update([timestamp, nonce, request.method, request.target, accountId, bodyHash].join("\n"))
.digest();
const received = Buffer.from(signature, "hex");
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
throw new Error("Invalid signature");
}
if (!isKnownAccount(accountId)) {
throw new Error("Unknown account");
}

for (const [id, expiresAt] of acceptedNonces) {
if (expiresAt < now) {
acceptedNonces.delete(id);
}
}
if (acceptedNonces.has(nonce)) {
throw new Error("Request already used");
}
acceptedNonces.set(nonce, signedAt + MAX_CLOCK_SKEW_SECONDS);

return accountId;
};

A valid signature shows that the request came from code holding your signing secret. Apart from your backend, only your app's serverside functions can read it, and Trackunit stores it for them. Because the function takes the account from getAccountId, the account in a validly signed request is one Trackunit verified.

Sign and verify the path and query exactly as your backend receives them, after any proxy or gateway rewriting, and verify against the raw body.

The secret is shared by all accounts, so anyone who obtains it can claim to be any account. That is why the verifier also takes isKnownAccount: reject any account you have not onboarded, for example one missing from your installation records, before you read or change its data. Every serverside function in your app, and every dependency bundled into one, can read the secret. Keep it out of browser code and logs, and use a different secret for local development. Rotating the secret is not instant, so have your backend accept both the old and the new secret during the switch. When you store the new secret, remember that irisAppStoreSecrets replaces all of your app's secrets.

Installation callbacks​

Verify the callback​

Every callback carries a Trackunit-Identity header: a JWT signed by Trackunit, valid for ten minutes. Reject any callback without a valid header, and reject a token ID (jti) you have already accepted:

import { createRemoteJWKSet, jwtVerify } from "jose";

const TRACKUNIT_IDENTITY_JWKS = createRemoteJWKSet(
new URL("https://iris.trackunit.com/api/marketplace/jwks"),
);

const CLOCK_TOLERANCE_SECONDS = 30;

// Token IDs already accepted, kept until jwtVerify would reject the token anyway
// (exp plus the clock tolerance). With more than one instance, claim the jti
// atomically before processing, for example Redis `SET <jti> 1 NX EX <ttl>`:
// a separate read then write lets concurrent duplicates through.
const acceptedTokenIds = new Map<string, number>();

export const verifyTrackunitIdentity = async (header: string | undefined): Promise<void> => {
if (!header) {
throw new Error("Missing Trackunit-Identity header");
}

const { payload } = await jwtVerify(header, TRACKUNIT_IDENTITY_JWKS, {
issuer: "Trackunit Iris Apps",
subject: "Trackunit",
audience: "irisapps",
algorithms: ["RS256"],
requiredClaims: ["exp", "iat", "jti"],
maxTokenAge: "10m",
clockTolerance: CLOCK_TOLERANCE_SECONDS,
});

const { jti, exp } = payload;
if (typeof jti !== "string" || typeof exp !== "number") {
throw new Error("Malformed Trackunit-Identity token");
}

const now = Math.floor(Date.now() / 1000);
for (const [id, rejectableAfter] of acceptedTokenIds) {
if (rejectableAfter < now) {
acceptedTokenIds.delete(id);
}
}
if (acceptedTokenIds.has(jti)) {
throw new Error("Trackunit-Identity token already used");
}
acceptedTokenIds.set(jti, exp + CLOCK_TOLERANCE_SECONDS);
};
What the header does not prove

The Trackunit-Identity token is not specific to your app and does not cover the request body: it carries no app, account, event type, or body digest. A valid header therefore shows only that Trackunit issued it for some callback in the last ten minutes. It cannot tell you that this body, for your app, is genuine, and checking the body's fields cannot authenticate it either. Treat every callback as a notification that you confirm before acting on it.

  • Reject a body whose irisAppId is not your app.
  • On INSTALLED, confirm the delivered credentials before you store them. Request a token with them, then query accountCurrent { id } in Trackunit's public GraphQL API (this needs the account.view scope). Store the installation only if the query succeeds and returns the accountId from the body. If it does not, keep nothing.
  • Store one record per accountId, overwriting rather than appending.

The installation client ID in the body is specific to that installation; it is not a client ID for your app as a whole.

Keep a record of installations​

Store the irisAppId, accountId, client ID, and credentials from each confirmed INSTALLED callback. Trackunit calls the same URL with "type": "UNINSTALLED" when a customer removes your app. There is no separate revocation event, so UNINSTALLED is your only signal, and it needs the same care:

  • Act only if the body's irisAppId, accountId, and client ID exactly match a record you stored. Ignore unknown or mismatched records, responding with a 2xx status.
  • Confirm the uninstall before you delete anything. Trackunit deletes the installation's credentials before it sends UNINSTALLED, so request a token with the stored credentials and act on the outcome:
    • An explicit rejection of the credentials, the OAuth error invalid_client, confirms the uninstall.
    • A successful exchange means the installation still exists. Ignore the callback and respond with a 2xx status.
    • Anything else proves nothing: a timeout, a network error, 429, or a 5xx from the token endpoint. Keep the installation, delete nothing, and respond with an error status so that Trackunit retries the delivery.
  • Once confirmed, delete the record only if it still holds the client ID from the callback, in a single atomic operation such as a conditional delete on accountId and client ID. A customer who reinstalls while you are confirming an uninstall normally gets a new client ID, and that new record must survive. If the client ID is unchanged, the token exchange succeeds and you ignore the callback.

Trackunit may deliver the same callback more than once, each time with a new jti. Make your handler idempotent for each accountId.

Don't rely on UNINSTALLED alone. If a token request with stored credentials ever fails with invalid_client, treat that installation as removed and delete it with the same conditional delete.

In every approach​

  • Never write tokens, secrets, or decoded token contents to application logs or telemetry.
  • Store signing and installation secrets as you would any other credential, and delete installation secrets once an uninstall is confirmed.
  • Treat the Trackunit account as the tenant boundary in your own storage.