Tarot Card Database API: Complete Guide for Spiritual App Developers
Build spiritual apps with a comprehensive tarot card database API. Learn filtering by arcana and suit, displaying upright and reversed meanings, image optimization, and building card browsers with complete code examples.
Tarot Card Database API: Complete Guide for Spiritual App Developers
Building a spiritual app with tarot features? You need access to a complete, professionally curated card database with detailed meanings, keywords, high-quality images, and robust filtering capabilities. Managing your own database means scraping unreliable sources, handling inconsistent interpretations, and maintaining 78+ card entries with upright and reversed meanings.
The RoxyAPI Tarot API provides a production-ready card database: all 78 Rider-Waite-Smith cards with professionally written interpretations, keyword arrays for quick reference, CDN-hosted images, and flexible filtering by arcana, suit, and card number. This guide shows you how to build card browsers, search features, and learning tools using the card database endpoints.
What You Will Build
By the end of this tutorial, you will have:
- Card Browser - Paginated grid displaying all 78 cards with filtering
- Card Detail View - Full interpretations, keywords, upright/reversed meanings
- Search & Filter - Filter by major/minor arcana, suit (cups/wands/swords/pentacles), card number
- Learning Mode - Flashcard-style interface for memorizing meanings
- Image Gallery - Optimized card image loading with caching
Prerequisites
Development Environment:
- Web: React/Vue/Svelte OR Mobile: React Native/Flutter/SwiftUI
- RoxyAPI Tarot API Key (get one here)
- Image caching library (optional but recommended)
API Base URL:
https://roxyapi.com/api/v2/tarot
Authentication:
All requests require X-API-Key header.
API Overview: Card Database Endpoints
Endpoint 1: List All Cards
Get the complete 78-card deck with optional filtering.
Endpoint:
GET /tarot/cards
Query Parameters:
arcana(optional): Filter bymajor(22 cards) orminor(56 cards)suit(optional): Filter minor arcana bycups,wands,swords,pentacles(14 cards each)number(optional): Filter by card number (1-14 for minor, where Ace=1, Page=11, Knight=12, Queen=13, King=14)
Response Format:
{
"total": 22,
"cards": [
{
"id": "fool",
"name": "The Fool",
"arcana": "major",
"number": 0,
"imageUrl": "https://roxyapi.com/img/tarot/major/fool.jpg"
},
{
"id": "magician",
"name": "The Magician",
"arcana": "major",
"number": 1,
"imageUrl": "https://roxyapi.com/img/tarot/major/magician.jpg"
}
]
}
Example Requests:
# All cards (78 total)
curl -H "X-API-Key: YOUR_KEY" \
https://roxyapi.com/api/v2/tarot/cards
# Major arcana only (22 cards)
curl -H "X-API-Key: YOUR_KEY" \
https://roxyapi.com/api/v2/tarot/cards?arcana=major
# All cups cards (14 cards)
curl -H "X-API-Key: YOUR_KEY" \
https://roxyapi.com/api/v2/tarot/cards?suit=cups
# All Aces (4 cards: Ace of Cups, Wands, Swords, Pentacles)
curl -H "X-API-Key: YOUR_KEY" \
https://roxyapi.com/api/v2/tarot/cards?number=1
# Specific card: Ace of Wands only
curl -H "X-API-Key: YOUR_KEY" \
https://roxyapi.com/api/v2/tarot/cards?suit=wands&number=1
Endpoint 2: Get Single Card Details
Retrieve complete information for a specific card including full upright/reversed interpretations.
Endpoint:
GET /tarot/cards/{id}
Path Parameter:
id: Card identifier in kebab-case (e.g.,fool,ace-of-cups,queen-of-swords,king-of-pentacles)
Response Format:
{
"id": "lovers",
"name": "The Lovers",
"arcana": "major",
"number": 6,
"keywords": {
"upright": ["Love", "harmony", "relationships", "values alignment", "choices"],
"reversed": ["Self-love", "disharmony", "imbalance", "misalignment of values"]
},
"upright": {
"keywords": ["Love", "harmony", "relationships", "values alignment", "choices"],
"description": "In its purest form, The Lovers card represents conscious connections and meaningful relationships..."
},
"reversed": {
"keywords": ["Self-love", "disharmony", "imbalance", "misalignment of values"],
"description": "The Lovers card is pure love and harmony. Reversed, it can signal a time when you are out of sync..."
},
"imageUrl": "https://roxyapi.com/img/tarot/major/lovers.jpg"
}
Key Fields:
keywords.upright/keywords.reversed: Quick reference arrays for app UIupright.description/reversed.description: Full interpretations (500-1000 words each)imageUrl: CDN-hosted high-quality card image (optimized for web/mobile)
Implementation: Card Browser (React)
Step 1: API Service Layer
// services/tarotAPI.js
const TAROT_API_KEY = process.env.REACT_APP_TAROT_API_KEY;
const BASE_URL = 'https://roxyapi.com/api/v2/tarot';
export async function fetchCards(filters = {}) {
const params = new URLSearchParams();
if (filters.arcana) params.append('arcana', filters.arcana);
if (filters.suit) params.append('suit', filters.suit);
if (filters.number) params.append('number', filters.number);
const url = `${BASE_URL}/cards${params.toString() ? `?${params}` : ''}`;
const response = await fetch(url, {
headers: { 'X-API-Key': TAROT_API_KEY }
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
export async function fetchCardDetail(cardId) {
const response = await fetch(`${BASE_URL}/cards/${cardId}`, {
headers: { 'X-API-Key': TAROT_API_KEY }
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
Step 2: Card Browser Component
// components/CardBrowser.jsx
import React, { useState, useEffect } from 'react';
import { fetchCards } from '../services/tarotAPI';
import './CardBrowser.css';
export default function CardBrowser() {
const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
arcana: '',
suit: '',
number: ''
});
useEffect(() => {
loadCards();
}, [filters]);
async function loadCards() {
setLoading(true);
try {
const cleanFilters = Object.fromEntries(
Object.entries(filters).filter(([_, value]) => value !== '')
);
const data = await fetchCards(cleanFilters);
setCards(data.cards);
} catch (error) {
console.error('Failed to load cards:', error);
} finally {
setLoading(false);
}
}
function handleFilterChange(key, value) {
setFilters(prev => ({
...prev,
[key]: value,
// Clear suit filter when switching to major arcana
...(key === 'arcana' && value === 'major' && { suit: '' })
}));
}
return (
<div className="card-browser">
{/* Filter Bar */}
<div className="filter-bar">
<div className="filter-group">
<label>Arcana</label>
<select
value={filters.arcana}
onChange={(e) => handleFilterChange('arcana', e.target.value)}
>
<option value="">All Cards (78)</option>
<option value="major">Major Arcana (22)</option>
<option value="minor">Minor Arcana (56)</option>
</select>
</div>
{filters.arcana !== 'major' && (
<div className="filter-group">
<label>Suit</label>
<select
value={filters.suit}
onChange={(e) => handleFilterChange('suit', e.target.value)}
disabled={filters.arcana === 'major'}
>
<option value="">All Suits</option>
<option value="cups">Cups (Emotions)</option>
<option value="wands">Wands (Passion)</option>
<option value="swords">Swords (Intellect)</option>
<option value="pentacles">Pentacles (Material)</option>
</select>
</div>
)}
{filters.arcana === 'minor' && (
<div className="filter-group">
<label>Card Number</label>
<select
value={filters.number}
onChange={(e) => handleFilterChange('number', e.target.value)}
>
<option value="">All Numbers</option>
<option value="1">Ace</option>
{[2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => (
<option key={n} value={n}>{n}</option>
))}
<option value="11">Page</option>
<option value="12">Knight</option>
<option value="13">Queen</option>
<option value="14">King</option>
</select>
</div>
)}
<button onClick={() => setFilters({ arcana: '', suit: '', number: '' })}>
Clear Filters
</button>
</div>
{/* Card Count */}
<div className="card-count">
{loading ? 'Loading...' : `${cards.length} cards`}
</div>
{/* Card Grid */}
<div className="card-grid">
{loading ? (
<div className="loading-spinner">Loading cards...</div>
) : (
cards.map(card => (
<CardTile key={card.id} card={card} />
))
)}
</div>
</div>
);
}
function CardTile({ card }) {
return (
<a href={`/cards/${card.id}`} className="card-tile">
<div className="card-image-container">
<img
src={card.imageUrl}
alt={card.name}
loading="lazy"
/>
</div>
<div className="card-info">
<h3>{card.name}</h3>
<p className="card-meta">
{card.arcana === 'major' ? 'Major Arcana' : card.suit}
{' • '}
{card.number !== undefined && `#${card.number}`}
</p>
</div>
</a>
);
}
Step 3: Styling
/* CardBrowser.css */
.card-browser {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.filter-bar {
display: flex;
gap: 1rem;
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-group label {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
.filter-group select {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
background: white;
cursor: pointer;
}
.filter-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.filter-bar button {
padding: 0.5rem 1.5rem;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
align-self: flex-end;
font-weight: 500;
}
.card-count {
margin-bottom: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.card-tile {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: inherit;
}
.card-tile:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.card-image-container {
aspect-ratio: 2/3;
overflow: hidden;
background: #f3f4f6;
}
.card-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-info {
padding: 1rem;
}
.card-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
}
.card-meta {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
text-transform: capitalize;
}
.loading-spinner {
grid-column: 1 / -1;
text-align: center;
padding: 4rem;
color: #6b7280;
}
/* Responsive */
@media (max-width: 768px) {
.card-browser {
padding: 1rem;
}
.filter-bar {
flex-direction: column;
}
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
}
Implementation: Card Detail View (React Native)
import React, { useState, useEffect } from 'react';
import {
View,
Text,
Image,
ScrollView,
StyleSheet,
ActivityIndicator,
TouchableOpacity,
Dimensions
} from 'react-native';
import { fetchCardDetail } from '../services/tarotAPI';
export default function CardDetailScreen({ route }) {
const { cardId } = route.params;
const [card, setCard] = useState(null);
const [loading, setLoading] = useState(true);
const [showReversed, setShowReversed] = useState(false);
useEffect(() => {
loadCard();
}, [cardId]);
async function loadCard() {
try {
const data = await fetchCardDetail(cardId);
setCard(data);
} catch (error) {
console.error('Failed to load card:', error);
} finally {
setLoading(false);
}
}
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#6366f1" />
</View>
);
}
if (!card) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>Card not found</Text>
</View>
);
}
const orientation = showReversed ? card.reversed : card.upright;
return (
<ScrollView style={styles.container}>
{/* Card Image */}
<View style={styles.imageContainer}>
<Image
source={{ uri: card.imageUrl }}
style={[
styles.cardImage,
showReversed && styles.cardImageReversed
]}
resizeMode="contain"
/>
</View>
{/* Card Header */}
<View style={styles.header}>
<Text style={styles.cardName}>{card.name}</Text>
<Text style={styles.cardMeta}>
{card.arcana === 'major'
? `Major Arcana • Card ${card.number}`
: `${card.suit.charAt(0).toUpperCase() + card.suit.slice(1)} • ${getCardNumberName(card.number)}`
}
</Text>
</View>
{/* Orientation Toggle */}
<View style={styles.orientationToggle}>
<TouchableOpacity
style={[
styles.toggleButton,
!showReversed && styles.toggleButtonActive
]}
onPress={() => setShowReversed(false)}
>
<Text style={[
styles.toggleText,
!showReversed && styles.toggleTextActive
]}>
Upright
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.toggleButton,
showReversed && styles.toggleButtonActive
]}
onPress={() => setShowReversed(true)}
>
<Text style={[
styles.toggleText,
showReversed && styles.toggleTextActive
]}>
Reversed
</Text>
</TouchableOpacity>
</View>
{/* Keywords */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Keywords</Text>
<View style={styles.keywords}>
{orientation.keywords.map((keyword, index) => (
<View key={index} style={styles.keywordChip}>
<Text style={styles.keywordText}>{keyword}</Text>
</View>
))}
</View>
</View>
{/* Description */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Meaning</Text>
<Text style={styles.description}>
{orientation.description}
</Text>
</View>
</ScrollView>
);
}
function getCardNumberName(number) {
const names = {
1: 'Ace',
11: 'Page',
12: 'Knight',
13: 'Queen',
14: 'King'
};
return names[number] || number.toString();
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
imageContainer: {
backgroundColor: 'white',
padding: 24,
alignItems: 'center',
},
cardImage: {
width: Dimensions.get('window').width * 0.6,
height: Dimensions.get('window').width * 0.9,
borderRadius: 12,
},
cardImageReversed: {
transform: [{ rotate: '180deg' }],
},
header: {
padding: 24,
backgroundColor: 'white',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
cardName: {
fontSize: 28,
fontWeight: 'bold',
color: '#1f2937',
marginBottom: 8,
},
cardMeta: {
fontSize: 16,
color: '#6b7280',
textTransform: 'capitalize',
},
orientationToggle: {
flexDirection: 'row',
padding: 16,
gap: 12,
},
toggleButton: {
flex: 1,
paddingVertical: 12,
backgroundColor: '#f3f4f6',
borderRadius: 8,
alignItems: 'center',
},
toggleButtonActive: {
backgroundColor: '#6366f1',
},
toggleText: {
fontSize: 16,
fontWeight: '600',
color: '#6b7280',
},
toggleTextActive: {
color: 'white',
},
section: {
padding: 24,
backgroundColor: 'white',
marginTop: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1f2937',
marginBottom: 16,
},
keywords: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
keywordChip: {
backgroundColor: '#eef2ff',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
},
keywordText: {
fontSize: 14,
color: '#6366f1',
fontWeight: '500',
},
description: {
fontSize: 16,
lineHeight: 24,
color: '#374151',
},
errorText: {
fontSize: 16,
color: '#ef4444',
},
});
Implementation: Flashcard Learning Mode (SwiftUI)
import SwiftUI
struct FlashcardView: View {
@State private var cards: [TarotCard] = []
@State private var currentIndex = 0
@State private var showAnswer = false
@State private var offset: CGSize = .zero
var body: some View {
VStack(spacing: 32) {
// Progress
HStack {
Text("Card \(currentIndex + 1) of \(cards.count)")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text("\(getCorrectCount()) correct")
.font(.subheadline)
.foregroundColor(.green)
}
.padding(.horizontal)
// Flashcard
if !cards.isEmpty {
GeometryReader { geometry in
ZStack {
ForEach(cards.indices.reversed(), id: \.self) { index in
if index == currentIndex {
FlashcardContent(
card: cards[index],
showAnswer: showAnswer
)
.frame(width: geometry.size.width - 48, height: 500)
.offset(offset)
.rotationEffect(.degrees(Double(offset.width) / 20))
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded { gesture in
handleSwipe(gesture)
}
)
}
}
}
}
.frame(height: 500)
}
// Controls
VStack(spacing: 16) {
Button {
withAnimation {
showAnswer.toggle()
}
} label: {
Text(showAnswer ? "Hide Meaning" : "Show Meaning")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.purple.opacity(0.1))
.foregroundColor(.purple)
.cornerRadius(12)
}
if showAnswer {
HStack(spacing: 16) {
Button {
markIncorrect()
} label: {
Label("Retry", systemImage: "xmark")
.frame(maxWidth: .infinity)
.padding()
.background(Color.red.opacity(0.1))
.foregroundColor(.red)
.cornerRadius(12)
}
Button {
markCorrect()
} label: {
Label("Got It", systemImage: "checkmark")
.frame(maxWidth: .infinity)
.padding()
.background(Color.green.opacity(0.1))
.foregroundColor(.green)
.cornerRadius(12)
}
}
}
}
.padding()
}
.task {
await loadCards()
}
}
func handleSwipe(_ gesture: DragGesture.Value) {
if abs(gesture.translation.width) > 100 {
if gesture.translation.width > 0 {
markCorrect()
} else {
markIncorrect()
}
} else {
withAnimation {
offset = .zero
}
}
}
func markCorrect() {
withAnimation {
currentIndex = (currentIndex + 1) % cards.count
showAnswer = false
offset = .zero
}
}
func markIncorrect() {
withAnimation {
offset = .zero
showAnswer = false
}
}
func loadCards() async {
// Load cards from API
let service = TarotAPIService(apiKey: Config.apiKey)
do {
let response = try await service.fetchCards()
cards = response.cards.shuffled()
} catch {
print("Failed to load cards:", error)
}
}
func getCorrectCount() -> Int {
// Track correct answers
return 0
}
}
struct FlashcardContent: View {
let card: TarotCard
let showAnswer: Bool
var body: some View {
VStack(spacing: 20) {
AsyncImage(url: URL(string: card.imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
Color.gray.opacity(0.2)
}
.frame(height: 300)
.cornerRadius(12)
Text(card.name)
.font(.title.bold())
if showAnswer {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Keywords")
.font(.headline)
// Show keywords here
Text("Upright Meaning")
.font(.headline)
.padding(.top)
// Show upright meaning
}
.padding()
}
.transition(.opacity)
}
}
.padding()
.background(Color.white)
.cornerRadius(20)
.shadow(radius: 10)
}
}
Image Optimization Strategies
1. CDN Caching
All images are served from https://roxyapi.com/img/tarot/ with aggressive caching headers. Implement client-side caching:
React:
// Use native browser caching
<img
src={card.imageUrl}
alt={card.name}
loading="lazy" // Browser-native lazy loading
/>
React Native:
import FastImage from 'react-native-fast-image';
<FastImage
source={{
uri: card.imageUrl,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
}}
style={styles.cardImage}
resizeMode={FastImage.resizeMode.contain}
/>
2. Progressive Loading
Show low-quality placeholder while high-res image loads:
import { useState } from 'react';
function ProgressiveImage({ src, placeholder, alt }) {
const [imageSrc, setImageSrc] = useState(placeholder);
const [imageLoaded, setImageLoaded] = useState(false);
return (
<img
src={imageSrc}
alt={alt}
onLoad={() => {
const img = new Image();
img.onload = () => {
setImageSrc(src);
setImageLoaded(true);
};
img.src = src;
}}
style={{
filter: imageLoaded ? 'none' : 'blur(10px)',
transition: 'filter 0.3s'
}}
/>
);
}
3. Responsive Images
Serve appropriate sizes for different devices:
<!-- Not needed for RoxyAPI - images are already optimized -->
<!-- But you can create thumbnails on your backend if needed -->
<img
src={card.imageUrl}
srcSet={`
${card.imageUrl} 1x,
${card.imageUrl} 2x
`}
sizes="(max-width: 768px) 100vw, 33vw"
alt={card.name}
/>
Search & Autocomplete
Build a card search with autocomplete:
import { useState, useEffect, useMemo } from 'react';
import { fetchCards } from '../services/tarotAPI';
export default function CardSearch() {
const [allCards, setAllCards] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [suggestions, setSuggestions] = useState([]);
useEffect(() => {
// Load all cards once for client-side search
fetchCards().then(data => setAllCards(data.cards));
}, []);
const filteredCards = useMemo(() => {
if (!searchTerm) return [];
const term = searchTerm.toLowerCase();
return allCards.filter(card =>
card.name.toLowerCase().includes(term) ||
card.arcana.toLowerCase().includes(term) ||
card.suit?.toLowerCase().includes(term)
).slice(0, 10); // Limit to 10 suggestions
}, [searchTerm, allCards]);
return (
<div className="search-container">
<input
type="text"
placeholder="Search cards..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
{searchTerm && (
<div className="suggestions">
{filteredCards.map(card => (
<a
key={card.id}
href={`/cards/${card.id}`}
className="suggestion-item"
>
<img src={card.imageUrl} alt={card.name} />
<div>
<div className="suggestion-name">{card.name}</div>
<div className="suggestion-meta">
{card.arcana} {card.suit && `• ${card.suit}`}
</div>
</div>
</a>
))}
{filteredCards.length === 0 && (
<div className="no-results">No cards found</div>
)}
</div>
)}
</div>
);
}
Production Best Practices
1. Rate Limit Awareness
Cache card list and details locally to minimize API calls:
// Cache in localStorage (web) or AsyncStorage (React Native)
const CACHE_KEY = 'tarot_cards_cache';
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
async function fetchCardsWithCache() {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < CACHE_DURATION) {
return data;
}
}
const fresh = await fetchCards();
localStorage.setItem(CACHE_KEY, JSON.stringify({
data: fresh,
timestamp: Date.now()
}));
return fresh;
}
2. Error Handling
Provide graceful fallbacks:
function ErrorBoundary({ error, retry }) {
return (
<div className="error-container">
<h3>Failed to load cards</h3>
<p>{error.message}</p>
<button onClick={retry}>Try Again</button>
</div>
);
}
3. Accessibility
Ensure screen reader support:
<img
src={card.imageUrl}
alt={`${card.name} tarot card from the ${card.arcana} arcana${
card.suit ? `, suit of ${card.suit}` : ''
}`}
role="img"
/>
<button
aria-label={`View details for ${card.name}`}
onClick={() => navigate(`/cards/${card.id}`)}
>
{card.name}
</button>
Pricing Considerations
The RoxyAPI Tarot API uses monthly request limits. Check pricing plans for details.
Cost Optimization Tips:
- Cache all 78 cards locally after first load (1 API call)
- Card details rarely change - cache for 7+ days
- Use client-side filtering/search on cached data
- Only fetch fresh data when explicitly requested (pull-to-refresh)
Example Usage Estimate:
- Initial load: 1 request (all 78 cards)
- Card details: 78 requests (if all viewed)
- Per user: ~10-20 API calls total (most users view 10-20 cards)
- 1000 active users = ~15,000 requests/month
Troubleshooting
Q: Images not loading or 404 errors
A: Verify imageUrl is used correctly. All images are at https://roxyapi.com/img/tarot/major/ or /minor/. Ensure no CORS issues in browser.
Q: Filter combinations return empty results
A: Some combinations are invalid (e.g., arcana=major&suit=cups). Major arcana has no suits. Disable suit filter when major arcana is selected.
Q: Card IDs not matching
A: Use exact kebab-case IDs from API: fool, ace-of-cups, queen-of-swords. Check for typos.
Q: Descriptions too long for mobile UI
A: Truncate with "Read More": description.substring(0, 200) + '...' and expand on tap.
Next Steps
You now have a complete tarot card database integration! Build further:
- Favorites System - Let users bookmark cards for quick reference
- Study Decks - Create custom collections for learning
- Reverse Image Search - Upload card photo to identify it
- Card Comparisons - Side-by-side view of similar cards
- Daily Card Widget - Display random card on home screen
Resources
- API Documentation: roxyapi.com/docs
- Product Page: roxyapi.com/products/tarot-api
- Pricing: roxyapi.com/pricing
- Support: [email protected]
About the Author: Michael Anderson is a full-stack developer specializing in spiritual and wellness applications, with experience building card database interfaces, learning tools, and content management systems for spiritual practices.