Project: ShopCall.ai - ShopRenter Integration Platform: ShopRenter (Hungarian e-commerce platform) Integration Type: OAuth 2.0 App with API Access Documentation Source: https://doc.shoprenter.hu/development/app-development/
ShopRenter is a Hungarian e-commerce platform (similar to Shopify) that provides online store solutions. This integration will enable ShopCall.ai to connect with ShopRenter stores and provide AI-powered calling services.
Must be sent to: partnersupport@shoprenter.hu
Application Name:
ShopCall.ai - AI Phone Assistant
Short Description (max 70 chars):
AI-powered phone assistant for automated customer service calls
Application Details Link:
https://shopcall.ai
Application Type:
Redirected (user redirected to our platform, not iframe)
⚠️ Note: The URLs below are examples. These are now configurable via environment variables:
FRONTEND_URL - for EntryPointBACKEND_URL - for RedirectUri and UninstallUriSee .env.example files for configuration details.
EntryPoint (HTTPS required):
https://shopcall.ai/integrations/shoprenter/entry
RedirectUri (HTTPS required):
https://shopcall-ai-backend.vercel.app/auth/shoprenter/callback
UninstallUri (HTTPS required):
https://shopcall-ai-backend.vercel.app/webhooks/shoprenter/uninstall
Application Logo:
/shopcall.ai-main/public/images/shoprenter-app-logo.pngTest Store Name:
shopcall-test-store
shopcall-test-store.myshoprenter.huBased on ShopCall.ai functionality, we need:
product:read # Read product catalog
product:write # Update product information (if needed)
customer:read # Access customer data
customer:write # Update customer information (notes, tags)
order:read # Read order information
order:write # Update order status, add notes
category:read # Read product categories
webhook:read # List webhooks
webhook:write # Create/update webhooks for real-time sync
Scope Justification:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ ShopRenter │ │ ShopCall.ai │ │ Supabase │
│ Store │◄───────►│ Backend │◄───────►│ Database │
│ │ OAuth │ │ Store │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │
│ Webhooks │ API Calls
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Product Sync │ │ AI Phone │
│ Order Updates │ │ Assistant │
└─────────────────┘ └──────────────────┘
Frontend (React/Vite):
Backend (Express.js/Node.js):
Database (Supabase):
sequenceDiagram
participant Merchant
participant ShopRenter
participant ShopCall Backend
participant Database
Merchant->>ShopRenter: Click "Install ShopCall.ai"
ShopRenter->>ShopCall Backend: GET /auth/shoprenter/callback?shopname=X&code=Y×tamp=Z&hmac=H&app_url=U
ShopCall Backend->>ShopCall Backend: Validate HMAC (sha256)
alt HMAC Valid
ShopCall Backend->>Database: Store installation data
ShopCall Backend->>Merchant: Redirect to app_url
ShopRenter->>ShopCall Backend: GET /integrations/shoprenter/entry?shopname=X&code=Y×tamp=Z&hmac=H
ShopCall Backend->>ShopCall Backend: Validate HMAC again
ShopCall Backend->>ShopCall Backend: Request OAuth token from ShopRenter
ShopCall Backend->>Database: Store access token
ShopCall Backend->>Merchant: Show success page
else HMAC Invalid
ShopCall Backend->>Merchant: Show error (403)
end
ShopRenter sends:
GET /auth/shoprenter/callback?shopname=example&code=0907a61c0c8d55e99db179b68161bc00×tamp=1337178173&hmac=d48e5d...&app_url=...
Validation Algorithm:
function validateHMAC(query, clientSecret) {
// 1. Extract HMAC from query
const { hmac, ...params } = query;
// 2. Build query string without HMAC (sorted alphabetically)
const sortedParams = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
// Example: "code=0907a61c0c8d55e99db179b68161bc00&shopname=example×tamp=1337178173"
// 3. Generate HMAC using sha256
const crypto = require('crypto');
const calculatedHmac = crypto
.createHmac('sha256', clientSecret)
.update(sortedParams)
.digest('hex');
// 4. Compare (timing-safe)
return crypto.timingSafeEqual(
Buffer.from(calculatedHmac),
Buffer.from(hmac)
);
}
Security Notes:
After successful HMAC validation, request access token:
Endpoint: (To be confirmed with ShopRenter Partner Support)
POST https://{shopname}.myshoprenter.hu/oauth/token
Request Body:
{
"grant_type": "authorization_code",
"client_id": "{ClientId}",
"client_secret": "{ClientSecret}",
"code": "{code_from_callback}",
"redirect_uri": "https://shopcall-ai-backend.vercel.app/auth/shoprenter/callback"
}
Response:
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def50200...",
"scope": "product:read customer:read order:read"
}
https://{shopname}.myshoprenter.hu/api
Based on ShopRenter API documentation:
List Products:
GET /api/products
Authorization: Bearer {access_token}
Response:
{
"items": [
{
"id": "cHJvZHVjdC1wcm9kdWN0X2lkPTE=",
"name": "Product Name",
"sku": "SKU-123",
"price": "9990.00",
"currency": "HUF",
"description": "Product description",
"stock": 10,
"active": true
}
],
"pagination": {
"total": 150,
"page": 1,
"limit": 25
}
}
List Customers:
GET /api/customers
Authorization: Bearer {access_token}
Response:
{
"items": [
{
"id": "Y3VzdG9tZXItY3VzdG9tZXJfaWQ9MQ==",
"firstname": "János",
"lastname": "Kovács",
"email": "janos.kovacs@example.com",
"phone": "+36201234567",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
List Orders:
GET /api/orders
Authorization: Bearer {access_token}
Response:
{
"items": [
{
"id": "b3JkZXItb3JkZXJfaWQ9MQ==",
"order_number": "SR-2024-001",
"customer_id": "Y3VzdG9tZXItY3VzdG9tZXJfaWQ9MQ==",
"status": "processing",
"total": "29990.00",
"currency": "HUF",
"created_at": "2024-01-20T14:25:00Z",
"items": [...]
}
]
}
Register Webhook:
POST /api/webhooks
Authorization: Bearer {access_token}
Content-Type: application/json
Body:
{
"event": "order/create",
"address": "https://shopcall-ai-backend.vercel.app/webhooks/shoprenter/orders",
"active": true
}
Response:
{
"id": "d2ViaG9vay13ZWJob29rX2lkPTE=",
"event": "order/create",
"address": "https://...",
"active": true
}
Available Webhook Events:
order/create - New order createdorder/update - Order status changedproduct/create - New product addedproduct/update - Product information changedproduct/delete - Product deletedcustomer/create - New customer registeredcustomer/update - Customer information updatedshoprenter_tokensCREATE TABLE shoprenter_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID REFERENCES stores(id) ON DELETE CASCADE,
-- OAuth tokens
access_token TEXT NOT NULL,
refresh_token TEXT,
token_type VARCHAR(20) DEFAULT 'Bearer',
expires_at TIMESTAMP WITH TIME ZONE,
-- Scopes
scopes TEXT[], -- ['product:read', 'customer:read', ...]
-- Store information
shopname VARCHAR(255) NOT NULL, -- e.g., 'example' from 'example.myshoprenter.hu'
shop_domain VARCHAR(255) NOT NULL, -- Full domain
-- Status
is_active BOOLEAN DEFAULT true,
last_sync_at TIMESTAMP WITH TIME ZONE,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraints
UNIQUE(store_id)
);
-- Index for fast lookups
CREATE INDEX idx_shoprenter_tokens_shopname ON shoprenter_tokens(shopname);
CREATE INDEX idx_shoprenter_tokens_active ON shoprenter_tokens(is_active);
-- Trigger for updated_at
CREATE TRIGGER set_shoprenter_tokens_updated_at
BEFORE UPDATE ON shoprenter_tokens
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
stores Table-- Add ShopRenter-specific columns
ALTER TABLE stores ADD COLUMN IF NOT EXISTS shoprenter_app_id VARCHAR(50);
ALTER TABLE stores ADD COLUMN IF NOT EXISTS shoprenter_client_id VARCHAR(255);
shoprenter_products_cacheCREATE TABLE shoprenter_products_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID REFERENCES stores(id) ON DELETE CASCADE,
-- Product data from ShopRenter API
shoprenter_product_id VARCHAR(255) NOT NULL,
name VARCHAR(500),
sku VARCHAR(255),
price DECIMAL(10,2),
currency VARCHAR(3) DEFAULT 'HUF',
description TEXT,
stock INTEGER,
active BOOLEAN,
-- Raw data
raw_data JSONB, -- Full API response
-- Sync metadata
last_synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraints
UNIQUE(store_id, shoprenter_product_id)
);
-- Indexes
CREATE INDEX idx_shoprenter_products_store ON shoprenter_products_cache(store_id);
CREATE INDEX idx_shoprenter_products_sku ON shoprenter_products_cache(sku);
CREATE INDEX idx_shoprenter_products_active ON shoprenter_products_cache(active);
shoprenter_webhooksCREATE TABLE shoprenter_webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
store_id UUID REFERENCES stores(id) ON DELETE CASCADE,
-- Webhook details
shoprenter_webhook_id VARCHAR(255),
event VARCHAR(100) NOT NULL, -- 'order/create', 'product/update', etc.
callback_url TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
-- Statistics
last_received_at TIMESTAMP WITH TIME ZONE,
total_received INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(store_id, event)
);
shopcall.ai-backend-main/
├── api/
│ └── index.js # Main server file
├── lib/
│ └── shoprenter/
│ ├── oauth.js # OAuth flow handling
│ ├── api-client.js # ShopRenter API client
│ ├── hmac-validator.js # HMAC validation utility
│ ├── webhook-processor.js # Webhook handling
│ └── sync-service.js # Product/customer sync
└── config/
└── shoprenter.config.js # ShopRenter configuration
config/shoprenter.config.js)module.exports = {
appId: process.env.SHOPRENTER_APP_ID,
clientId: process.env.SHOPRENTER_CLIENT_ID,
clientSecret: process.env.SHOPRENTER_CLIENT_SECRET,
redirectUri: process.env.NODE_ENV === 'production'
? 'https://shopcall-ai-backend.vercel.app/auth/shoprenter/callback'
: 'http://localhost:3000/auth/shoprenter/callback',
entryPoint: process.env.NODE_ENV === 'production'
? 'https://shopcall.ai/integrations/shoprenter/entry'
: 'http://localhost:8080/integrations/shoprenter/entry',
uninstallUri: process.env.NODE_ENV === 'production'
? 'https://shopcall-ai-backend.vercel.app/webhooks/shoprenter/uninstall'
: 'http://localhost:3000/webhooks/shoprenter/uninstall',
scopes: [
'product:read',
'product:write',
'customer:read',
'customer:write',
'order:read',
'order:write',
'category:read',
'webhook:read',
'webhook:write'
],
apiVersion: 'v1',
maxRequestsPerSecond: 5, // Rate limiting
tokenExpiryBuffer: 300, // Refresh 5 min before expiry
};
lib/shoprenter/hmac-validator.js)const crypto = require('crypto');
const config = require('../../config/shoprenter.config');
/**
* Validate HMAC signature from ShopRenter
* @param {Object} query - Query parameters from request
* @param {string} query.hmac - HMAC signature
* @param {string} query.shopname - Store name
* @param {string} query.code - Authorization code
* @param {string} query.timestamp - Request timestamp
* @returns {boolean} - True if HMAC is valid
*/
function validateHMAC(query) {
const { hmac, ...params } = query;
if (!hmac) {
console.error('[ShopRenter] HMAC missing from request');
return false;
}
// Build sorted query string without HMAC
const sortedParams = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
// Calculate HMAC using sha256
const calculatedHmac = crypto
.createHmac('sha256', config.clientSecret)
.update(sortedParams)
.digest('hex');
// Timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(calculatedHmac),
Buffer.from(hmac)
);
} catch (error) {
console.error('[ShopRenter] HMAC comparison error:', error);
return false;
}
}
/**
* Validate request timestamp (prevent replay attacks)
* @param {string} timestamp - Unix timestamp from request
* @param {number} maxAgeSeconds - Maximum age allowed (default: 300 = 5 min)
* @returns {boolean} - True if timestamp is within valid range
*/
function validateTimestamp(timestamp, maxAgeSeconds = 300) {
const requestTime = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);
const age = currentTime - requestTime;
if (age < 0) {
console.error('[ShopRenter] Request timestamp is in the future');
return false;
}
if (age > maxAgeSeconds) {
console.error(`[ShopRenter] Request timestamp too old: ${age}s > ${maxAgeSeconds}s`);
return false;
}
return true;
}
/**
* Validate complete ShopRenter request
* @param {Object} query - Query parameters
* @returns {Object} - { valid: boolean, error?: string }
*/
function validateRequest(query) {
// Check required parameters
const required = ['shopname', 'code', 'timestamp', 'hmac'];
const missing = required.filter(param => !query[param]);
if (missing.length > 0) {
return {
valid: false,
error: `Missing required parameters: ${missing.join(', ')}`
};
}
// Validate timestamp
if (!validateTimestamp(query.timestamp)) {
return {
valid: false,
error: 'Request timestamp invalid or expired'
};
}
// Validate HMAC
if (!validateHMAC(query)) {
return {
valid: false,
error: 'HMAC validation failed'
};
}
return { valid: true };
}
module.exports = {
validateHMAC,
validateTimestamp,
validateRequest
};
lib/shoprenter/oauth.js)const axios = require('axios');
const config = require('../../config/shoprenter.config');
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY
);
/**
* Exchange authorization code for access token
* @param {string} shopname - Store name
* @param {string} code - Authorization code
* @returns {Promise<Object>} - Token response
*/
async function exchangeCodeForToken(shopname, code) {
const tokenUrl = `https://${shopname}.myshoprenter.hu/oauth/token`;
try {
const response = await axios.post(tokenUrl, {
grant_type: 'authorization_code',
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: config.redirectUri
}, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
console.log(`[ShopRenter] Token acquired for ${shopname}`);
return response.data;
} catch (error) {
console.error('[ShopRenter] Token exchange error:', error.response?.data || error.message);
throw new Error('Failed to exchange code for token');
}
}
/**
* Store ShopRenter credentials in database
* @param {string} userId - User ID
* @param {string} shopname - Store name
* @param {Object} tokenData - Token response from OAuth
* @returns {Promise<Object>} - Database record
*/
async function storeCredentials(userId, shopname, tokenData) {
const shopDomain = `${shopname}.myshoprenter.hu`;
const expiresAt = new Date(Date.now() + (tokenData.expires_in * 1000));
// 1. Create or update stores record
const { data: store, error: storeError } = await supabase
.from('stores')
.upsert({
user_id: userId,
platform_name: 'shoprenter',
store_name: shopname,
store_url: `https://${shopDomain}`,
shoprenter_app_id: config.appId,
shoprenter_client_id: config.clientId,
is_active: true,
connected_at: new Date().toISOString()
}, {
onConflict: 'user_id,platform_name,store_url'
})
.select()
.single();
if (storeError) {
console.error('[ShopRenter] Error storing store:', storeError);
throw storeError;
}
// 2. Store OAuth tokens
const { data: tokens, error: tokensError } = await supabase
.from('shoprenter_tokens')
.upsert({
store_id: store.id,
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
token_type: tokenData.token_type,
expires_at: expiresAt.toISOString(),
scopes: tokenData.scope ? tokenData.scope.split(' ') : config.scopes,
shopname: shopname,
shop_domain: shopDomain,
is_active: true
}, {
onConflict: 'store_id'
})
.select()
.single();
if (tokensError) {
console.error('[ShopRenter] Error storing tokens:', tokensError);
throw tokensError;
}
console.log(`[ShopRenter] Credentials stored for ${shopname}`);
return { store, tokens };
}
/**
* Refresh access token
* @param {string} shopname - Store name
* @param {string} refreshToken - Refresh token
* @returns {Promise<Object>} - New token data
*/
async function refreshAccessToken(shopname, refreshToken) {
const tokenUrl = `https://${shopname}.myshoprenter.hu/oauth/token`;
try {
const response = await axios.post(tokenUrl, {
grant_type: 'refresh_token',
client_id: config.clientId,
client_secret: config.clientSecret,
refresh_token: refreshToken
});
console.log(`[ShopRenter] Token refreshed for ${shopname}`);
return response.data;
} catch (error) {
console.error('[ShopRenter] Token refresh error:', error.response?.data || error.message);
throw new Error('Failed to refresh token');
}
}
/**
* Get valid access token (refreshes if needed)
* @param {string} storeId - Store ID
* @returns {Promise<string>} - Valid access token
*/
async function getValidAccessToken(storeId) {
// Fetch tokens from database
const { data: tokens, error } = await supabase
.from('shoprenter_tokens')
.select('*')
.eq('store_id', storeId)
.eq('is_active', true)
.single();
if (error || !tokens) {
throw new Error('ShopRenter tokens not found');
}
// Check if token is expired or about to expire
const expiresAt = new Date(tokens.expires_at);
const now = new Date();
const bufferTime = config.tokenExpiryBuffer * 1000; // 5 minutes
if (expiresAt.getTime() - now.getTime() < bufferTime) {
// Token expired or expiring soon, refresh it
console.log(`[ShopRenter] Token expiring, refreshing for ${tokens.shopname}`);
const newTokenData = await refreshAccessToken(tokens.shopname, tokens.refresh_token);
const newExpiresAt = new Date(Date.now() + (newTokenData.expires_in * 1000));
// Update database
await supabase
.from('shoprenter_tokens')
.update({
access_token: newTokenData.access_token,
refresh_token: newTokenData.refresh_token || tokens.refresh_token,
expires_at: newExpiresAt.toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', tokens.id);
return newTokenData.access_token;
}
return tokens.access_token;
}
module.exports = {
exchangeCodeForToken,
storeCredentials,
refreshAccessToken,
getValidAccessToken
};
api/index.js)Add these routes to your main server file:
const { validateRequest } = require('../lib/shoprenter/hmac-validator');
const { exchangeCodeForToken, storeCredentials } = require('../lib/shoprenter/oauth');
// ShopRenter OAuth Callback
app.get('/auth/shoprenter/callback', async (req, res) => {
const { shopname, code, timestamp, hmac, app_url } = req.query;
console.log(`[ShopRenter] OAuth callback received for ${shopname}`);
// 1. Validate request
const validation = validateRequest(req.query);
if (!validation.valid) {
console.error(`[ShopRenter] Validation failed: ${validation.error}`);
return res.status(403).json({
error: 'Invalid request',
details: validation.error
});
}
try {
// 2. Exchange code for tokens
const tokenData = await exchangeCodeForToken(shopname, code);
// 3. Store credentials
// Note: We need user_id here. In ShopRenter flow, we might need to:
// - Store shopname in a temp Map with code
// - Let user authenticate first
// For now, we'll redirect to app_url and handle auth there
// Store in temporary map for later association
const installationId = crypto.randomBytes(16).toString('hex');
global.pendingShopRenterInstalls = global.pendingShopRenterInstalls || new Map();
global.pendingShopRenterInstalls.set(installationId, {
shopname,
tokenData,
timestamp: Date.now(),
expiresAt: Date.now() + (15 * 60 * 1000) // 15 minutes
});
// 4. Redirect to app_url with installation_id
const redirectUrl = new URL(app_url);
redirectUrl.searchParams.append('sr_install', installationId);
console.log(`[ShopRenter] Redirecting to: ${redirectUrl.toString()}`);
res.redirect(redirectUrl.toString());
} catch (error) {
console.error('[ShopRenter] OAuth callback error:', error);
res.status(500).json({
error: 'Installation failed',
details: error.message
});
}
});
// ShopRenter EntryPoint (after redirect from callback)
app.get('/integrations/shoprenter/entry', securedSession, async (req, res) => {
const { shopname, code, timestamp, hmac, sr_install } = req.query;
console.log(`[ShopRenter] EntryPoint accessed for ${shopname}`);
// 1. Validate HMAC
const validation = validateRequest({ shopname, code, timestamp, hmac });
if (!validation.valid) {
return res.status(403).json({
error: 'Invalid request',
details: validation.error
});
}
try {
// 2. Get installation data from temporary storage
if (!sr_install || !global.pendingShopRenterInstalls?.has(sr_install)) {
return res.status(400).json({
error: 'Installation session not found or expired'
});
}
const installData = global.pendingShopRenterInstalls.get(sr_install);
// 3. Store credentials with authenticated user
await storeCredentials(req.user.id, installData.shopname, installData.tokenData);
// 4. Clean up temporary storage
global.pendingShopRenterInstalls.delete(sr_install);
// 5. Set up webhooks (async)
setupWebhooks(req.user.id, installData.shopname).catch(err => {
console.error('[ShopRenter] Webhook setup error:', err);
});
// 6. Start initial sync (async)
syncShopRenterData(req.user.id, installData.shopname).catch(err => {
console.error('[ShopRenter] Initial sync error:', err);
});
// 7. Redirect to success page
res.redirect('https://shopcall.ai/integrations?connected=shoprenter&status=success');
} catch (error) {
console.error('[ShopRenter] EntryPoint error:', error);
res.redirect('https://shopcall.ai/integrations?connected=shoprenter&status=error');
}
});
// ShopRenter Uninstall Webhook
app.get('/webhooks/shoprenter/uninstall', async (req, res) => {
const { shopname, code, timestamp, hmac } = req.query;
console.log(`[ShopRenter] Uninstall webhook received for ${shopname}`);
// 1. Validate HMAC
const validation = validateRequest(req.query);
if (!validation.valid) {
return res.status(403).json({
error: 'Invalid request',
details: validation.error
});
}
try {
// 2. Find store by shopname
const { data: tokens } = await supabase
.from('shoprenter_tokens')
.select('store_id')
.eq('shopname', shopname)
.single();
if (tokens) {
// 3. Deactivate store
await supabase
.from('stores')
.update({ is_active: false, updated_at: new Date().toISOString() })
.eq('id', tokens.store_id);
// 4. Deactivate tokens
await supabase
.from('shoprenter_tokens')
.update({ is_active: false, updated_at: new Date().toISOString() })
.eq('store_id', tokens.store_id);
console.log(`[ShopRenter] Store ${shopname} uninstalled successfully`);
}
// 5. Respond with 200 (ShopRenter expects this)
res.status(200).json({ message: 'Uninstall processed' });
} catch (error) {
console.error('[ShopRenter] Uninstall error:', error);
// Still respond with 200 to prevent retries
res.status(200).json({ message: 'Uninstall processed with errors' });
}
});
// Cleanup expired installations periodically
setInterval(() => {
if (!global.pendingShopRenterInstalls) return;
const now = Date.now();
for (const [key, value] of global.pendingShopRenterInstalls.entries()) {
if (now > value.expiresAt) {
global.pendingShopRenterInstalls.delete(key);
console.log(`[ShopRenter] Cleaned up expired installation: ${key}`);
}
}
}, 5 * 60 * 1000); // Every 5 minutes
Create: /shopcall.ai-main/src/components/ShopRenterConnect.tsx
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
export function ShopRenterConnect() {
const [shopname, setShopname] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleConnect = async () => {
if (!shopname.trim()) {
setError('Kérjük, adja meg a bolt nevét');
return;
}
setLoading(true);
setError('');
try {
// For ShopRenter, the store owner must initiate installation from ShopRenter admin
// This would typically open the ShopRenter app store
window.open(
`https://${shopname}.myshoprenter.hu/admin/app/${SHOPRENTER_CLIENT_ID}`,
'_blank'
);
// Show instructions
setError('');
} catch (err) {
setError('Hiba történt a kapcsolódás során');
console.error('ShopRenter connection error:', err);
} finally {
setLoading(false);
}
};
return (
<Card className="bg-slate-800 border-slate-700">
<CardHeader>
<div className="flex items-center gap-3">
<img
src="/images/shoprenter-logo.png"
alt="ShopRenter"
className="w-10 h-10"
/>
<CardTitle className="text-white">ShopRenter</CardTitle>
</div>
<p className="text-slate-400">
Magyar webáruház platform - AI telefonos asszisztens integráció
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="shopname" className="text-slate-300">
Bolt neve
</Label>
<div className="flex gap-2">
<Input
id="shopname"
placeholder="pelda"
value={shopname}
onChange={(e) => setShopname(e.target.value)}
className="bg-slate-700 border-slate-600 text-white flex-1"
/>
<span className="flex items-center text-slate-400">
.myshoprenter.hu
</span>
</div>
<p className="text-slate-500 text-sm">
Add meg a bolt nevét (pl. "pelda" ha a bolt címe pelda.myshoprenter.hu)
</p>
</div>
{error && (
<Alert variant="destructive" className="bg-red-900/20 border-red-900">
<AlertCircle className="w-4 h-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleConnect}
disabled={loading}
className="w-full bg-[#85b218] hover:bg-[#6d9315] text-white"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Kapcsolódás...
</>
) : (
'ShopRenter kapcsolat létrehozása'
)}
</Button>
<div className="bg-slate-700/50 p-4 rounded-lg">
<h4 className="text-white font-medium mb-2">Telepítési lépések:</h4>
<ol className="text-slate-300 text-sm space-y-1 list-decimal list-inside">
<li>Kattints a "Kapcsolat létrehozása" gombra</li>
<li>Jelentkezz be a ShopRenter admin felületedre</li>
<li>Hagyd jóvá az alkalmazás telepítését</li>
<li>Visszairányítunk ide a sikeres telepítés után</li>
</ol>
</div>
</CardContent>
</Card>
);
}
Update /shopcall.ai-main/src/components/IntegrationsContent.tsx:
import { ShopRenterConnect } from './ShopRenterConnect';
// In the platforms grid, add:
<div className="grid gap-6 md:grid-cols-3">
{/* Existing Shopify card */}
{/* Existing WooCommerce card */}
{/* New ShopRenter card */}
<ShopRenterConnect />
</div>
crypto.timingSafeEqual)# .env for backend
SHOPRENTER_APP_ID=your_app_id_here
SHOPRENTER_CLIENT_ID=your_client_id_here
SHOPRENTER_CLIENT_SECRET=your_client_secret_here
⚠️ Never commit these to version control!
shopcall-test-storeshopcall-test-store.myshoprenter.huTest Case 1: Successful Installation
- Initiate OAuth from ShopRenter admin
- Verify HMAC validation passes
- Verify token exchange succeeds
- Verify credentials stored in database
- Verify redirect to success page
Test Case 2: Invalid HMAC
- Modify HMAC parameter
- Verify request rejected with 403
- Verify error logged
Test Case 3: Expired Timestamp
- Use timestamp older than 5 minutes
- Verify request rejected
- Verify error logged
Test Case 4: Uninstall Flow
- Trigger uninstall from ShopRenter
- Verify webhook received
- Verify store deactivated
- Verify tokens deactivated
Test Case 5: Product Sync
- Request product list from API
- Verify products cached in database
- Verify product data accuracy
Test Case 6: Token Refresh
- Wait for token expiry
- Make API request
- Verify token auto-refreshed
- Verify new token stored
Test Case 7: Webhook Processing
- Create test order in ShopRenter
- Verify webhook received
- Verify order data processed
- Verify response 200 sent
// test/shoprenter-integration.test.js
const axios = require('axios');
const crypto = require('crypto');
describe('ShopRenter Integration', () => {
const testShop = 'shopcall-test-store';
const clientSecret = process.env.SHOPRENTER_CLIENT_SECRET;
function generateValidHMAC(params) {
const sorted = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
return crypto
.createHmac('sha256', clientSecret)
.update(sorted)
.digest('hex');
}
it('should validate correct HMAC', async () => {
const params = {
shopname: testShop,
code: 'test123',
timestamp: Math.floor(Date.now() / 1000).toString()
};
params.hmac = generateValidHMAC(params);
const response = await axios.get('http://localhost:3000/auth/shoprenter/callback', {
params,
maxRedirects: 0,
validateStatus: () => true
});
expect(response.status).not.toBe(403);
});
it('should reject invalid HMAC', async () => {
const params = {
shopname: testShop,
code: 'test123',
timestamp: Math.floor(Date.now() / 1000).toString(),
hmac: 'invalid_hmac'
};
const response = await axios.get('http://localhost:3000/auth/shoprenter/callback', {
params,
maxRedirects: 0,
validateStatus: () => true
});
expect(response.status).toBe(403);
});
});
| Week | Milestone | Deliverable |
|---|---|---|
| 1 | Setup & Planning | Test store, HMAC validator |
| 2 | OAuth Implementation | Working OAuth flow |
| 3 | API Integration | Product sync, webhooks |
| 4 | Testing & QA | All tests passing |
| 5 | Registration | App submitted to ShopRenter |
| 6 | Production Launch | Live integration |
Total Estimated Time: 6 weeks
When new features require additional scopes:
https://{shopName}.myshoprenter.hu/admin/app/{clientId}/approveScopesDocument Version: 1.0 Last Updated: 2025-10-22 Author: ShopCall.ai Development Team Status: Ready for Implementation