Customer Portal Authentication

Portal access tokens are short-lived JWT tokens that authenticate your customers to the Hook Mesh customer portal. Generate them on-demand to grant secure, time-limited access to webhook management.

Portal Token Model

Portal tokens use a secure authentication model designed for customer-facing interfaces:

Short-Lived

Tokens expire after 1 hour by default (configurable up to 24 hours). This limits exposure if tokens are compromised.

Application-Scoped

Each token grants access to a single application's webhooks. Customers can only manage their own endpoints and event types.

Permission-Based

Choose between read-only (view webhooks) or read-write (full management) permissions when generating tokens.

Stateless JWT

Tokens are signed JWTs that can't be revoked early. They automatically expire after the configured duration.

Why short-lived tokens? Portal tokens are meant to be generated fresh for each customer session. Don't cache them long-term—generate a new token every time your customer accesses the portal.

Generating Portal Tokens

Create a portal token by sending a POST request to the portal tokens endpoint:

API Endpoint

POST /v1/applications/{application_id}/portal-tokens
Authorization: Bearer sk_live_abc123...
Content-Type: application/json

{
  "expires_in": 3600,
  "permissions": ["read", "write"]
}

Request Parameters

ParameterTypeDescription
expires_inintegerToken lifetime in seconds. Min: 60, Default: 3600 (1 hour), Max: 86400 (24 hours)
permissionsarrayArray of permissions: ["read"] or ["read", "write"]. Default: ["read", "write"]

Response Format

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBfaWQiOiJhcHBfMTIzIiwicGVybWlzc2lvbnMiOlsicmVhZCIsIndyaXRlIl0sImV4cCI6MTczNzM4NDAwMH0.S2F3YUJhc2U2NEVuY29kZWRTaWduYXR1cmU",
  "url": "https://portal.hookmesh.com/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_at": "2026-01-20T16:30:00Z"
}
FieldDescription
tokenJWT token string to authenticate the customer
expires_atISO 8601 timestamp when the token expires
urlPre-constructed portal URL with token in path (ready to redirect)

Permission Scopes

Portal tokens support two permission levels to control what customers can do:

Read Permission

View-only access to webhook data:

  • View configured endpoints
  • View event types and their schemas
  • View webhook delivery logs and history
  • View retry attempts and failure details
  • Cannot create, update, or delete anything
Read-Only Token
{
  "expires_in": 7200,
  "permissions": ["read"]
}

Write Permission

Full management capabilities (includes read):

  • Create and configure webhook endpoints
  • Update endpoint URLs and settings
  • Enable/disable endpoints
  • Subscribe to event types
  • Test endpoint connectivity
  • Rotate endpoint secrets
  • View all read-permission data
Read-Write Token
{
  "expires_in": 3600,
  "permissions": ["read", "write"]
}

Code Examples

Node.js

async function generatePortalToken(applicationId) {
  const response = await fetch(
    `https://api.hookmesh.com/v1/applications/${applicationId}/portal-tokens`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.HOOKMESH_API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        expires_in: 3600, // 1 hour
        permissions: ['read', 'write']
      })
    }
  );

  if (!response.ok) {
    throw new Error(`Failed to generate token: ${response.statusText}`);
  }

  const data = await response.json();
  return data.token;
}

// Usage in Express route
app.get('/webhook-settings', async (req, res) => {
  const customerId = req.user.id;

  // Get or create application for this customer
  const application = await getCustomerApplication(customerId);

  // Generate fresh portal token
  const result = await generatePortalToken(application.id);

  // Redirect to portal (token in URL path)
  res.redirect(result.url);
});

Python

import os
import requests
from datetime import timedelta

def generate_portal_token(application_id, expires_in=3600, read_only=False):
    """Generate a customer portal access token."""

    permissions = ['read'] if read_only else ['read', 'write']

    response = requests.post(
        f'https://api.hookmesh.com/v1/applications/{application_id}/portal-tokens',
        headers={
            'Authorization': f'Bearer {os.environ.get("HOOKMESH_API_KEY")}',
            'Content-Type': 'application/json'
        },
        json={
            'expires_in': expires_in,
            'permissions': permissions
        }
    )

    response.raise_for_status()
    data = response.json()

    return {
        'token': data['token'],
        'portal_url': data['portal_url'],
        'expires_at': data['expires_at']
    }

# Usage in Flask route
@app.route('/webhook-settings')
@login_required
def webhook_settings():
    customer_id = current_user.id

    # Get application for this customer
    application = get_customer_application(customer_id)

    # Generate token (1 hour expiry)
    result = generate_portal_token(
        application.id,
        expires_in=3600,
        read_only=False
    )

    # Redirect to pre-constructed portal URL
    return redirect(result['url'])

Go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
)

type PortalTokenRequest struct {
    ExpiresIn   int      `json:"expires_in"`
    Permissions []string `json:"permissions"`
}

type PortalTokenResponse struct {
    Token     string `json:"token"`
    ExpiresAt string `json:"expires_at"`
    PortalURL string `json:"portal_url"`
}

func generatePortalToken(applicationID string) (*PortalTokenResponse, error) {
    url := fmt.Sprintf("https://api.hookmesh.com/v1/applications/%s/portal-tokens", applicationID)

    payload := PortalTokenRequest{
        ExpiresIn:   3600, // 1 hour
        Permissions: []string{"read", "write"},
    }

    jsonData, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
    if err != nil {
        return nil, err
    }

    req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("HOOKMESH_API_KEY")))
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return nil, fmt.Errorf("failed to generate token: %s", body)
    }

    var result PortalTokenResponse
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, err
    }

    return &result, nil
}

// Usage in HTTP handler
func webhookSettingsHandler(w http.ResponseWriter, r *http.Request) {
    customerID := getCustomerID(r)

    // Get application for this customer
    application, err := getCustomerApplication(customerID)
    if err != nil {
        http.Error(w, "Failed to get application", http.StatusInternalServerError)
        return
    }

    // Generate portal token
    result, err := generatePortalToken(application.ID)
    if err != nil {
        http.Error(w, "Failed to generate token", http.StatusInternalServerError)
        return
    }

    // Redirect to portal (token in URL path)
    http.Redirect(w, r, result.PortalURL, http.StatusTemporaryRedirect)
}

PHP

<?php

function generatePortalToken($applicationId, $expiresIn = 3600, $readOnly = false) {
    $apiKey = getenv('HOOKMESH_API_KEY');
    $url = "https://api.hookmesh.com/v1/applications/{$applicationId}/portal-tokens";

    $permissions = $readOnly ? ['read'] : ['read', 'write'];

    $data = [
        'expires_in' => $expiresIn,
        'permissions' => $permissions
    ];

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json'
    ]);

    $response = curl_exec($ch);
    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($statusCode !== 200) {
        throw new Exception("Failed to generate token: " . $response);
    }

    return json_decode($response, true);
}

// Usage in Laravel controller
class WebhookSettingsController extends Controller
{
    public function show(Request $request)
    {
        $customerId = auth()->id();

        // Get application for this customer
        $application = $this->getCustomerApplication($customerId);

        // Generate portal token
        $result = generatePortalToken(
            $application->id,
            expiresIn: 3600,
            readOnly: false
        );

        // Redirect to portal
        return redirect($result['url']);
    }
}

Integration Patterns

1. Generate on Login

Generate a token immediately when customers log in to your application:

// After successful authentication
app.post('/login', async (req, res) => {
  const user = await authenticateUser(req.body);

  // Create session
  req.session.user = user;

  // Pre-generate portal token for webhook settings page
  const application = await getCustomerApplication(user.id);
  const token = await generatePortalToken(application.id);

  // Store token in session (expires soon anyway)
  req.session.portalToken = token;

  res.json({ success: true });
});

2. Generate on Portal Access

Generate a fresh token only when customers click to access webhook settings:

// When customer clicks "Webhook Settings" button
app.get('/webhook-settings', async (req, res) => {
  const user = req.session.user;
  const application = await getCustomerApplication(user.id);

  // Generate fresh token on demand
  const result = await generatePortalToken(application.id);

  // Redirect immediately
  res.redirect(result.portal_url);
});

3. Embed in Dashboard

Embed the portal directly in your application using an iframe:

// API endpoint that returns token for frontend
app.get('/api/portal-token', async (req, res) => {
  const user = req.session.user;
  const application = await getCustomerApplication(user.id);

  const result = await generatePortalToken(application.id);

  res.json({
    token: result.token,
    expires_at: result.expires_at
  });
});

// Frontend: Embed portal in iframe
async function loadPortal() {
  const response = await fetch('/api/portal-token');
  const { token } = await response.json();

  const iframe = document.getElementById('portal-iframe');
  iframe.src = `https://portal.hookmesh.com/${token}`;
}
Recommended: Generate tokens on-demand (pattern #2) for the best security posture. Only create tokens when customers actively need portal access.

Token Expiration & Renewal

Handling Expired Tokens

When a token expires, the portal will display an error message. Handle this gracefully:

// Listen for portal iframe messages
window.addEventListener('message', async (event) => {
  if (event.origin !== 'https://portal.hookmesh.com') return;

  if (event.data.type === 'token_expired') {
    // Token expired - generate a new one
    const response = await fetch('/api/portal-token');
    const { token } = await response.json();

    // Reload portal with fresh token
    const iframe = document.getElementById('portal-iframe');
    iframe.src = `https://portal.hookmesh.com/${token}`;
  }
});

Automatic Renewal

For long-running portal sessions, refresh tokens before they expire:

let tokenExpiresAt;

async function renewTokenBeforeExpiry() {
  // Refresh token 5 minutes before expiry
  const renewAt = new Date(tokenExpiresAt).getTime() - (5 * 60 * 1000);
  const now = Date.now();
  const delay = renewAt - now;

  if (delay > 0) {
    setTimeout(async () => {
      const response = await fetch('/api/portal-token');
      const { token, expires_at } = await response.json();

      tokenExpiresAt = expires_at;

      // Update portal with new token
      const iframe = document.getElementById('portal-iframe');
      iframe.contentWindow.postMessage(
        { type: 'update_token', token },
        'https://portal.hookmesh.com'
      );

      // Schedule next renewal
      renewTokenBeforeExpiry();
    }, delay);
  }
}

Token Refresh

For users who remain in the portal longer than the token lifetime, you need a refresh strategy. Here are three common patterns:

Pattern 1: Automatic Redirect on Expiry

The simplest approach - redirect users to regenerate the token:

// React - Detect expiry and redirect
import { useEffect, useState } from 'react';

function PortalWrapper({ appId, initialToken, expiresAt }) {
  const [portalUrl, setPortalUrl] = useState(
    `https://portal.hookmesh.com/${initialToken}`
  );

  useEffect(() => {
    const expiryTime = new Date(expiresAt).getTime();
    const now = Date.now();
    const timeUntilExpiry = expiryTime - now;

    if (timeUntilExpiry > 0) {
      // Set timer to redirect before expiry
      const timer = setTimeout(() => {
        // Redirect to your token generation endpoint
        window.location.href = `/dashboard/webhooks?app_id=${appId}`;
      }, timeUntilExpiry - 60000); // Redirect 1 minute before expiry

      return () => clearTimeout(timer);
    }
  }, [appId, expiresAt]);

  return <iframe src={portalUrl} />;
}

Pattern 2: Background Token Refresh

Refresh the token without interrupting the user:

// React - Background refresh with iframe reload
import { useEffect, useState, useRef } from 'react';

function PortalWithRefresh({ appId, userId }) {
  const [portalUrl, setPortalUrl] = useState('');
  const [expiresAt, setExpiresAt] = useState<Date | null>(null);
  const iframeRef = useRef<HTMLIFrameElement>(null);

  // Initial token generation
  useEffect(() => {
    generateToken(appId, userId);
  }, [appId, userId]);

  // Refresh timer
  useEffect(() => {
    if (!expiresAt) return;

    const expiryTime = expiresAt.getTime();
    const now = Date.now();
    const timeUntilExpiry = expiryTime - now;

    if (timeUntilExpiry > 0) {
      // Refresh 5 minutes before expiry
      const refreshTime = timeUntilExpiry - (5 * 60 * 1000);

      const timer = setTimeout(async () => {
        await generateToken(appId, userId);

        // Reload iframe with new token
        if (iframeRef.current) {
          iframeRef.current.src = portalUrl;
        }
      }, refreshTime);

      return () => clearTimeout(timer);
    }
  }, [expiresAt, appId, userId, portalUrl]);

  async function generateToken(appId: string, userId: string) {
    const response = await fetch('/api/portal-token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ appId, userId })
    });

    const { url, expires_at } = await response.json();
    setPortalUrl(url);
    setExpiresAt(new Date(expires_at));
  }

  return (
    <iframe
      ref={iframeRef}
      src={portalUrl}
      style={{ width: '100%', height: '600px', border: 'none' }}
    />
  );
}

Pattern 3: Long-Lived Tokens with Read-Only Access

For support scenarios where users need extended access:

// Node.js - Generate long-lived read-only token
const response = await fetch(
  `https://api.hookmesh.com/v1/applications/${appId}/portal-tokens`,
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.HOOKMESH_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      expires_in: 86400, // 24 hours (maximum)
      permissions: ['read'] // Read-only for safety
    })
  }
);

const { url, expires_at } = await response.json();
# Python - Long-lived read-only token
response = requests.post(
    f'https://api.hookmesh.com/v1/applications/{app_id}/portal-tokens',
    headers={
        'Authorization': f'Bearer {os.getenv("HOOKMESH_API_KEY")}',
        'Content-Type': 'application/json'
    },
    json={
        'expires_in': 86400,  # 24 hours (maximum)
        'permissions': ['read']  # Read-only for safety
    }
)

portal_data = response.json()
url = portal_data['url']

Handling Refresh in Backend

Your backend token generation endpoint should track refresh requests:

// Express.js - Token refresh endpoint
app.post('/api/portal-token', async (req, res) => {
  const { appId, userId } = req.body;

  // Verify user has access to this application
  const hasAccess = await checkUserAccess(userId, appId);
  if (!hasAccess) {
    return res.status(403).json({ error: 'Access denied' });
  }

  // Generate new token
  const response = await fetch(
    `https://api.hookmesh.com/v1/applications/${appId}/portal-tokens`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.HOOKMESH_API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        expires_in: 3600, // 1 hour
        permissions: ['read', 'write']
      })
    }
  );

  const { url, expires_at } = await response.json();

  // Optional: Log refresh for audit trail
  await logPortalAccess(userId, appId, 'token_refresh');

  res.json({ url, expires_at });
});

Best Practices

Do:

  • Refresh tokens before they expire (5 minutes buffer)
  • Use read-only tokens for long-lived sessions
  • Log token generation for security auditing
  • Handle network errors during refresh gracefully

Don't:

  • Don't wait until token expires to refresh
  • Don't generate tokens with write access for 24-hour sessions
  • Don't cache tokens in localStorage (security risk)
  • Don't refresh more frequently than necessary (rate limits)

Security Best Practices

✅ Generate tokens on-demand

Create fresh tokens when customers access the portal, not in advance. This minimizes the window of exposure if tokens are compromised.

✅ Use short expiration times

Default 1-hour expiration is recommended. Only extend for specific use cases like support sessions.

✅ Always use HTTPS

Portal tokens must be transmitted over HTTPS only. Never send tokens over unencrypted HTTP connections.

✅ Validate application ownership

Always verify that the authenticated customer owns the application before generating tokens. Never generate tokens for other customers' applications.

✅ Use read-only for support

Generate read-only tokens when your support team needs to view customer webhook data to prevent accidental changes.

❌ Don't cache long-term

Never store portal tokens in databases or cache them beyond their expiration time. Generate fresh tokens for each session.

❌ Don't log tokens

Tokens are sensitive credentials. Sanitize logs to prevent token exposure. Treat them like passwords.

❌ Don't share tokens

Each customer should get their own token. Never reuse tokens across multiple customers or sessions.

Multi-Tenant Considerations

If your application serves multiple customers, ensure proper isolation:

async function generateCustomerPortalToken(customerId) {
  // 1. Authenticate the requesting user
  const requestingUser = await getCurrentUser();

  // 2. Verify the requesting user has access to this customer
  if (!await hasAccessToCustomer(requestingUser, customerId)) {
    throw new Error('Unauthorized: Cannot access this customer');
  }

  // 3. Get the application for this specific customer
  const application = await getCustomerApplication(customerId);

  if (!application) {
    throw new Error('No application found for customer');
  }

  // 4. Generate token scoped to this customer's application
  const result = await generatePortalToken(application.id);

  // 5. Log the access for audit trail
  await logPortalAccess({
    customerId,
    applicationId: application.id,
    requestingUserId: requestingUser.id,
    expiresAt: result.expires_at
  });

  return result;
}

Testing Portal Access

Local Development

During development, test portal token generation without redirecting:

// Development endpoint to test token generation
if (process.env.NODE_ENV === 'development') {
  app.get('/dev/test-portal-token', async (req, res) => {
    const testApplicationId = 'app_test_123';

    try {
      const result = await generatePortalToken(testApplicationId);

      res.json({
        success: true,
        token: result.token,
        portal_url: result.portal_url,
        expires_at: result.expires_at,
        decoded: decodeJWT(result.token) // Show token contents
      });
    } catch (error) {
      res.status(500).json({
        success: false,
        error: error.message
      });
    }
  });
}

function decodeJWT(token) {
  const [header, payload, signature] = token.split('.');
  return {
    header: JSON.parse(Buffer.from(header, 'base64').toString()),
    payload: JSON.parse(Buffer.from(payload, 'base64').toString())
  };
}

Testing with cURL

# Generate a portal token
curl -X POST https://api.hookmesh.com/v1/applications/app_abc123/portal-tokens \
  -H "Authorization: Bearer sk_test_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "expires_in": 3600,
    "permissions": ["read", "write"]
  }'

# Response
{
  "token": "eyJhbGciOi...",
  "url": "https://portal.hookmesh.com/eyJhbGciOi...",
  "expires_at": "2026-01-20T16:30:00Z"
}

# Test the portal URL in your browser
open "https://portal.hookmesh.com/eyJhbGciOi..."

Revoking Access

Portal tokens cannot be revoked before their expiration time since they're stateless JWTs. To revoke access:

  • Wait for expiration: Tokens automatically expire after the configured duration (default 1 hour)
  • Use short expiration: Set tokens to expire quickly (60 seconds for one-time use)
  • Disable the application: If you need immediate revocation, disable the entire application
  • Don't generate new tokens: Stop generating tokens for customers who shouldn't have access
One-Time Portal Access
// Generate a token that expires in 60 seconds
// Useful for one-time access links sent via email
async function generateOneTimePortalLink(applicationId) {
  const result = await fetch(
    `https://api.hookmesh.com/v1/applications/${applicationId}/portal-tokens`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.HOOKMESH_API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        expires_in: 60, // 1 minute
        permissions: ['read', 'write']
      })
    }
  );

  const data = await response.json();
  return data.url;
}

Error Handling

400 Bad Request

Returned when token generation parameters are invalid:

{
  "error": {
    "code": "invalid_request",
    "message": "expires_in must be between 60 and 86400 seconds"
  }
}

404 Not Found

Returned when the application doesn't exist:

{
  "error": {
    "code": "not_found",
    "message": "Application not found"
  }
}

429 Rate Limit

Portal token generation is rate limited to 100 requests per minute per organization:

{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Portal token generation rate limit exceeded. Retry after 60 seconds.",
    "retry_after": 60
  }
}

Next Steps