Build custom feedback portals with UserVoice API and Webflow

Learn how to integrate UserVoice with Webflow to create custom feedback portals, using a secure API proxy, OAuth 2.0, webhooks, and CMS syncing.

Build custom feedback portals with UserVoice API and Webflow

Table of contents

Build a fully custom UserVoice feedback portal in Webflow using the API, webhooks, OAuth, and a secure server-side proxy.

UserVoice provides a REST API v2 and webhook capabilities that enable custom feedback portal implementations beyond what the standard widget offers.

This guide covers the API integration pattern—server-side proxy architecture, OAuth 2.0 authentication, webhook configuration, and CMS synchronization for building custom feedback experiences in Webflow. For simple widget embedding without server infrastructure, see the companion guide: Embed UserVoice feedback widgets in Webflow.

What this integration enables

The API integration pattern supports advanced use cases that require server-side processing:

  • Custom feedback submission forms with your own UI design
  • Displaying UserVoice data (suggestions, vote counts, statuses) in Webflow CMS
  • Real-time updates via webhooks when ideas are submitted or status changes
  • Single Sign-On (SSO) integration with your authentication system
  • Programmatic suggestion creation and management
  • Custom voting interfaces and feedback dashboards

Architecture overview

Custom implementations using the UserVoice Admin API require server-side proxy infrastructure due to two critical technical constraints:

  1. Webflow client-side limitations: According to Custom code in head and body tags, Webflow only supports client-side JavaScript, HTML, and CSS—no server-side languages.
  2. API credential security: The Component Architecture documentation explicitly warns: "Never include secrets in your component code. All JavaScript you write gets sent to the browser and is visible to your site visitors."

Your Webflow site communicates with a proxy server, which authenticates against the UserVoice Admin API using OAuth 2.0 Client Credentials flow. The proxy securely stores API credentials in environment variables and manages token acquisition and refresh.

Prerequisites

Set up your UserVoice account

  1. Create a UserVoice account and set up a forum through the admin console settings
  2. Generate API credentials in Admin Console under Settings → Integrations → UserVoice API keys
  3. Create a "Trusted API Client" and check the "Trusted" checkbox as documented in the API Key documentation
  4. Save your Client ID and Client Secret securely
  5. Note your subdomain (the yourcompany portion of yourcompany.uservoice.com)

Configure your Webflow workspace

  1. Ensure your workspace uses a plan that supports custom code
  2. Verify you have Admin or Owner workspace permissions (required to register apps and manage API credentials)
  3. For CMS synchronization, register an app in your workspace settings to generate Webflow OAuth credentials

Server-side proxy implementation

Hosting options

Implement a server-side proxy component using one of these approaches:

  • Webflow Cloud (recommended for staying on-platform): Deploy a Next.js or Astro application alongside your Webflow site using Webflow Cloud. This approach is more complex than standalone serverless functions but keeps your entire stack within Webflow's ecosystem and eliminates separate hosting management.
  • Standalone serverless functions: Use AWS Lambda, Cloudflare Workers, Vercel, or Netlify Functions for lightweight, cost-effective deployments.
  • Traditional server infrastructure: For applications requiring more control over the runtime environment.

Proxy requirements

Your proxy must:

  • Store UserVoice API credentials (Client ID and Client Secret) securely in environment variables
  • Generate and cache OAuth 2.0 Client Credentials access tokens (valid for 7,200 seconds)
  • Validate incoming requests originating from your Webflow domain
  • Validate and allowlist requested resources to prevent arbitrary endpoint access
  • Proxy authenticated requests to UserVoice Admin API v2 endpoints using Bearer token authorization
  • Forward UserVoice API responses to the client while monitoring rate limit headers
  • Implement exponential backoff retry logic for handling rate-limited responses (HTTP 429)

Configure CORS headers on your proxy to allow requests from your Webflow domain.

Authentication flow

UserVoice implements OAuth 2.0 Client Credentials for server-side applications. The flow:

  1. Your proxy requests an access token by sending client credentials
  2. UserVoice returns a bearer token valid for 7,200 seconds (2 hours)
  3. Include the token in the Authorization: Bearer {token} header for all API requests

Token acquisition:

curl https://SUBDOMAIN.uservoice.com/api/v2/oauth/token \
  --data "grant_type=client_credentials&client_id=KEY&client_secret=SECRET"

Response:

{
  "access_token": "096e8ae9c6a3c039",
  "token_type": "bearer",
  "expires_in": 7200
}

Example proxy implementation

// Proxy implementation for serverless platforms (AWS Lambda/Vercel/Netlify)
// Note: Uses native fetch (Node.js v21+). For older Node versions, install node-fetch.

// Token cache (use Redis/DynamoDB in production for multi-instance deployments)
let cachedToken = null;
let tokenExpiry = 0;

// Endpoint allowlist - only these resources can be requested
// This prevents client-side code from accessing arbitrary API endpoints
const ALLOWED_RESOURCES = {
  'suggestions': '/api/v2/admin/suggestions',
  'forums': '/api/v2/admin/forums',
  'users': '/api/v2/admin/users',
  'comments': '/api/v2/admin/comments'
};

async function getAccessToken() {
  if (cachedToken && Date.now() < tokenExpiry) {
    return cachedToken;
  }
  
  const response = await fetch(
    `https://${process.env.USERVOICE_SUBDOMAIN}.uservoice.com/api/v2/oauth/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: `grant_type=client_credentials&client_id=${process.env.USERVOICE_CLIENT_ID}&client_secret=${process.env.USERVOICE_CLIENT_SECRET}`
    }
  );
  
  const data = await response.json();
  cachedToken = data.access_token;
  tokenExpiry = Date.now() + (data.expires_in * 1000) - 60000; // Refresh 1 min early
  
  return cachedToken;
}

exports.handler = async (event) => {
  // Validate origin
  const origin = event.headers.origin || event.headers.Origin;
  const allowedOrigins = [
    'webflow.io',
    'yourdomain.com'  // Replace with your production domain
  ];
  
  const isAllowedOrigin = origin && allowedOrigins.some(domain => origin.includes(domain));
  if (!isAllowedOrigin) {
    return { statusCode: 403, body: JSON.stringify({ error: 'Forbidden' }) };
  }
  
  try {
    const { resource, method = 'GET', body } = JSON.parse(event.body);
    
    // Validate resource against allowlist
    const endpoint = ALLOWED_RESOURCES[resource];
    if (!endpoint) {
      return {
        statusCode: 400,
        body: JSON.stringify({ error: 'Invalid resource requested' })
      };
    }
    
    const token = await getAccessToken();
    
    // Proxy to UserVoice using validated endpoint
    const uvResponse = await fetch(
      `https://${process.env.USERVOICE_SUBDOMAIN}.uservoice.com${endpoint}`,
      {
        method,
        headers: {
          'Authorization': `Bearer ${token}`,
          'Content-Type': 'application/json'
        },
        body: body ? JSON.stringify(body) : undefined
      }
    );
    
    // Log rate limit status for monitoring
    const remaining = uvResponse.headers.get('X-Rate-Limit-Remaining');
    const limit = uvResponse.headers.get('X-Rate-Limit-Limit');
    if (remaining && parseInt(remaining, 10) < 10) {
      console.warn(`Rate limit warning: ${remaining}/${limit} requests remaining`);
    }
    
    const data = await uvResponse.json();
    
    return {
      statusCode: uvResponse.status,
      headers: {
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    };
    
  } catch (error) {
    console.error('Proxy error:', error.message);
    
    return {
      statusCode: 502,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ 
        error: 'Integration error', 
        message: error.message 
      })
    };
  }
};
Alex Halliday
CEO
AirOps
Learn more
Aleyda Solis
International SEO Consultant and Founder
Orainti
Learn more
Barry Schwartz
President and Owner
RustyBrick, Inc
Learn more
Chris Andrew
CEO and Cofounder
Scrunch
Learn more
Connor Gillivan
CEO and Founder
TrioSEO
Learn more
Eli Schwartz
Author
Product-led SEO
Learn more
Ethan Smith
CEO
Graphite
Learn more
Evan Bailyn
CEO
First Page Sage
Learn more
Gaetano Nino DiNardi
Growth Advisor
Learn more
Jason Barnard
CEO and Founder
Kalicube
Learn more
Kevin Indig
Growth Advisor
Learn more
Lily Ray
VP SEO Strategy & Research
Amsive
Learn more
Marcel Santilli
CEO and Founder
GrowthX
Learn more
Michael King
CEO and Founder
iPullRank
Learn more
Rand Fishkin
CEO and Cofounder
SparkToro, Alertmouse, & Snackbar Studio
Learn more
Stefan Katanic
CEO
Veza Digital
Learn more
Steve Toth
CEO
Notebook Agency
Learn more
Sydney Sloan
CMO
G2
Learn more

Error handling and retry logic

Add exponential backoff for rate limiting and transient failures:

async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const response = await fetch(url, options);
    
    if (response.ok) {
      return response;
    }
    
    // Handle rate limiting (HTTP 429)
    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After');
      const waitTime = retryAfter 
        ? parseInt(retryAfter, 10) * 1000 
        : Math.pow(2, attempt) * 1000;
      
      console.log(`Rate limited. Waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}`);
      await new Promise(resolve => setTimeout(resolve, waitTime));
      continue;
    }
    
    // Don't retry client errors (4xx) other than 429
    if (response.status >= 400 && response.status < 500) {
      throw new Error(`Client error: ${response.status}`);
    }
    
    // Retry server errors (5xx) with exponential backoff
    if (response.status >= 500) {
      const waitTime = Math.pow(2, attempt) * 1000;
      console.log(`Server error ${response.status}. Waiting ${waitTime}ms before retry`);
      await new Promise(resolve => setTimeout(resolve, waitTime));
      continue;
    }
  }
  
  throw new Error(`Failed after ${maxRetries} retries`);
}

Error handling summary:

Scenario Response Action
HTTP 429 (rate limited) Retry after Retry-After header value or use exponential backoff Log and wait
HTTP 4xx (client error) Do not retry Return error to client
HTTP 5xx (server error) Retry with exponential backoff Log and retry up to three times
Network failure Retry with exponential backoff Log and retry
Token expiration Refresh token and retry Handled by getAccessToken() cache logic

Client-side communication

From your Webflow site, call the proxy using abstracted resource names (not raw endpoints):

// Request suggestions through the proxy
fetch('https://your-secure-proxy.com/api/uservoice', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    resource: 'suggestions',  // Abstracted resource name, validated server-side
    method: 'GET'
  })
})
.then(response => response.json())
.then(data => {
  // Handle UserVoice data safely
  console.log(data.suggestions);
});

Security note: The proxy validates the resource parameter against an allowlist before constructing the actual API endpoint. This prevents client-side code from accessing arbitrary UserVoice API endpoints through your proxy.

UserVoice API reference

Common endpoints

The Admin API Reference documents all available endpoints. Add these to your proxy's ALLOWED_RESOURCES as needed:

Resource Endpoint Description
suggestions /api/v2/admin/suggestions List and manage ideas
forums /api/v2/admin/forums Forum configuration
users /api/v2/admin/users User management
comments /api/v2/admin/comments Idea comments
status_updates /api/v2/admin/status_updates Status change history

Response structure

UserVoice API responses include the primary resource, related data via side-loading (using the includes parameter), and pagination metadata:

{
  "suggestions": [{
    "id": 303,
    "title": "Make the logo bigger",
    "body": "Description text",
    "created_at": "2012-07-19T14:23:16Z",
    "state": "published",
    "links": {
      "forum": 2002,
      "created_by": 1001
    }
  }],
  "forums": [{
    "id": 2002,
    "name": "Product Ideas"
  }],
  "pagination": {
    "page": 1,
    "per_page": 20,
    "total_records": 1
  }
}

See Associations & Side-loading and Pagination documentation for details.

Webhook integration

UserVoice webhooks enable real-time updates when feedback events occur.

Available events

Configure webhooks in UserVoice Admin Console under Integrations → Service Hooks:

Event Trigger
New Forum A new forum is created
New Idea A new idea is submitted
New Comment A comment is added to an idea
Idea Status Updates An idea’s status changes
Votes Update Votes are cast on ideas

Webhook security

UserVoice implements HMAC-SHA256 signature verification. Verify incoming webhooks using your SSO key (found in Admin Console: Settings → Web Portal → UserVoice Authentication):

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, ssoKey) {
  const computedSignature = crypto
    .createHmac('sha256', ssoKey)
    .update(payload)
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(computedSignature)
  );
}

exports.webhookHandler = async (event) => {
  const payload = event.body;
  const receivedSignature = event.headers['x-uservoice-signature'];
  
  if (!verifyWebhookSignature(payload, receivedSignature, process.env.USERVOICE_SSO_KEY)) {
    return { statusCode: 401, body: 'Invalid signature' };
  }
  
  // Process webhook safely
  const data = JSON.parse(payload);
  // ... handle event
};

Note: UserVoice does not automatically retry failed webhook deliveries. Implement your own retry handling if needed.

Receiving webhooks

Deploy a serverless function to receive webhooks. You can also use Make or Zapier for no-code automation.

Rate limiting

UserVoice limits

UserVoice enforces API rate limits communicated through HTTP response headers:

  • X-Rate-Limit-Limit: Maximum requests allowed per time window
  • X-Rate-Limit-Remaining: Requests remaining in current window
  • X-Rate-Limit-Reset: Timestamp when rate limit resets

Limits vary by subscription tier. When exceeded, UserVoice responds with HTTP 429 and includes a Retry-After header.

Webflow limits

The Webflow API Rate Limits documentation specifies plan-based throttling:

Plan Limit
Starter, Basic 60 requests per minute
CMS, eCommerce, Business 120 requests per minute
Enterprise Custom negotiated

Site Publish endpoints are further limited to one successful publish per minute.

Syncing feedback to Webflow CMS

Display feedback data in Webflow by syncing to a CMS collection.

UserVoice Field Webflow CMS Field Transformation
title name Direct string mapping
title slug Lowercase + hyphenation
body description Direct string mapping
votes_count votes Number (no conversion)
state status Direct string mapping
id uservoice-id Number to string
created_at created-date ISO 8601 (preserved)

Sync implementation

async function syncUserVoiceToWebflow(suggestions) {
  const items = suggestions.map(s => ({
    fieldData: {
      name: s.title,
      slug: s.title.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''),
      description: s.body,
      votes: s.votes_count,
      status: s.state,
      'uservoice-id': String(s.id),
      'created-date': s.created_at
    }
  }));
  
  // Bulk insert to Webflow (max 100 per request)
  const response = await fetch(
    `https://api.webflow.com/v2/collections/${COLLECTION_ID}/items/bulk`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${WEBFLOW_TOKEN}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ items })
    }
  );
  
  return response.json();
}

Use UserVoice's date range filters (updated_before, updated_after) for efficient incremental synchronization.

SSO implementation

For seamless authentication between your app and UserVoice, implement SSO using JWT tokens.

Server-side token generation

const jwt = require('jsonwebtoken');

function generateUserVoiceToken(user, ssoKey) {
  const payload = {
    guid: user.id,
    email: user.email,
    display_name: user.name,
    expires: Math.floor(Date.now() / 1000) + 3600 // 1 hour
  };
  
  return jwt.sign(payload, ssoKey, { algorithm: 'HS256' });
}

exports.handler = async (event) => {
  const user = JSON.parse(event.body);
  const token = generateUserVoiceToken(user, process.env.USERVOICE_SSO_KEY);
  
  return {
    statusCode: 200,
    body: JSON.stringify({ token })
  };
};

Client-side integration

If using the UserVoice widget alongside API integration:

fetch('https://your-proxy.com/generate-uv-token', {
  method: 'POST',
  body: JSON.stringify({
    id: currentUser.id,
    email: currentUser.email,
    name: currentUser.name
  })
})
.then(res => res.json())
.then(data => {
  UserVoice.push(['identify', {
    sso: data.token
  }]);
});

Verification and testing

  1. Test token acquisition: Use curl or Postman to verify OAuth flow works with your credentials
  2. Test proxy endpoint: Confirm your proxy responds to requests from your Webflow domain with proper CORS headers
  3. Test resource validation: Verify the proxy rejects invalid resource names
  4. Test webhook signatures: Send test webhooks and confirm HMAC verification works
  5. Monitor rate limits: Check X-Rate-Limit-Remaining headers during testing

Troubleshooting

Issue Resolution
Authentication failures UserVoice API key documentation
Rate limit errors UserVoice handling API rate limiting
CORS errors Configure proxy to return appropriate Access-Control-Allow-Origin headers
Token expiration Implement token refresh logic in getAccessToken()
Webhook signature mismatch Verify SSO key matches and ensure the payload is not modified

For additional debugging, consult the UserVoice API Technical Details and Webflow Developer Documentation.

Read now

Last Updated
January 16, 2026
Category

Related articles


Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.