Overview
The Store Operations API handles the complete store lifecycle including registration, updates, approval workflows, and wallet initialization. This system manages the onboarding process for new merchants and provides administrative tools for store management.
Location: convex/stores/operations.ts
Store Lifecycle
Store Registration
Merchant registers their store with basic information and documents
Document Review
Admin reviews trade license and store information
Approval/Rejection
Admin approves or rejects the store application
Wallet Initialization
Approved stores get a wallet for payment processing
Store Goes Live
Store can start selling products to customers
Register Store
Register a new store for the authenticated user.
Store name for public display
Primary business category ID
Store logo image storage ID
Store cover/banner image storage ID
Trade license document storage ID
TypeScript
JavaScript
Python
const storeId = await convex . mutation ( api . stores . operations . registerStore , {
name: "Mario's Pizza" ,
primaryCategory: "k123456789" , // Food category ID
logoId: "s123456789" , // storage ID for logo
coverImageId: "s987654321" , // storage ID for cover image
tradeLicenseId: "s456789123" // storage ID for trade license
});
Update Store
Update store information for the authenticated store owner.
status
'pending' | 'approved' | 'rejected'
Store status (admin only)
Store operational settings including delivery, pickup, and live status
Bank account details for payouts
Store physical address and location details Complete formatted address string
Area/district ID reference
Nearby landmark for easier location
const updatedStore = await convex . mutation ( api . stores . operations . updateStore , {
name: "Mario's Authentic Pizza" ,
settings: {
deliveryEnabled: true ,
pickupEnabled: true ,
storeLive: true
},
bankAccount: {
IBAN: "AE123456789012345678901" ,
bankName: "Emirates NBD" ,
bankId: "b123456789" ,
accountHolderName: "Mario Rossi"
},
address: {
fullAddress: "123 Main Street, Business Bay, Dubai, UAE" ,
city: "c123456789" , // Dubai city ID
area: "a987654321" , // Business Bay area ID
flatVilaNumber: "Villa 123" ,
buildingNameNumber: "Marina Plaza" ,
landmark: "Near Dubai Mall" ,
latitude: "25.1972" ,
longitude: "55.2744"
}
});
{
"_id" : "j123456789" ,
"name" : "Mario's Authentic Pizza" ,
"primaryCategory" : "k123456789" ,
"status" : "approved" ,
"logoUrl" : "https://storage.convex.dev/..." ,
"coverImageUrl" : "https://storage.convex.dev/..." ,
"settings" : {
"deliveryEnabled" : true ,
"pickupEnabled" : true ,
"storeLive" : true
},
"bankAccount" : {
"IBAN" : "AE123456789012345678901" ,
"bankName" : "Emirates NBD" ,
"accountHolderName" : "Mario Rossi"
},
"address" : {
"fullAddress" : "123 Main Street, Business Bay, Dubai, UAE" ,
"city" : {
"_id" : "c123456789" ,
"name" : "Dubai" ,
"nameArabic" : "دبي"
},
"area" : {
"_id" : "a987654321" ,
"name" : "Business Bay" ,
"city" : "c123456789"
},
"flatVilaNumber" : "Villa 123" ,
"buildingNameNumber" : "Marina Plaza" ,
"landmark" : "Near Dubai Mall" ,
"latitude" : "25.1972" ,
"longitude" : "55.2744"
},
"_creationTime" : 1640995200000
}
Update Store Address
Update only the address information for the authenticated store owner.
Complete formatted address string
Area/district ID reference
Nearby landmark for easier location
TypeScript
JavaScript
Python
const addressResult = await convex . mutation ( api . stores . operations . updateStoreAddress , {
fullAddress: "456 New Address Street, Marina, Dubai, UAE" ,
city: "c123456789" , // Dubai city ID
area: "a555666777" , // Marina area ID
flatVilaNumber: "Apartment 204" ,
buildingNameNumber: "Ocean View Tower" ,
landmark: "Next to Marina Mall" ,
latitude: "25.0657" ,
longitude: "55.1713"
});
{
"_id" : "j123456789" ,
"address" : {
"fullAddress" : "456 New Address Street, Marina, Dubai, UAE" ,
"city" : {
"_id" : "c123456789" ,
"name" : "Dubai" ,
"nameArabic" : "دبي"
},
"area" : {
"_id" : "a555666777" ,
"name" : "Marina" ,
"city" : "c123456789"
},
"flatVilaNumber" : "Apartment 204" ,
"buildingNameNumber" : "Ocean View Tower" ,
"landmark" : "Next to Marina Mall" ,
"latitude" : "25.0657" ,
"longitude" : "55.1713"
}
}
Approve Store (Admin)
Admin function to approve a store application and initialize its wallet.
Reason for approval (optional)
const approvalResult = await convex . mutation ( api . stores . operations . approveStore , {
storeId: "j123456789" ,
statusReason: "All documents verified and approved"
});
{
"storeId" : "j123456789" ,
"walletId" : "w123456789" ,
"message" : "Store approved successfully and wallet initialized" ,
"notificationSent" : true
}
Reject Store (Admin)
Admin function to reject a store application.
Detailed reason for rejection
await convex . mutation ( api . stores . operations . rejectStore , {
storeId: "j123456789" ,
rejectionReason: "Trade license document is expired. Please upload a valid license."
});
{
"storeId" : "j123456789" ,
"message" : "Store rejected successfully" ,
"notificationSent" : true
}
Initialize Store Wallet
Initialize wallet for an existing approved store.
Store ID to initialize wallet for
const walletResult = await convex . mutation ( api . stores . operations . initializeExistingStoreWallet , {
storeId: "j123456789"
});
{
"walletId" : "w123456789" ,
"message" : "Wallet initialized successfully for store"
}
Bulk Initialize Wallets (Admin)
Admin function to bulk initialize wallets for all approved stores without wallets.
const bulkResult = await convex . mutation ( api . stores . operations . bulkInitializeWallets );
{
"processedStores" : 25 ,
"walletsCreated" : 23 ,
"errors" : [
{
"storeId" : "j987654321" ,
"storeName" : "Test Store" ,
"error" : "Store not approved"
},
{
"storeId" : "j555666777" ,
"storeName" : "Another Store" ,
"error" : "Wallet already exists"
}
]
}
Complete store registration component:
import { useState } from 'react' ;
import { useMutation , useQuery } from 'convex/react' ;
import { api } from './convex/_generated/api' ;
interface StoreRegistrationProps {
onSuccess ?: ( storeId : string ) => void ;
}
function StoreRegistrationForm ({ onSuccess } : StoreRegistrationProps ) {
const [ formData , setFormData ] = useState ({
name: '' ,
primaryCategory: '' ,
logoFile: null as File | null ,
coverImageFile: null as File | null ,
tradeLicenseFile: null as File | null
});
const [ uploading , setUploading ] = useState ( false );
const [ errors , setErrors ] = useState < Record < string , string >>({});
const categories = useQuery ( api . shared . categories . getParents );
const generateUploadUrl = useMutation ( api . shared . utils . generateUploadUrl );
const registerStore = useMutation ( api . stores . operations . registerStore );
const uploadFile = async ( file : File ) : Promise < string > => {
const uploadUrl = await generateUploadUrl ();
const response = await fetch ( uploadUrl , {
method: 'POST' ,
body: file
});
const { storageId } = await response . json ();
return storageId ;
};
const handleSubmit = async ( e : React . FormEvent ) => {
e . preventDefault ();
setUploading ( true );
setErrors ({});
try {
// Validation
const newErrors : Record < string , string > = {};
if ( ! formData . name . trim ()) newErrors . name = 'Store name is required' ;
if ( ! formData . primaryCategory ) newErrors . primaryCategory = 'Category is required' ;
if ( ! formData . tradeLicenseFile ) newErrors . tradeLicense = 'Trade license is required' ;
if ( Object . keys ( newErrors ). length > 0 ) {
setErrors ( newErrors );
return ;
}
// Upload files
const tradeLicenseId = await uploadFile ( formData . tradeLicenseFile ! );
const logoId = formData . logoFile ? await uploadFile ( formData . logoFile ) : undefined ;
const coverImageId = formData . coverImageFile ? await uploadFile ( formData . coverImageFile ) : undefined ;
// Register store
const storeId = await registerStore ({
name: formData . name . trim (),
primaryCategory: formData . primaryCategory ,
logoId ,
coverImageId ,
tradeLicenseId
});
onSuccess ?.( storeId );
} catch ( error ) {
console . error ( 'Store registration failed:' , error );
setErrors ({ submit: 'Registration failed. Please try again.' });
} finally {
setUploading ( false );
}
};
return (
< form onSubmit = { handleSubmit } className = "max-w-2xl mx-auto space-y-6" >
< div className = "bg-blue-50 p-4 rounded-lg" >
< h2 className = "text-xl font-bold text-blue-900 mb-2" > Register Your Store </ h2 >
< p className = "text-blue-700 text-sm" >
Complete this form to register your store . All information will be reviewed by our team .
</ p >
</ div >
{ /* Store Name */ }
< div >
< label className = "block text-sm font-medium text-gray-700 mb-2" >
Store Name < span className = "text-red-500" >* </ span >
</ label >
< input
type = "text"
value = {formData. name }
onChange = {(e) => {
setFormData ( prev => ({ ... prev , name: e . target . value }));
setErrors ( prev => ({ ... prev , name: '' }));
}}
placeholder = "Enter your store name"
className = "w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled = { uploading }
/>
{ errors . name && < p className = "text-red-500 text-sm mt-1" > {errors. name } </ p > }
</ div >
{ /* Primary Category */ }
< div >
< label className = "block text-sm font-medium text-gray-700 mb-2" >
Primary Category < span className = "text-red-500" >* </ span >
</ label >
< select
value = {formData. primaryCategory }
onChange = {(e) => {
setFormData ( prev => ({ ... prev , primaryCategory: e . target . value }));
setErrors ( prev => ({ ... prev , primaryCategory: '' }));
}}
className = "w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled = { uploading }
>
< option value = "" > Select a category </ option >
{ categories ?. map (( category ) => (
< option key = {category. _id } value = {category. _id } >
{ category . name }
</ option >
))}
</ select >
{ errors . primaryCategory && < p className = "text-red-500 text-sm mt-1" > {errors. primaryCategory } </ p > }
</ div >
{ /* Logo Upload */ }
< div >
< label className = "block text-sm font-medium text-gray-700 mb-2" >
Store Logo ( Optional )
</ label >
< input
type = "file"
accept = "image/*"
onChange = {(e) => setFormData ( prev => ({ ... prev , logoFile: e . target . files ?.[ 0 ] || null }))}
className = "w-full p-3 border border-gray-300 rounded-lg"
disabled = { uploading }
/>
< p className = "text-xs text-gray-500 mt-1" >
Recommended : Square image , minimum 200 x200px
</ p >
</ div >
{ /* Cover Image Upload */ }
< div >
< label className = "block text-sm font-medium text-gray-700 mb-2" >
Cover Image ( Optional )
</ label >
< input
type = "file"
accept = "image/*"
onChange = {(e) => setFormData ( prev => ({ ... prev , coverImageFile: e . target . files ?.[ 0 ] || null }))}
className = "w-full p-3 border border-gray-300 rounded-lg"
disabled = { uploading }
/>
< p className = "text-xs text-gray-500 mt-1" >
Recommended : 1200 x400px banner image
</ p >
</ div >
{ /* Trade License Upload */ }
< div >
< label className = "block text-sm font-medium text-gray-700 mb-2" >
Trade License < span className = "text-red-500" >* </ span >
</ label >
< input
type = "file"
accept = ".pdf,.jpg,.jpeg,.png"
onChange = {(e) => {
setFormData ( prev => ({ ... prev , tradeLicenseFile: e . target . files ?.[ 0 ] || null }));
setErrors ( prev => ({ ... prev , tradeLicense: '' }));
}}
className = "w-full p-3 border border-gray-300 rounded-lg"
disabled = { uploading }
/>
< p className = "text-xs text-gray-500 mt-1" >
Upload a clear copy of your trade license ( PDF , JPG , PNG )
</ p >
{ errors . tradeLicense && < p className = "text-red-500 text-sm mt-1" > {errors. tradeLicense } </ p > }
</ div >
{ /* Submit Button */ }
< div >
< button
type = "submit"
disabled = { uploading }
className = "w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{ uploading ? (
<>
< svg className = "animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" >
< circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" > </ circle >
< path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > </ path >
</ svg >
Registering Store ...
</>
) : (
' Register Store '
)}
</ button >
</ div >
{ errors . submit && (
< div className = "bg-red-50 p-4 rounded-lg" >
< p className = "text-red-700 text-sm" > {errors. submit } </ p >
</ div >
)}
< div className = "bg-gray-50 p-4 rounded-lg" >
< h3 className = "font-medium text-gray-900 mb-2" > What happens next ? </ h3 >
< ol className = "text-sm text-gray-600 space-y-1 list-decimal list-inside" >
< li > Your application will be reviewed by our team </ li >
< li > We 'll verify your trade license and business details</li >
< li > You 'll receive approval/rejection notification within 24-48 hours</li >
< li > Once approved , you can start adding products and go live </ li >
</ ol >
</ div >
</ form >
);
}
export default StoreRegistrationForm ;
Store Status Management
Store applications go through several status states:
Pending Initial status when store is registered. Awaiting admin review.
Under Review Admin is actively reviewing documents and store information.
Approved Store is approved and can start selling. Wallet is automatically initialized.
Rejected Store application rejected. Reason provided for resubmission.
Admin Review Process
For administrators reviewing store applications:
// Review and approve a store
const handleApproval = async ( storeId : string ) => {
try {
const result = await convex . mutation ( api . stores . operations . approveStore , {
storeId ,
statusReason: "All documents verified. Welcome to the marketplace!"
});
console . log ( `Store approved: ${ result . storeId } ` );
console . log ( `Wallet created: ${ result . walletId } ` );
// Send notification to store owner
await convex . action ( api . shared . notifications . sendNotificationToUser , {
userId: storeOwnerId ,
title: "🎉 Store Approved!" ,
body: "Your store has been approved and is now live" ,
data: {
type: "store_status" ,
storeId: result . storeId ,
status: "approved"
}
});
} catch ( error ) {
console . error ( 'Approval failed:' , error );
}
};
// Review and reject a store
const handleRejection = async ( storeId : string , reason : string ) => {
try {
await convex . mutation ( api . stores . operations . rejectStore , {
storeId ,
rejectionReason: reason
});
// Send notification to store owner
await convex . action ( api . shared . notifications . sendNotificationToUser , {
userId: storeOwnerId ,
title: "Store Application Update" ,
body: "Your store application needs attention" ,
data: {
type: "store_status" ,
storeId ,
status: "rejected"
}
});
} catch ( error ) {
console . error ( 'Rejection failed:' , error );
}
};
Error Handling
Status Code : 400{
"error" : "User already has a registered store" ,
"existingStoreId" : "j123456789"
}
Status Code : 404{
"error" : "Primary category not found" ,
"categoryId" : "k123456789"
}
Status Code : 400{
"error" : "Trade license document is required for store registration"
}
Status Code : 404{
"error" : "Store not found or access denied" ,
"storeId" : "j123456789"
}
Store registration requires a valid trade license document. The approval process typically takes 24-48 hours for document verification.
Upload high-quality images for better store presentation. Square logos (200x200px minimum) and banner cover images (1200x400px) work best.