| name | violetconnect-shopify |
| description | Shopify OAuth integration patterns for VioletConnect merchant onboarding |
VioletConnect: Shopify Integration
Overview
VioletConnect integrates with Shopify via centralized OAuth 2.0 for merchant onboarding. This skill documents Shopify-specific OAuth flows, pre-registration patterns, and implementation details.
Integration Type: Centralized OAuth 2.0
Store Identifier: Store URL (*.myshopify.com)
Credential Storage: access_token (long-lived, no refresh)
Pre-Registration Support: All flows (A, B, C)
Related Skills:
- violetconnect: Core VioletConnect features and architecture
- violetconnect-patterns: Code patterns (Redux, axiosWrapper, AppContext)
- v-shopify: General Shopify API patterns and integration details
Shopify OAuth Flow
Standard Flow (No Pre-Registration)
1. Merchant lands on VioletConnect: connect.violet.io/{appAlias}/platforms/shopify
2. Merchant enters email → Creates session
3. Merchant enters store URL (e.g., "mystore.myshopify.com")
4. VioletConnect validates store URL format
5. VioletConnect redirects to Shopify OAuth:
- URL: https://{store}.myshopify.com/admin/oauth/authorize
- Params: client_id, scope, redirect_uri, state
6. Merchant approves app in Shopify admin
7. Shopify redirects back to VioletConnect callback:
- URL: connect.violet.io/{appAlias}/platforms/shopify/callback
- Params: code, shop, state
8. VioletConnect exchanges code for access_token (server-side)
9. VioletConnect creates merchant record in MerchantService
10. Merchant completes commission/payout setup
11. Merchant redirected to success page
With Pre-Registration (Flow A)
Flow A: Direct Install Link - Shopify-specific pattern
1. Channel pre-registers merchant with Shopify credentials
2. Channel sends Shopify install link directly to merchant:
- URL: https://{store}.myshopify.com/admin/oauth/authorize?client_id={id}&redirect_uri={uri}
3. Merchant clicks install link (bypasses VioletConnect)
4. Merchant approves in Shopify admin
5. Shopify redirects to VioletConnect callback with code
6. VioletConnect detects no active session
7. VioletConnect looks up pre-registration by store_url (from state)
8. If found: Resume flow with pre-stored credentials
9. If not found: Error (no session)
Key Point: Flow A is Shopify-only - Shopify allows direct install links that bypass VioletConnect email/store URL entry.
Shopify OAuth Implementation Details
Dynamic Routing
Page Files:
pages/[appAlias]/platforms/shopify/index.tsx
pages/[appAlias]/platforms/shopify/callback.tsx
Key Utility Files:
OAuth Parameters
interface ShopifyOAuthParams {
client_id: string;
scope: string;
redirect_uri: string;
state: string;
grant_options?: string[];
}
Example OAuth URL:
https://mystore.myshopify.com/admin/oauth/authorize
?client_id=abc123
&scope=read_products,write_orders,read_customers
&redirect_uri=https://connect.violet.io/andydev/platforms/shopify/callback
&state=csrf_token_session_id
Access Token Exchange
Server-Side Only (pages/api/shopify/token-exchange.ts):
POST https:
Body: {
client_id: string,
client_secret: string,
code: string
}
Response: {
access_token: string,
scope: string
}
Implementation Pattern:
import { NextApiRequest, NextApiResponse } from 'next';
import axios from 'axios';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code, shop } = req.body;
try {
const response = await axios.post(
`https://${shop}/admin/oauth/access_token`,
{
client_id: process.env.SHOPIFY_CLIENT_ID,
client_secret: process.env.SHOPIFY_CLIENT_SECRET,
code
}
);
const { access_token, scope } = response.data;
const merchant = await createMerchant({
platform: 'shopify',
storeUrl: shop,
accessToken: access_token,
scope
});
res.status(200).json({ merchantId: merchant.id });
} catch (: ) {
res.(error.?. || ).({
: error.?. ||
});
}
}
Store URL Validation
Required Format
Shopify store URLs must match: *.myshopify.com
Validation Function:
export function validateShopifyStoreUrl(url: string): boolean {
const cleanUrl = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
const shopifyPattern = /^[a-z0-9-]+\.myshopify\.com$/i;
return shopifyPattern.test(cleanUrl);
}
Handling Custom Domains:
- Shopify Plus merchants may have custom domains
- Accept custom domains, but verify via Shopify API
- Store canonical
*.myshopify.com URL in database
Development Stores:
- Development stores use same
*.myshopify.com format
- No special handling needed
Pre-Registration Integration
Shopify supports all three pre-registration flows:
Flow A: Direct Install Link (Shopify-Only)
Implementation:
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useDispatch } from 'react-redux';
import { lookupByStoreUrl } from '@/redux/slices/preRegistration';
function ShopifyCallback() {
const router = useRouter();
const dispatch = useDispatch();
const { code, shop, state } = router.query;
useEffect(() => {
if (code && shop) {
handleOAuthCallback();
}
}, [code, shop]);
const handleOAuthCallback = async () => {
const sessionValid = await checkSession();
if (!sessionValid) {
const preReg = await dispatch(lookupByStoreUrl({
storeUrl: shop as string,
appId: getAppIdFromState(state as string)
})).();
(preReg.) {
(code, shop, preReg.);
} {
router.();
}
} {
(code, shop);
}
};
;
}
Flow B: Store URL Lookup
Implementation:
import { useDispatch, useSelector } from 'react-redux';
import { lookupByStoreUrl } from '@/redux/slices/preRegistration';
function ShopifyUrlPage() {
const dispatch = useDispatch();
const { current, found } = useSelector((state) => state.preRegistration);
const { appId } = useContext(AppContext);
const onStoreUrlSubmit = async (data: { storeUrl: string }) => {
if (!validateShopifyStoreUrl(data.storeUrl)) {
setError('Must be a valid Shopify store URL (*.myshopify.com)');
return;
}
const result = await dispatch(lookupByStoreUrl({
storeUrl: data.storeUrl,
appId
})).unwrap();
if (result.found) {
return;
} else {
(data.);
}
};
(found && current) {
(
);
}
;
}
Flow C: MerchantId Link
Implementation: Handled by AppContext (see violetconnect-patterns skill)
Shopify-Specific Note: InstallLink in pre-registration must be full Shopify OAuth URL:
https://{store}.myshopify.com/admin/oauth/authorize?client_id={id}&redirect_uri={uri}&state={state}
Shopify Components
StoreUrlInput Component
interface StoreUrlInputProps {
onSubmit: (storeUrl: string) => void;
loading?: boolean;
}
const StoreUrlInput = ({ onSubmit, loading }: StoreUrlInputProps) => {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: yupResolver(shopifyStoreUrlSchema)
});
return (
<form onSubmit={handleSubmit(({ storeUrl }) => onSubmit(storeUrl))}>
<label>Shopify Store URL</label>
<input
{...register('storeUrl')}
placeholder="mystore.myshopify.com"
className={styles.input}
/>
{errors.storeUrl && (
<span className={styles.error}>{errors.storeUrl.message}</span>
)}
<VioletButton
type="submit"
text="Continue"
loading={loading}
=
/>
Your store URL is found in your Shopify admin URL.
);
};
shopifyStoreUrlSchema = yup.({
: yup.()
.()
.(, , {
(value || );
})
});
OAuthRedirect Component
interface OAuthRedirectProps {
storeUrl: string;
clientId: string;
redirectUri: string;
state: string;
scope: string;
}
const OAuthRedirect = ({ storeUrl, clientId, redirectUri, state, scope }: OAuthRedirectProps) => {
useEffect(() => {
const oauthUrl = constructShopifyOAuthUrl({
storeUrl,
clientId,
redirectUri,
state,
scope
});
window.location.href = oauthUrl;
}, []);
return (
<div className={styles.redirecting}>
<Spinner />
<p>Redirecting to Shopify...</p>
</div>
);
};
function constructShopifyOAuthUrl({ storeUrl, clientId, redirectUri, state, scope }: OAuthRedirectProps) {
const cleanStore = storeUrl.replace(/^https?:\/\//, '').replace(, );
params = ({
: clientId,
scope,
: redirectUri,
state
});
;
}
CallbackHandler Component
const CallbackHandler = () => {
const router = useRouter();
const { code, shop, state } = router.query;
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (router.query.error) {
setError(router.query.error as string);
return;
}
if (code && shop && state) {
handleCallback();
}
}, [code, shop, state]);
const handleCallback = async () => {
try {
if (!validateState(state as string)) {
throw new Error('Invalid state parameter');
}
const response = await axiosWrapper({
url: '/api/shopify/token-exchange',
method: 'POST',
: { code, shop, state }
});
router.();
} (: ) {
(err. || );
}
};
(error) {
;
}
(
);
};
Error Handling
Shopify-Specific Errors
| Error | Cause | VioletConnect Handling |
|---|
| Invalid store URL | Wrong format | Show inline error: "Must be *.myshopify.com" |
| Store not found | Store doesn't exist | Shopify returns error on OAuth redirect |
| OAuth denied | Merchant clicks "Cancel" | Callback receives error=access_denied |
| Invalid credentials | Wrong client_id/secret | Token exchange fails, show error |
| Scope mismatch | Requested scope not granted | Check returned scope, warn merchant |
Error Component:
interface OAuthErrorProps {
error: string;
onRetry?: () => void;
}
const OAuthError = ({ error, onRetry }: OAuthErrorProps) => {
const errorMessages: Record<string, string> = {
'access_denied': 'You declined to install the app. Please try again to connect your store.',
'invalid_request': 'Invalid OAuth request. Please contact support.',
'session_expired': 'Your session has expired. Please start over.'
};
return (
<div className={styles.errorContainer}>
<AlertIcon className={styles.errorIcon} />
<h2>Connection Failed</h2>
<p>{errorMessages[error] || 'An error occurred during setup.'}</p>
{onRetry && (
<VioletButton
text="Try Again"
onClick={onRetry}
/>
)}
</div>
);
};
Testing
Local Development Setup
SHOPIFY_CLIENT_ID=your_client_id
SHOPIFY_CLIENT_SECRET=your_client_secret
SHOPIFY_SCOPES=read_products,write_orders,read_customers
TEST_SHOPIFY_STORE=mytest-store.myshopify.com
E2E Testing
import { test, expect } from '@playwright/test';
test.describe('Shopify OAuth Flow', () => {
test('should complete OAuth flow successfully', async ({ page }) => {
await page.goto('http://localhost:3001/testchannel/platforms/shopify');
await page.fill('input[name="email"]', 'test@example.com');
await page.click('button[type="submit"]');
await page.fill('input[name="storeUrl"]', 'mytest-store.myshopify.com');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/myshopify\.com\/admin\/oauth\/authorize/);
});
test('should validate store URL format', async ({ page }) => {
await page.goto('http://localhost:3001/testchannel/platforms/shopify');
await page.fill('input[name="email"]', 'test@example.com');
page.();
page.(, );
page.();
(page.()).();
});
});
Unit Testing
import { validateShopifyStoreUrl } from '@/utils/shopify/validation';
describe('validateShopifyStoreUrl', () => {
it('should accept valid myshopify.com URLs', () => {
expect(validateShopifyStoreUrl('mystore.myshopify.com')).toBe(true);
expect(validateShopifyStoreUrl('https://mystore.myshopify.com')).toBe(true);
expect(validateShopifyStoreUrl('https://mystore.myshopify.com/')).toBe(true);
});
it('should reject invalid URLs', () => {
expect(validateShopifyStoreUrl('mystore.com')).toBe(false);
expect(validateShopifyStoreUrl('mystore.shopify.com')).toBe(false);
expect(validateShopifyStoreUrl('invalid')).toBe(false);
});
});
Configuration
Environment Variables
SHOPIFY_CLIENT_ID=abc123456
SHOPIFY_CLIENT_SECRET=xyz789012
SHOPIFY_SCOPES=read_products,write_orders,read_customers,read_fulfillments
SHOPIFY_REDIRECT_URI=https://connect.violet.io/{appAlias}/platforms/shopify/callback
SHOPIFY_API_VERSION=2024-01
Channel-Specific Configuration
interface ChannelConfig {
appId: number;
appAlias: string;
supportedPlatforms: ['shopify', ...];
shopifyClientId: string;
shopifyClientSecret: string;
shopifyScopes: string;
}
Note: Each channel can have its own Shopify app with different scopes.
Related Documentation
- Core VioletConnect:
violetconnect skill
- Code Patterns:
violetconnect-patterns skill
- Shopify API:
v-shopify plugin (general Shopify integration patterns)
- Pre-Registration Spec:
prism-brain/product/specs/merchant-pre-registration/
- Shopify Integration Brain:
ecom-integrations-brain/integrations/shopify/