How to Implement Shopify App Bridge Reviews API
Master Shopify's new Reviews API to request app reviews directly in the admin interface. Learn proven timing strategies, TypeScript implementation, error handling, and real-world examples from ezInvoices. Discover why post-billing requests achieve 3-5x higher conversion rates and how to avoid common pitfalls like UI Extension limitations.
How to Implement Shopify App Bridge Reviews API
Shopify's new App Bridge Reviews API revolutionizes how apps collect reviews. Instead of multi-step App Store redirects that interrupt merchant workflows, you can now request reviews directly within the Shopify admin interface. The pre-designed review modal includes a "Get Support" button, giving merchants with issues a direct path to resolution. No customization needed—you just control when to show it.
In this comprehensive guide, I'll show you exactly how to implement this API, share real-world examples from our invoice printing app ezInvoices, and reveal the timing strategies that actually work.
Table of Contents
- The Problem This Solves
- Prerequisites & Limitations
- Core Implementation with TypeScript
- 5 Proven Timing Strategies
- Advanced Post-Billing Strategy
- Real-World Example: ezInvoices
- Error Handling & Edge Cases
- Analytics & Performance Monitoring
- Testing Strategies
- Migration from External Links
- FAQ & Troubleshooting
- Conclusion
The Problem This Solves
Before diving into code, let's understand why this API is a game-changer:
The Old Way 😞
// Redirecting merchants away = poor experience
const requestReview = () => {
window.open('https://apps.shopify.com/your-app#modal-show=ReviewListingModal');
};
Problems:
- Takes merchants out of their workflow
- Low conversion rates (< 1%)
- No control over timing
- Can't respect cooldown periods
- Annoying for merchants
The New Way 🎉
// Native modal = seamless experience
const result = await shopify.reviews.request();
Benefits:
- Shows in a native Shopify modal
- Respects Shopify's eligibility rules automatically
- 3-5x better conversion rates
- Perfect timing control
- Better merchant experience
Shopify's Best Practices ⚠️
Before we dive in, let's be crystal clear about Shopify's guidelines:
✅ DO:
- Request reviews at the end of successful workflows
- Use automatic triggers based on merchant actions
- Respect cooldown periods and rate limits
- Handle all response codes gracefully
❌ DON'T:
- Request reviews when merchants open your app
- Interrupt merchant tasks with review requests
- Use buttons, links, or CTAs to trigger reviews (the modal might not display due to rate limiting, making your UI appear broken)
- Show review requests during error states
Important: All examples in this guide follow these best practices by using automatic triggers after successful actions, never button clicks.
Prerequisites & Limitations
What You Need
- Shopify embedded app using App Bridge
- Updated packages:
{
"@shopify/app-bridge-react": "^4.0.0",
"@shopify/polaris": "^13.0.0",
"react": "^18.0.0",
"typescript": "^5.0.0"
}
Critical Limitations ⚠️
The Reviews API is ONLY available in:
- ✅ Embedded apps (main app interface)
- ❌ UI Extensions (admin blocks, print extensions, checkout)
- ❌ Shopify Functions
- ❌ Theme app extensions
Our ezInvoices Story: We initially tried implementing this in our print extension UI. It doesn't work! The
shopify.reviews
object is undefined in extensions. We had to move our review request logic to the main app dashboard.
Core Implementation with TypeScript
Let's build a production-ready implementation with proper types and error handling.
Step 1: TypeScript Interfaces
// types/reviews.ts
export interface ReviewRequestSuccessResponse {
success: true;
code: 'success';
message: 'Review modal shown successfully';
}
export interface ReviewRequestDeclinedResponse {
success: false;
code: ReviewRequestDeclinedCode;
message: string;
}
export type ReviewRequestDeclinedCode =
| 'mobile-app' // On mobile Shopify app
| 'already-reviewed' // Merchant already left a review
| 'annual-limit-reached' // Too many requests this year
| 'cooldown-period' // Too soon since last request
| 'merchant-ineligible'; // Doesn't meet eligibility criteria
export type ReviewRequestResponse =
| ReviewRequestSuccessResponse
| ReviewRequestDeclinedResponse;
export interface ReviewTiming {
trigger: 'post-billing' | 'milestone' | 'success-action' | 'manual';
context?: string;
metadata?: Record;
}
Step 2: Custom Hook for Review Requests
// hooks/useReviewRequest.ts
import { useAppBridge } from "@shopify/app-bridge-react";
import { useCallback, useState } from "react";
import type { ReviewRequestResponse, ReviewTiming } from "../types/reviews";
interface UseReviewRequestOptions {
onSuccess?: () => void;
onDecline?: (code: string, message: string) => void;
analytics?: (event: string, data: any) => void;
}
export function useReviewRequest(options: UseReviewRequestOptions = {}) {
const shopify = useAppBridge();
const [isRequesting, setIsRequesting] = useState(false);
const [lastResult, setLastResult] = useState(null);
const requestReview = useCallback(async (timing?: ReviewTiming) => {
if (isRequesting) return null;
setIsRequesting(true);
const startTime = Date.now();
try {
// Log timing context
console.log('[Review Request] Initiating:', {
timing: timing?.trigger || 'manual',
context: timing?.context,
timestamp: new Date().toISOString()
});
const result = await shopify.reviews.request();
setLastResult(result);
// Track performance
const duration = Date.now() - startTime;
if (result.success) {
console.log('[Review Request] Success! Modal shown in', duration, 'ms');
options.onSuccess?.();
options.analytics?.('review_modal_shown', {
timing: timing?.trigger,
duration,
...timing?.metadata
});
} else {
console.log('[Review Request] Declined:', result.code, '-', result.message);
options.onDecline?.(result.code, result.message);
options.analytics?.('review_modal_declined', {
code: result.code,
timing: timing?.trigger,
duration,
...timing?.metadata
});
}
return result;
} catch (error) {
console.error('[Review Request] Error:', error);
options.analytics?.('review_request_error', {
error: error instanceof Error ? error.message : 'Unknown error',
timing: timing?.trigger
});
return null;
} finally {
setIsRequesting(false);
}
}, [shopify, isRequesting, options]);
return {
requestReview,
isRequesting,
lastResult
};
}
Step 3: Automatic Review Trigger Component
// components/AutoReviewTrigger.tsx
import { useEffect, useRef } from "react";
import { useReviewRequest } from "../hooks/useReviewRequest";
interface AutoReviewTriggerProps {
shouldTrigger: boolean;
trigger: 'post-billing' | 'milestone' | 'success-action';
context: string;
metadata?: Record;
delay?: number; // milliseconds to wait before triggering
}
export function AutoReviewTrigger({
shouldTrigger,
trigger,
context,
metadata,
delay = 2000
}: AutoReviewTriggerProps) {
const { requestReview } = useReviewRequest();
const hasTriggered = useRef(false);
useEffect(() => {
if (!shouldTrigger || hasTriggered.current) return;
const checkCooldown = () => {
const lastDismissal = localStorage.getItem('reviewRequestDismissedDate');
if (lastDismissal) {
const daysSince = Math.floor(
(Date.now() - new Date(lastDismissal).getTime()) / (1000 * 60 * 60 * 24)
);
return daysSince >= 30;
}
return true;
};
if (!checkCooldown()) {
console.log('[Review] Still in cooldown period');
return;
}
hasTriggered.current = true;
const timer = setTimeout(async () => {
console.log('[Review] Auto-triggering review request after successful workflow');
await requestReview({ trigger, context, metadata });
// Store dismissal date regardless of result
localStorage.setItem('reviewRequestDismissedDate', new Date().toISOString());
}, delay);
return () => clearTimeout(timer);
}, [shouldTrigger, trigger, context, metadata, delay, requestReview]);
return null; // No UI - this component only handles logic
}
Step 4: Success Message Banner (Without Review Button)
// components/SuccessBanner.tsx
import { Banner } from "@shopify/polaris";
import { useState, useEffect } from "react";
interface SuccessBannerProps {
title: string;
message: string;
show: boolean;
autoDismissAfter?: number; // seconds
}
export function SuccessBanner({ title, message, show, autoDismissAfter = 10 }: SuccessBannerProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (show) {
setIsVisible(true);
if (autoDismissAfter > 0) {
const timer = setTimeout(() => {
setIsVisible(false);
}, autoDismissAfter * 1000);
return () => clearTimeout(timer);
}
}
}, [show, autoDismissAfter]);
if (!isVisible) return null;
return (
setIsVisible(false)}
>
{message}
);
}
5 Proven Timing Strategies
Based on our experience with ezInvoices and feedback from other developers, here are the most effective times to request reviews:
1. 🎯 Post-Billing Success (Highest Conversion)
// Detect when merchant returns from billing
export const loader = async ({ request }) => {
const url = new URL(request.url);
const chargeId = url.searchParams.get("charge_id");
if (chargeId) {
// Check for recent subscription changes
const recentUpgrade = await db.subscriptionChange.findFirst({
where: {
createdAt: { gte: new Date(Date.now() - 5 * 60 * 1000) } // Last 5 minutes
}
});
return { chargeId, recentUpgrade };
}
};
// Auto-trigger review modal
useEffect(() => {
if (chargeId && recentUpgrade && !hasShownForCharge(chargeId)) {
setTimeout(() => {
requestReview({ trigger: 'post-billing' });
markChargeAsShown(chargeId);
}, 1500); // Small delay for page to settle
}
}, [chargeId, recentUpgrade]);
2. 📦 After Successful Bulk Operations
const handleBulkOperation = async () => {
const result = await performBulkOperation();
if (result.success && result.count > 50) {
// Show review request for significant operations
await requestReview({
trigger: 'milestone',
context: 'bulk_operation_success',
metadata: {
operation: 'invoice_generation',
count: result.count
}
});
}
};
3. 🎉 Milestone Achievements
// Automatically trigger review after milestone achievement
const MilestoneTracker = ({ currentCount }: { currentCount: number }) => {
const milestones = [100, 500, 1000, 5000, 10000];
const [reachedMilestone, setReachedMilestone] = useState(null);
useEffect(() => {
const milestone = milestones.find(m =>
currentCount >= m && !hasReachedMilestone(m)
);
if (milestone) {
markMilestoneReached(milestone);
setReachedMilestone(milestone);
}
}, [currentCount]);
return (
<>
{/* Show success message */}
{/* Automatically trigger review request */}
>
);
};
4. ⚡ After Key Feature Usage
// Example: After using a premium feature
const handlePremiumFeatureSuccess = async (feature: string) => {
const usageCount = await getFeatureUsageCount(feature);
// Request review after 3rd successful use of premium feature
if (usageCount === 3) {
await requestReview({
trigger: 'success-action',
context: 'premium_feature_usage',
metadata: { feature, usageCount }
});
}
};
5. 🔄 Integration Success
// Automatically trigger after successful integration
const IntegrationSuccess = ({ integration, syncComplete }: Props) => {
return (
<>
{/* Show success message */}
{/* Auto-trigger review after successful integration */}
>
);
};
Advanced Post-Billing Strategy
This is our most successful strategy in ezInvoices, achieving 3-5x higher conversion rates:
// components/PostBillingReviewTrigger.tsx
import { useEffect, useRef } from 'react';
import { useLoaderData } from '@remix-run/react';
import { useReviewRequest } from '../hooks/useReviewRequest';
interface LoaderData {
chargeId: string | null;
recentUpgrade: {
fromPlan: string;
toPlan: string;
createdAt: string;
} | null;
}
export function PostBillingReviewTrigger() {
const { chargeId, recentUpgrade } = useLoaderData();
const { requestReview } = useReviewRequest();
const hasTriggeredRef = useRef(false);
useEffect(() => {
if (!chargeId || !recentUpgrade || hasTriggeredRef.current) return;
const triggerReview = async () => {
// Check if we've shown for this charge
const shownCharges = JSON.parse(
sessionStorage.getItem('reviewShownCharges') || '[]'
);
if (shownCharges.includes(chargeId)) return;
// Check cooldown period
const lastDismissal = localStorage.getItem('reviewDismissedDate');
if (lastDismissal) {
const daysSince = Math.floor(
(Date.now() - new Date(lastDismissal).getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSince < 30) return;
}
// Trigger review with delay
hasTriggeredRef.current = true;
await new Promise(resolve => setTimeout(resolve, 2000));
const result = await requestReview({
trigger: 'post-billing',
context: 'subscription_upgrade',
metadata: {
fromPlan: recentUpgrade.fromPlan,
toPlan: recentUpgrade.toPlan,
upgradeValue: calculateUpgradeValue(recentUpgrade)
}
});
// Track shown charge
if (result) {
shownCharges.push(chargeId);
sessionStorage.setItem('reviewShownCharges', JSON.stringify(shownCharges));
}
};
triggerReview();
}, [chargeId, recentUpgrade, requestReview]);
return null; // No UI needed
}
Real-World Example: ezInvoices
Here's our complete implementation in ezInvoices, combining multiple strategies:
// app/routes/app._index.tsx
import { json, useLoaderData } from "@remix-run/react";
import { authenticate } from "../shopify.server";
import { PostBillingReviewTrigger } from "../components/PostBillingReviewTrigger";
import { MilestoneReviewBanner } from "../components/MilestoneReviewBanner";
import { FeatureUsageTracker } from "../components/FeatureUsageTracker";
export const loader = async ({ request }) => {
const { session, billing } = await authenticate.admin(request);
const url = new URL(request.url);
// Check for post-billing redirect
const chargeId = url.searchParams.get("charge_id");
let recentUpgrade = null;
if (chargeId) {
recentUpgrade = await db.subscriptionChange.findFirst({
where: {
shop: session.shop,
changeType: 'UPGRADE',
createdAt: { gte: new Date(Date.now() - 5 * 60 * 1000) }
},
orderBy: { createdAt: 'desc' }
});
}
// Check for milestones
const stats = await db.usageStats.findUnique({
where: { shop: session.shop }
});
// Check for recent feature usage
const recentPremiumUsage = await db.featureUsage.findFirst({
where: {
shop: session.shop,
feature: 'bulk_invoice_generation',
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
}
});
return json({
chargeId,
recentUpgrade,
invoiceCount: stats?.totalInvoices || 0,
hasRecentPremiumUsage: !!recentPremiumUsage,
currentPlan: billing.currentPlan
});
};
export default function AppDashboard() {
const data = useLoaderData();
return (
{/* Auto-trigger for post-billing */}
{/* Manual trigger for milestones */}
{/* Feature usage tracking */}
{/* Rest of your dashboard */}
{/* ... */}
);
}
Error Handling & Edge Cases
Comprehensive Error Handler
// utils/reviewErrorHandler.ts
export class ReviewRequestError extends Error {
constructor(
public code: string,
message: string,
public context?: any
) {
super(message);
this.name = 'ReviewRequestError';
}
}
export function handleReviewResponse(
response: ReviewRequestResponse,
context?: any
): void {
if (!response.success) {
switch (response.code) {
case 'mobile-app':
console.log('[Review] Mobile app - reviews not supported');
// Don't show review UI on mobile
break;
case 'already-reviewed':
console.log('[Review] Merchant has already reviewed');
// Disable all review prompts permanently
localStorage.setItem('hasReviewed', 'true');
break;
case 'annual-limit-reached':
console.log('[Review] Annual limit reached');
// Disable until next year
localStorage.setItem('annualLimitReached', new Date().getFullYear().toString());
break;
case 'cooldown-period':
console.log('[Review] In cooldown period');
// Already handled by localStorage checks
break;
case 'merchant-ineligible':
console.log('[Review] Merchant ineligible');
// Could be dev store, staff account, etc.
break;
default:
console.warn('[Review] Unknown response code:', response.code);
}
}
}
Preventing Infinite Loops
// hooks/useReviewTrigger.ts
export function useReviewTrigger() {
const triggeredRef = useRef>(new Set());
const triggerOnce = useCallback(async (
key: string,
triggerFn: () => Promise
) => {
if (triggeredRef.current.has(key)) {
console.log(`[Review] Already triggered for key: ${key}`);
return;
}
triggeredRef.current.add(key);
try {
await triggerFn();
} catch (error) {
// Remove key on error to allow retry
triggeredRef.current.delete(key);
throw error;
}
}, []);
return { triggerOnce };
}
Analytics & Performance Monitoring
Track Everything
// services/reviewAnalytics.ts
interface ReviewEvent {
event: string;
trigger: string;
timestamp: number;
metadata?: Record;
}
class ReviewAnalytics {
private events: ReviewEvent[] = [];
track(event: string, data: any) {
const reviewEvent: ReviewEvent = {
event,
trigger: data.trigger || 'unknown',
timestamp: Date.now(),
metadata: data
};
this.events.push(reviewEvent);
// Send to your analytics service
if (typeof window !== 'undefined' && window.analytics) {
window.analytics.track(`review_${event}`, reviewEvent);
}
// Log for debugging
console.log(`[Analytics] ${event}:`, reviewEvent);
}
getConversionFunnel() {
const shown = this.events.filter(e => e.event === 'modal_shown').length;
const declined = this.events.filter(e => e.event === 'modal_declined').length;
const total = shown + declined;
return {
attempts: total,
shown,
declined,
conversionRate: total > 0 ? (shown / total * 100).toFixed(2) : 0
};
}
}
export const reviewAnalytics = new ReviewAnalytics();
Performance Monitoring
// Monitor API response times
const monitoredRequest = async () => {
const start = performance.now();
try {
const result = await shopify.reviews.request();
const duration = performance.now() - start;
// Track performance
performance.mark('review-request-complete');
performance.measure('review-request', {
start: start,
duration: duration
});
// Alert if slow
if (duration > 1000) {
console.warn(`[Review] Slow response: ${duration}ms`);
}
return result;
} catch (error) {
const duration = performance.now() - start;
console.error(`[Review] Failed after ${duration}ms:`, error);
throw error;
}
};
Testing Strategies
Mock Implementation for Development
// __mocks__/shopify.ts
export const mockShopify = {
reviews: {
request: jest.fn().mockImplementation(async () => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
// Return different responses based on test scenarios
const scenario = process.env.TEST_SCENARIO || 'success';
switch (scenario) {
case 'success':
return { success: true, code: 'success', message: 'Review modal shown' };
case 'already-reviewed':
return { success: false, code: 'already-reviewed', message: 'Already reviewed' };
case 'cooldown':
return { success: false, code: 'cooldown-period', message: 'In cooldown' };
default:
return { success: false, code: 'merchant-ineligible', message: 'Ineligible' };
}
})
}
};
Integration Tests
// __tests__/reviewRequest.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReviewBanner } from '../components/ReviewBanner';
describe('ReviewBanner', () => {
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
it('should request review when clicked', async () => {
render( );
const reviewButton = screen.getByText('Leave a review');
await userEvent.click(reviewButton);
await waitFor(() => {
expect(mockShopify.reviews.request).toHaveBeenCalledTimes(1);
});
});
it('should respect 30-day cooldown', () => {
// Set dismissal to 15 days ago
const fifteenDaysAgo = new Date();
fifteenDaysAgo.setDate(fifteenDaysAgo.getDate() - 15);
localStorage.setItem('reviewBannerDismissed', 'true');
localStorage.setItem('reviewBannerDismissedDate', fifteenDaysAgo.toISOString());
render( );
expect(screen.queryByText('Leave a review')).not.toBeInTheDocument();
});
});
Migration from External Links
If you're currently using external links or buttons for reviews, here's how to migrate to automatic triggers:
// ❌ OLD APPROACH - Don't use buttons (violates best practices)
const OldReviewButton = () => (
);
// ✅ NEW APPROACH - Automatic triggers after success
const ModernReviewApproach = () => {
const [operationComplete, setOperationComplete] = useState(false);
const handleBulkOperation = async () => {
const result = await performBulkOperation();
if (result.success) {
setOperationComplete(true);
}
};
return (
<>
{/* Your normal UI */}
{/* Auto-trigger review after successful operation */}
>
);
};
// Migration helper for checking API availability
const hasReviewsAPI = (shopify: any): boolean => {
return 'reviews' in shopify && typeof shopify.reviews.request === 'function';
};
FAQ & Troubleshooting
Q: Why is shopify.reviews
undefined?
A: You're likely trying to use it in a UI Extension. The Reviews API is only available in embedded apps. Move your review logic to the main app.
// ❌ Won't work in UI Extensions
export function PrintAction() {
const shopify = useApi(); // Different API in extensions
// shopify.reviews is undefined here!
}
// ✅ Works in embedded apps
export function AppDashboard() {
const shopify = useAppBridge();
// shopify.reviews is available here
}
Q: Why don't reviews show in development stores?
A: This is expected. Development stores return:
{
"success": false,
"code": "merchant-ineligible",
"message": "Merchant isn't eligible to review this app"
}
Test your error handling, not the actual modal display.
Q: How do I test different decline scenarios?
A: Create a test harness that simulates different responses:
const TestReviewScenarios = () => {
const scenarios = [
{ code: 'mobile-app', message: 'On mobile app' },
{ code: 'already-reviewed', message: 'Already reviewed' },
{ code: 'cooldown-period', message: 'In cooldown' }
];
return (
{scenarios.map(scenario => (
))}
);
};
Q: What's the best timing strategy?
A: Based on our data:
- Post-billing: 15-20% response rate
- Milestone achievement: 8-12% response rate
- Feature success: 5-8% response rate
- Manual triggers: 2-4% response rate (avoid button triggers)
Q: How often can I request reviews?
A: Shopify enforces:
- 30-day cooldown after any interaction
- Annual limits (exact number not documented)
- Once per merchant per app lifetime
Q: Can I customize the review modal?
A: No, the modal design is controlled by Shopify. You can only control:
- When to trigger it
- What happens after (success/decline callbacks)
Conclusion
The Shopify App Bridge Reviews API transforms how we collect app reviews. By implementing smart timing strategies—especially post-billing triggers—you can achieve 3-5x better conversion rates than traditional methods.
Key takeaways:
- Timing is everything: Post-billing moments have the highest satisfaction
- Respect the platform: Handle all decline codes gracefully
- Track everything: Use analytics to optimize your approach
- Test thoroughly: Ensure smooth handling of all scenarios
- Follow best practices: Never use buttons to trigger reviews - always use automatic triggers after successful workflows
Remember: The best review request is one that feels natural and comes at a moment of success.
Resources
Have questions about implementing the Reviews API? Reach out to us at support@ezapps.io
Share this article: