Zoho billing logo Help Docs
/

Web Tabs

Web tabs display an embeddable external web page or application inside Zoho Billing. They let your team access external tools without switching browser tabs. You can also enable JSON Web Token (JWT) authentication so your app can verify the Zoho Billing organization and user behind each request. You can create a maximum of 10 web tabs.

Insight: A JWT is a standard format for securely transmitting information as a signed JSON object. It consists of three parts: a header, a payload, and a signature, separated by dots.

Web tabs can be used in many ways depending on your business needs.

Scenario: Zylker Manufacturing collects recurring payments from customers through a third-party payment gateway that isn’t yet integrated with Zoho Billing. Their finance team logs in to the gateway’s dashboard separately to check settlement reports and failed transactions. They create a web tab pointing to the gateway’s dashboard URL so accountants can review payment settlements without leaving Zoho Billing.

Notes:

  • Web pages that use http:// do not open in web tabs. Use an https:// URL.
  • Some pages and applications block embedding to prevent clickjacking. You cannot open these pages in web tabs.
  • Web tabs do not connect to other Zoho Billing modules or affect their data.

Create a Web Tab

To create a new web tab:

  • Go to Settings.
  • Select Web Tabs under Customization.
  • Click + New Web Tab in the top-right corner.
  • Enter a name for the web tab in the Tab Name field.
  • Enter the URL of the external application in the URL field.
    • To include dynamic values like the organization name or customer ID in the URL, click Insert Placeholders and select the values you need.
  • Select This URL belongs to a Zoho app or website if the URL points to a Zoho product or website.

Scenario: Zylker Manufacturing embeds their payment gateway’s dashboard as a web tab in Zoho Billing. Without JWT authentication, the gateway has no way to identify which company opened the tab. Anyone with the URL could open it and potentially see another company’s settlement data. Enabling JWT authentication solves this: Zoho Billing signs each request with a token that identifies Zylker’s organization and user, so the gateway loads only Zylker’s data.

  • To verify that requests to your external app came from Zoho Billing for the right organization and user, enable JWT Authentication.
    • Enter a Secret Key between 32 and 500 characters. Zoho Billing uses this key to sign each token it sends to your app. Keep it on your server only and do not include it in frontend code.
    • Select a Token Validity period: 10 minutes, 30 minutes, 1 hour, or 3 hours (default).
  • Under Visibility, select who can view this web tab:
    • Only Me: Only you can see the web tab.
    • Only Selected Users & Roles: Select specific users and roles from the dropdown that appears.
    • Everyone: All users in your organization can see the web tab.
  • Click Save.

After saving, the web tab appears in the left sidebar under Web Tabs. Click it to open your external app within Zoho Billing.

Note: When JWT authentication is enabled, Zoho Billing sends a signed token after the web tab opens. Your server verifies this token to confirm the request came from Zoho Billing for the right organization and user. Tokens expire after the validity period you set. When a token expires, your web tab application can request a new one programmatically.

Edit a Web Tab

You can edit a web tab to update its name, URL, JWT authentication settings, or visibility. To edit a web tab:

  • Go to Settings.
  • Select Web Tabs under Customization.
  • Click the web tab you want to edit, or hover over it, click the Dropdown icon, and select Edit.
  • Make the necessary changes and click Save.

Zoho Billing applies the changes to the web tab and its visibility settings.

Validate a JWT Token

The Validate JWT Token feature lets you verify a token your app received from Zoho Billing. Use it to confirm the token details or diagnose issues before making changes to your backend.

To validate a JWT token:

  • Go to Settings.
  • Select Web Tabs under Customization.
  • Click the web tab you want to edit, or hover over it, click the Dropdown icon, and select Edit.
  • Click Validate JWT Token in the top-right corner of the Edit Web Tab page.
  • Paste your JWT token into the JWT Token field.
  • Click Verify. Zoho Billing verifies the token against the Secret Key saved on this web tab and shows one of these results:
ResultWhat It Means
Token is validThe signature is valid and the token has not expired. Zoho Billing shows the signature as Valid, the validity as Valid until {date and time}, and the decoded payload with the organization ID, user ID, token type, and web tab ID.
Token is invalidThe token signature is invalid. It was not signed with this web tab’s Secret Key, or the token was modified after it was issued. Zoho Billing shows the signature as Invalid and the payload as Unverified, since it cannot be trusted.
Token has expiredThe token’s expiry time has passed. Zoho Billing shows the signature as Valid, but the validity shows Expired on {date and time}. Reload the web tab to get a new token.
tab_id does not match this web tabThe tab_id in the payload of this JWT token doesn’t match your web tab. Zoho Billing shows the signature as Valid and the validity as Valid until {date and time}, but the token belongs to a different web tab.

Note: If you’re building the server integration for your web tab, see JWT Authentication on this page for payload claims, server-side validation steps, the token refresh flow, and code samples in Node.js, Python, Java, PHP, and Go.


Mark a Web Tab as Inactive

If you no longer need a web tab, you can mark it as inactive instead of deleting it. Inactive web tabs are hidden from the left sidebar and cannot be opened, but they can be marked as active later if needed.

To mark a web tab as inactive:

  • Go to Settings.
  • Select Web Tabs under Customization.
  • Hover over the web tab you want to mark as inactive, click the Dropdown icon, and select Mark as Inactive.

The web tab is hidden from the left sidebar and remains available for reactivation.


Mark a Web Tab as Active

To mark an inactive web tab as active:

  • Go to Settings.
  • Select Web Tabs under Customization.
  • Hover over the inactive web tab you want to mark as active, click the Dropdown icon, and select Mark as Active.

The web tab reappears in the left sidebar for users included in its visibility settings.


Delete a Web Tab

Warning: Deleting a web tab removes it permanently from Zoho Billing and cannot be undone. To hide the web tab without deleting it, mark it as inactive instead.

To delete a web tab:

  • Go to Settings.
  • Select Web Tabs under Customization.
  • Hover over the web tab you want to delete, click the Dropdown icon, and select Delete.
  • Click OK in the confirmation pop-up.

Zoho Billing permanently removes the web tab.


JWT Authentication

Web tabs can be created under Settings for your organization users, under Customer Portal settings for your customers, and as components inside extensions built on the Zoho Billing Developer Portal. You can enable JWT authentication on any of these web tabs so your external app can verify that requests came from Zoho Billing for the right organization and user. The token mechanism is the same in all three cases.

How the Token Is Delivered

The JWT token is not appended to the web tab URL. Zoho Billing delivers it to your app via postMessage from the Billing parent window after your app loads in the iframe.

When a user opens the web tab, Zoho Billing:

  • Loads your app’s URL in the iframe.
  • Posts the JWT token to your app using the ZOHO_WEBTAB_AUTH_TOKENS message type.

Your app receives the message and forwards the token to your backend for validation. Your server validates the token before rendering any data.

The message your app receives:

{
  "type": "ZOHO_WEBTAB_AUTH_TOKENS",
  "jwt_token": "eyJhbGciOiJIUzI1NiJ9..."
}

When a session ends, Zoho Billing sends jwt_token: null in the same message type.


JWT Payload Claims

After your server verifies the token signature, you can read the following claims from the payload:

ClaimDescription
organization_idThe Zoho Billing organization ID. Map this to your tenant to load the correct data.
user_idThe Zoho Billing user ID for web tabs accessed by organization users, or the contact/customer ID for Customer Portal web tabs.
token_typeAlways access. Reject a token if this value differs.
tab_idThe ID of the web tab for which the token was issued.
iatThe issued-at time as a Unix timestamp in seconds.
expThe expiration time as a Unix timestamp in seconds. Reject the token after this time.

Token settings:

SettingValue
AlgorithmHS256 (HMAC-SHA256)
Signing keyThe Secret Key configured on the web tab, as UTF-8 bytes
FormatStandard JWT (header.payload.signature)

Token validity options:

Token Validity SettingLifetime
10 minutes600 seconds
30 minutes1,800 seconds
1 hour3,600 seconds
3 hours (default)10,800 seconds

What JWT Validation Protects

Validating the JWT token confirms that:

  • The web tab request was generated by Zoho Billing.
  • The token was signed using the Secret Key configured for that web tab.
  • The token was not modified in transit.
  • The token has not expired.
  • The request belongs to the expected web tab, organization, and user.

Do not trust placeholder values or JWT claims until the signature and expiration checks pass.


Validate on Your Server

Run these checks in order on every JWT token your front end receives through postMessage:

  • Reject if the token is missing or blank.
  • Verify the signature with your web tab Secret Key and the HS256 algorithm.
  • Reject if exp is missing or in the past.
  • Reject if iat, organization_id, user_id, token_type, or tab_id is missing.
  • Reject if token_type is not access.
  • Reject if tab_id does not match the web tab ID expected by your app.
  • Only then use organization_id and user_id to load data.

Do not render sensitive content before these checks pass.


Token Refresh

JWT tokens expire after the Token Validity period you set. The default is three hours. Your app does not call Zoho APIs directly to get a new token. Instead, it sends a postMessage request to the Zoho Billing parent window, which returns a new token.

StepWhoAction
1Your appDetects token expiration or receives a 401 response for an expired token from your API
2Your appSends ZOHO_WEBTAB_REQUEST_TOKEN_REFRESH to the Zoho Billing parent through postMessage
3Zoho BillingCalls the refresh API internally
4Zoho BillingReturns a new token in a ZOHO_WEBTAB_AUTH_TOKENS message
5Your appValidates the new token on your server

The refresh request your app sends:

{
  "type": "ZOHO_WEBTAB_REQUEST_TOKEN_REFRESH"
}

Rules:

  • Only Zoho Billing calls the refresh API. Do not call Zoho refresh endpoints from your backend.
  • If Zoho Billing does not return a token within your app’s timeout, stop authenticated requests and ask the user to reload the web tab.
  • Do not log full JWT tokens or your Secret Key.

URL Placeholders and Trust

You can include supported placeholders in the web tab URL using Insert Placeholders when configuring the web tab. For example:

https://yourapp.example.com/entry?customer_id=${CONTACT.CONTACT_ID}

Zoho Billing resolves placeholders before loading your app. Treat placeholder values as untrusted until your server validates the JWT. An attacker could craft a URL with arbitrary placeholder values. Read organization_id and user_id only from the verified JWT payload.


Secret Key Security

The Secret Key is shared only between Zoho Billing and your backend.

RuleDetail
Minimum length32 characters
Maximum length500 characters
StorageEnvironment variables or a secret manager on your server only

Never store the Secret Key in:

  • Frontend JavaScript
  • Mobile apps
  • Public repositories
  • Logs
  • Browser-visible responses
  • Client-side configuration files

Server SDK Samples

Use these samples to validate a JWT token on your backend. Store your Secret Key in an environment variable. Never include it in front-end code. Each library reports invalid signatures and expired tokens through an exception or error. Handle that failure and return an unauthorized response without exposing validation details.

Node.js

Dependency: jsonwebtoken

const jwt = require("jsonwebtoken");

function validateJwtToken(token, secret, expectedTabId) {
  const claims = jwt.verify(token, secret, { algorithms: ["HS256"] });
  const requiredClaims = ["exp", "iat", "organization_id", "user_id", "token_type", "tab_id"];
  if (requiredClaims.some((claim) => claims[claim] == null || claims[claim] === "")) {
    throw new Error("Token is missing required claims.");
  }
  if (claims.token_type !== "access") throw new Error("Invalid token type.");
  if (String(claims.tab_id) !== String(expectedTabId)) {
    throw new Error("Invalid tab.");
  }
  return {
    organization_id: claims.organization_id,
    user_id: claims.user_id,
    tab_id: claims.tab_id,
  };
}

Python

Dependency: PyJWT

import jwt

def validate_jwt_token(token, secret, expected_tab_id):
    required_claims = ["exp", "iat", "organization_id", "user_id", "token_type", "tab_id"]
    claims = jwt.decode(
        token,
        secret.encode("utf-8"),
        algorithms=["HS256"],
        options={"require": required_claims},
    )
    if any(claims.get(claim) in (None, "") for claim in required_claims):
      raise ValueError("Token is missing required claims.")
    if claims.get("token_type") != "access":
        raise ValueError("Invalid token type.")
    if str(claims.get("tab_id")) != str(expected_tab_id):
        raise ValueError("Invalid tab.")
    return {
        "organization_id": claims["organization_id"],
        "user_id": claims["user_id"],
        "tab_id": claims["tab_id"],
    }

Java

Dependencies: io.jsonwebtoken:jjwt-api, io.jsonwebtoken:jjwt-impl, and io.jsonwebtoken:jjwt-jackson

SecretKey key = Keys.hmacShaKeyFor(webTabSecret.getBytes(StandardCharsets.UTF_8));
Jws<Claims> parsed = Jwts.parser()
  .verifyWith(key)
  .build()
  .parseSignedClaims(token);

if (!Jwts.SIG.HS256.getId().equals(parsed.getHeader().getAlgorithm())) {
  throw new IllegalArgumentException("Invalid signing algorithm.");
}

Claims claims = parsed.getPayload();
if (claims.getExpiration() == null || claims.getIssuedAt() == null
    || claims.get("organization_id", String.class) == null
    || claims.get("user_id", String.class) == null
    || claims.get("token_type", String.class) == null
    || claims.get("tab_id", String.class) == null) {
  throw new IllegalArgumentException("Token is missing required claims.");
}
if (!"access".equals(claims.get("token_type", String.class))) {
    throw new IllegalArgumentException("Invalid token type.");
}
if (!expectedTabId.equals(claims.get("tab_id", String.class))) {
    throw new IllegalArgumentException("Invalid tab.");
}
// Use claims.get("organization_id") and claims.get("user_id")

PHP

Dependency: firebase/php-jwt

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function validateJwtToken(string $token, string $secret, string $expectedTabId): array
{
  try {
    $claims = (array) JWT::decode($token, new Key($secret, 'HS256'));
  } catch (Throwable $exception) {
    throw new InvalidArgumentException('Invalid token.', 0, $exception);
  }

  $requiredClaims = ['exp', 'iat', 'organization_id', 'user_id', 'token_type', 'tab_id'];
  foreach ($requiredClaims as $claim) {
    if (!isset($claims[$claim]) || $claims[$claim] === '') {
      throw new InvalidArgumentException('Token is missing required claims.');
    }
  }
  if ($claims['token_type'] !== 'access') {
    throw new InvalidArgumentException('Invalid token type.');
  }
  if ((string) $claims['tab_id'] !== $expectedTabId) {
    throw new InvalidArgumentException('Invalid tab.');
  }

  return [
    'organization_id' => $claims['organization_id'],
    'user_id' => $claims['user_id'],
    'tab_id' => $claims['tab_id'],
  ];
}

Go

Dependency: github.com/golang-jwt/jwt/v5

package webtabs

import (
  "errors"
  "fmt"

  "github.com/golang-jwt/jwt/v5"
)

type webTabClaims struct {
  OrganizationID string `json:"organization_id"`
  UserID         string `json:"user_id"`
  TokenType      string `json:"token_type"`
  TabID          string `json:"tab_id"`
  jwt.RegisteredClaims
}

func validateJWTToken(token, secret, expectedTabID string) (*webTabClaims, error) {
  claims := &webTabClaims{}
  parsed, err := jwt.ParseWithClaims(
    token,
    claims,
    func(token *jwt.Token) (interface{}, error) {
      return []byte(secret), nil
    },
    jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
    jwt.WithExpirationRequired(),
  )
  if err != nil {
    return nil, fmt.Errorf("invalid token: %w", err)
  }
  if !parsed.Valid {
    return nil, errors.New("invalid token")
  }
  if claims.IssuedAt == nil || claims.OrganizationID == "" || claims.UserID == "" || claims.TokenType == "" || claims.TabID == "" {
    return nil, errors.New("token is missing required claims")
  }
  if claims.TokenType != "access" {
    return nil, errors.New("invalid token type")
  }
  if expectedTabID != claims.TabID {
    return nil, errors.New("invalid tab ID")
  }

  return claims, nil
}

Client SDK

Your app runs in an iframe inside Zoho Billing or the Customer Portal. The client helper in this section receives JWT tokens through postMessage and requests new tokens when they expire.

API

MethodPurpose
createWebTabAuthClient({ parentOrigin, onToken, onTokenExpired, onSessionEnded })Create a client. parentOrigin is required and must be the exact Zoho Billing or portal origin.
init()Start listening for ZOHO_WEBTAB_AUTH_TOKENS messages from the parent.
destroy()Remove the listener, clear the expiration timer, and drop the in-memory token. Call it when the session ends or your app unloads.
getAccessToken()Return the current JWT string or null.
requestRefresh()Send ZOHO_WEBTAB_REQUEST_TOKEN_REFRESH to the parent so Zoho Billing can issue a new token.
onToken({ jwt_token })Run when a new token arrives during initial load or after a refresh.
onTokenExpired()Run when the token’s exp time is reached. Request a refresh from this callback.
onSessionEnded()Run when the parent sends jwt_token: null. Stop authenticated requests and end the local session.

The helper verifies event.origin and event.source, keeps one JWT in memory, and schedules onTokenExpired() from the token’s exp claim. It does not call your backend or Zoho APIs. Your app must validate the JWT on the server.

Set parentOrigin to the exact origin for your Zoho Billing data center or Customer Portal host. An origin includes the scheme and host, but no path or trailing slash. Never use * for incoming validation or outgoing messages.

Quick Start

Add the SDK source shown below to a file named zoho-webtab-auth-sdk.js in your app. Then initialize the helper with your parent origin and server verification endpoint.

<script type="module">
  import { createWebTabAuthClient } from './zoho-webtab-auth-sdk.js';

  const JWT_VERIFICATION_API_ENDPOINT = '/api/webtab/session'; // endpoint: your JWT Token verification endpoint

  const auth = createWebTabAuthClient({
    parentOrigin: 'https://billing.zoho.com', // portal: your portal host origin
    onToken({ jwt_token }) {
      fetch(JWT_VERIFICATION_API_ENDPOINT, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jwt_token }),
      });
    },
    onSessionExpired() {
      auth.requestRefresh();
    },
  });

  auth.init();
</script>

Call auth.requestRefresh() only when your API reports that the JWT has expired. Do not refresh automatically for other 401 responses. If the parent does not return a token within your app’s timeout, call auth.destroy(), stop protected requests, and ask the user to reload the web tab. Do not call Zoho refresh endpoints from your server.

SDK Source

const MESSAGE_AUTH_TOKENS = 'ZOHO_WEBTAB_AUTH_TOKENS';
const MESSAGE_REQUEST_REFRESH = 'ZOHO_WEBTAB_REQUEST_TOKEN_REFRESH';

function getTokenExpiryMs(token) {
  try {
    const segment = token.split('.')[1];
    if (!segment) return null;
    const payload = JSON.parse(atob(segment.replace(/-/g, '+').replace(/_/g, '/')));
    if (!payload.exp) return null;
    return payload.exp * 1000;
  } catch {
    return null;
  }
}

export function createWebTabAuthClient(config) {
  const { parentOrigin, onToken, onSessionExpired } = config || {};
  if (!parentOrigin) throw new Error('parentOrigin is required');

  let jwt_token = null;
  let initialized = false;
  let expiryTimerId = null;

  function clearExpiryTimer() {
    if (expiryTimerId) { clearTimeout(expiryTimerId); expiryTimerId = null; }
  }

  function scheduleExpiry(token) {
    clearExpiryTimer();
    const expiresAt = getTokenExpiryMs(token);
    if (!expiresAt) return;
    const delay = expiresAt - Date.now();
    if (delay <= 0) { onSessionExpired?.(); return; }
    expiryTimerId = setTimeout(() => { expiryTimerId = null; onSessionExpired?.(); }, delay);
  }

  function onMessage(event) {
    if (event.origin !== parentOrigin) return;
    const data = event.data;
    if (!data || data.type !== MESSAGE_AUTH_TOKENS) return;
    if (data.jwt_token === null || data.jwt_token === undefined) {
      clearExpiryTimer(); jwt_token = null; onSessionExpired?.(); return;
    }
    jwt_token = data.jwt_token;
    scheduleExpiry(jwt_token);
    onToken?.({ jwt_token });
  }

  function init() {
    if (initialized) return;
    initialized = true;
    window.addEventListener('message', onMessage);
  }

  function destroy() {
    if (!initialized) return;
    initialized = false;
    window.removeEventListener('message', onMessage);
    clearExpiryTimer();
    jwt_token = null;
  }

  function getAccessToken() { return jwt_token; }

  function requestRefresh() {
    window.parent.postMessage({ type: MESSAGE_REQUEST_REFRESH }, parentOrigin);
  }

  return { init, destroy, getAccessToken, requestRefresh };
}

export default createWebTabAuthClient;

if (typeof window !== 'undefined') {
  window.ZohoWebTabAuth = { createWebTabAuthClient };
}
Was this document helpful?
Yes
No

Thank you for your feedback!