ezApps
  • About
  • Blog
  • Products
  • Support
All Posts

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.

Published: July 9, 2025
shopifyapp-bridgereviewstutorialtypescriptreact

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

  1. Shopify embedded app using App Bridge
  2. 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:

  1. Post-billing: 15-20% response rate
  2. Milestone achievement: 8-12% response rate
  3. Feature success: 5-8% response rate
  4. 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:

  1. Timing is everything: Post-billing moments have the highest satisfaction
  2. Respect the platform: Handle all decline codes gracefully
  3. Track everything: Use analytics to optimize your approach
  4. Test thoroughly: Ensure smooth handling of all scenarios
  5. 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

  • Shopify App Bridge Reviews API Docs
  • App Bridge Setup Guide
  • ezInvoices on Shopify App Store

Have questions about implementing the Reviews API? Reach out to us at support@ezapps.io

Share this article:

Share on XShare on LinkedIn

© 2025 ezApps. All rights reserved.

Privacy PolicyContact

ezApps has no affiliation with Shopify or Sage Accounting and its registered trademarks.