🚀 NEW: Professional 4K EXR Alpha Matte Extraction for VFX is now LIVE in VFX Studio!
⚡ PixelAPI
Marketplace Product Photo Cleaner, background removal, image editing, and automation APIs for sellers and agencies.
Sign in with your Google account. 24-hour trial with up to 5,000 credits — explore every API. After that, plans start at $10/mo (top-up credits require an active plan).
Have a test account? Login with email ▾
✂️
Background Removal
Instant transparent PNG. $0.0025/image — lower-priced than PhotoRoom at $0.02.
🎨
AI Backgrounds
Replace backgrounds with AI lifestyle scenes from text prompts.
🔍
4x Upscaling
Enlarge images to 4x resolution with AI upscaling.
🖼️
Image Generation
Generate product photos and creatives with AI text-to-image & AI image generation.
👤
Face Restoration
Enhance blurry portraits and headshots with AI face restoration.
🧹
Object Removal
Remove unwanted objects and watermarks from photos.
🎬
VFX Alpha Mattes
Extract production-grade RGBA/Alpha sequences from 4K EXR AI V2V renders. NEW
Simple Pricing
Free
$0/mo
24h trial · up to 5,000 credits
Starter
$10/mo
10,000 credits/month
Pro
$50/mo
60,000 credits/month
Scale
$349/mo
300,000 credits/month
Dead Simple API
curl -X POST https://api.pixelapi.dev/v1/image/remove-background \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "image=@product.jpg"
# Returns transparent PNG in ~2 seconds
↑↓ navigate · ↵ open · ⌘K / Ctrl-K to toggle
PixelAPI Portrait Studio guarantees the output face matches your input. ArcFace similarity gate. No identity manipulation possible. This is a platform-wide commitment — not a setting.
Credits: 80 per portrait ($0.007 / ₹6.80) Speed: ~30-45 seconds Model: AI text-to-image Dev + identity-preserving AI v0.9.1 API:POST /v1/portrait/studio
Result
Upload a photo to get started
`;
}
async function handlePortraitSelect(input){
const file = input.files[0];
if(!file) return;
if(file.size > 10 * 1024 * 1024) return toast('Max 10MB','error');
await _acceptPortraitFile(file);
}
async function handlePortraitDrop(e){
e.preventDefault();
e.target.closest('.upload-area').classList.remove('dragover');
const file = e.dataTransfer.files[0];
if(!file) return toast('Please drop an image','error');
if(file.size > 10 * 1024 * 1024) return toast('Max 10MB','error');
await _acceptPortraitFile(file);
}
async function _acceptPortraitFile(file){
// Show detecting state
const fn = $('#portrait-filename');
fn.style.display = 'block';
fn.textContent = '🔍 Analysing photo… (detecting faces)';
let finalFile = file;
try{
const fd = new FormData(); fd.append('image', file);
const d = await apiUpload('/v1/image/detect-faces', fd);
const count = d.count || 0;
if(count === 0){
toast('No face detected. Try a clearer photo with a visible face.','error');
fn.textContent = '⚠ No face detected in ' + file.name;
$('#portrait-submit').disabled = true;
return;
}
// `selected` is the face object we chose — used for the auto-restore decision
let selected = null;
if(count === 1){
selected = d.faces[0];
finalFile = await _cropFileToBbox(file, selected.crop_x1, selected.crop_y1, selected.crop_x2, selected.crop_y2);
fn.textContent = '✓ 1 face detected · ' + file.name;
} else {
const picked = await new Promise(resolve => _showFacePicker(file, d.faces, resolve));
if(!picked || !picked.file){
fn.textContent = '(cancelled)';
$('#portrait-submit').disabled = true;
return;
}
finalFile = picked.file;
selected = picked.face;
fn.textContent = '✓ Face selected from group photo · ' + file.name;
}
// Smart auto-restore: if the face looks degraded, run AI face restoration first
if(selected && selected.suggest_restore){
fn.textContent = '✨ Enhancing photo for best portrait… (may take ~10s)';
try{
const enhanced = await _autoRestoreFace(finalFile);
if(enhanced){
finalFile = enhanced;
const reasons = [];
if(selected.is_grayscale) reasons.push('grayscale');
if(selected.blur_score < 100) reasons.push('low sharpness');
if(selected.face_pixel_size < 140) reasons.push('small face');
fn.textContent = '✨ Enhanced (' + reasons.join(', ') + ') · ' + file.name;
}
}catch(e){
console.warn('auto-restore failed, continuing with raw crop:', e);
fn.textContent = '✓ Face ready · ' + file.name;
}
}
}catch(e){
console.warn('face-detect failed, using file as-is', e);
fn.textContent = '📎 ' + file.name + ' (' + (file.size/1024).toFixed(0) + ' KB)';
}
state.portraitFile = finalFile;
$('#portrait-submit').disabled = false;
}
async function _autoRestoreFace(file){
const fd = new FormData(); fd.append('image', file);
const d = await apiUpload('/v1/image/restore-face', fd);
const gid = d.generation_id || d.id;
if(!gid) throw new Error('no generation_id from restore-face');
for(let i=0; i<22; i++){
await new Promise(x=>setTimeout(x, 2000));
const r = await api('GET', '/v1/image/' + gid);
if(r.status === 'completed' && r.output_url){
const url = absUrl(r.output_url);
const resp = await fetch(url);
const blob = await resp.blob();
return new File([blob], 'restored.png', {type: blob.type || 'image/png'});
}
if(r.status === 'failed' || r.status === 'blocked') throw new Error(r.error_message || 'restore failed');
}
throw new Error('restore timed out');
}
function _cropFileToBbox(file, x1, y1, x2, y2){
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const w = x2 - x1, h = y2 - y1;
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, x1, y1, w, h, 0, 0, w, h);
canvas.toBlob(blob => resolve(new File([blob], 'face_crop.jpg', {type:'image/jpeg'})), 'image/jpeg', 0.95);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
function _showFacePicker(file, faces, onSelect){
const html = '
' +
'
' +
'
' + faces.length + ' faces detected
' +
'
Click the face you want to use for the portrait. We\'ll crop + pass it to the generator automatically.
';
restoreBtn();
});
}
function handleFileSelect(input){
// record aspect ratio + alpha presence for the fit-mode warning UI
try {
const f = input.files && input.files[0];
if (f && f.type && f.type.startsWith('image/')) {
const _img = new Image();
_img.onload = () => {
_toolUploadedAspect = _img.naturalWidth / _img.naturalHeight;
// Detect alpha by drawing the corner pixels onto a canvas
try {
const c = document.createElement('canvas');
c.width = 4; c.height = 4;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, 4, 4);
ctx.drawImage(_img, 0, 0, 4, 4);
const px = ctx.getImageData(0, 0, 4, 4).data;
// alpha < 255 in ANY of 16 pixels → has_alpha
let hasAlpha = false;
for (let i = 3; i < px.length; i += 4) if (px[i] < 255) { hasAlpha = true; break; }
_toolUploadedHasAlpha = hasAlpha;
} catch (_e) { _toolUploadedHasAlpha = false; }
// Re-check warning in case user already typed dims
try { checkFitModeWarning(); } catch (_e) {}
};
_img.src = URL.createObjectURL(f);
}
} catch (e) {}
const file = input.files[0];
if(!file) return;
state.uploadedFile = file;
const preview = $('#upload-preview');
if(preview){
if(file.type && file.type.startsWith('image/')){
preview.src = URL.createObjectURL(file);
preview.style.display = 'block';
}else{
preview.removeAttribute('src');
preview.style.display = 'none';
}
}
const fn = $('#upload-filename');
fn.style.display = 'block';
fn.textContent = `📎 ${file.name} (${(file.size/1024).toFixed(0)} KB)`;
$('#tool-submit').disabled = false;
}
function handleDrop(e){
e.preventDefault();
e.target.closest('.upload-area').classList.remove('dragover');
const file = e.dataTransfer.files[0];
if(!file) return toast('Please drop a file','error');
state.uploadedFile = file;
const preview = $('#upload-preview');
if(preview){
if(file.type && file.type.startsWith('image/')){
preview.src = URL.createObjectURL(file);
preview.style.display = 'block';
}else{
preview.removeAttribute('src');
preview.style.display = 'none';
}
}
const fn = $('#upload-filename');
fn.style.display = 'block';
fn.textContent = `📎 ${file.name} (${(file.size/1024).toFixed(0)} KB)`;
$('#tool-submit').disabled = false;
}
async function submitTool(toolKey){
const tool = TOOL_CONFIG[toolKey];
if(!state.uploadedFile) return toast('Please upload a file first','error');
const maxMB = tool.accept && tool.accept.includes('video') ? 500 : 20;
if(state.uploadedFile.size > maxMB * 1024 * 1024) return toast('File too large (max ' + maxMB + 'MB). Please compress or trim your video.','error');
const btn = $('#tool-submit');
btn.disabled = true;
btn.textContent = 'Processing...';
const res = $('#tool-result');
res.innerHTML = '
Uploading and processing...
';
try{
// Remove-BG: web-only face-detect + picker gate. Kicks in only when ≥2
// human faces are detected; API clients bypass this entirely.
let _rmbgFaceBbox = null;
if(toolKey === 'remove-bg'){
try{
const fd0 = new FormData(); fd0.append('image', state.uploadedFile);
// inject extra fields (handles upscale custom-dims, etc.)
try { const _ex = collectExtraFields(toolKey); for (const k in _ex) fd.append(k, _ex[k]); } catch(e){}
const det = await apiUpload('/v1/image/detect-faces', fd0);
if(det && det.count >= 2 && Array.isArray(det.faces)){
const pick = await new Promise(resolve => _showRemoveBgFacePicker(det.faces, resolve));
if(pick === null){
btn.disabled=false; btn.textContent = `${tool.icon} Process · ${tool.credits} credits`;
res.innerHTML = '
Upload a file to get started
';
return;
}
if(pick && pick.face){
const f = pick.face;
_rmbgFaceBbox = `${f.x},${f.y},${f.x + f.w},${f.y + f.h}`;
}
// else: keepAll — fall through without face_bbox
}
}catch(_e){ /* detect failed — fall through to normal flow */ }
}
const form = new FormData();
form.append(tool.fileKey || 'image', state.uploadedFile);
if(_rmbgFaceBbox){ form.append('face_bbox', _rmbgFaceBbox); }
if(tool.needsPrompt){
const prompt = $('#tool-prompt')?.value?.trim();
if(!prompt) { toast('Please enter a prompt','error'); btn.disabled=false; btn.textContent=`${tool.icon} Process · ${tool.credits} credits`; res.innerHTML='
Upload a file to get started
'; return; }
form.append('prompt', prompt);
}
if(tool.needsMask){
const maskInput = $('#tool-mask-input');
if(maskInput && maskInput.files[0]) {
form.append('mask', maskInput.files[0]);
} else if(tool.maskRequired === false) {
// Mask is optional — auto-mask will be generated from prompt
} else {
toast('A mask image is required. Paint white over the area to remove.', 'error');
btn.disabled=false; btn.textContent=`${tool.icon} Process · ${tool.credits} credits`;
res.innerHTML='
Upload a file to get started
';
return;
}
}
// Append any extra fields (strength, model, etc.)
if(tool.extraFields){
for(const ef of tool.extraFields){
const el = $(`#tool-extra-${ef.name}`);
const val = el ? el.value : ef.default;
form.append(ef.name, val);
}
}
// Pre-submit ETA check (load_state-aware). For known heavy endpoints,
// surface estimated wait/queue position so the user knows what to expect.
try{
const adm = await checkAdmissionBeforeSubmit(tool.endpoint);
if(!adm.ok){
toast(adm.message, 'error');
btn.disabled=false;
btn.textContent = `${tool.icon} Process · ${tool.credits} credits`;
res.innerHTML = '
Server is busy — please retry shortly. No credits were charged.
';
return;
}
if(e.message && e.message.includes('Failed to fetch')){
const res = $('#tool-result');
res.innerHTML = '
❌ Upload Failed
The file may be too large. Maximum upload size is 500MB for videos. If upload fails, try a smaller file and 20MB for images. Try compressing or trimming your file.
Buy credits on demand — perfect for businesses with variable usage.
${!isIndia?'
Important: Credit packs add paid credits only — they do not activate a plan. Without an active subscription (minimum Starter at $10/mo) your credits stay locked: after the 24h trial you cannot use the API until you subscribe. If you are on the Free plan, subscribe first, then top up. To activate a plan choose Starter $10, Pro $50, or Scale $349.
':'
Important: Credit packs add paid credits only — they do not activate a plan or unlock API access. You must have an active subscription (minimum Starter at ₹500/mo) to use credits via API. If you are on the Free plan, subscribe first, then top up. Payments are non-refundable.
'}
${(isIndia?topups:topupsUSD).map(t=>`
${t.highlight?'
⭐ MOST POPULAR
':''}
${t.price}
${t.credits} credits
${t.bonus?`
🎁 ${t.bonus}
`:''}
`).join('')}
`:''}
Credit Costs per Operation
Operation
Credits
Cost ${isIndia?'(Starter ₹500)':'(Starter)'}
🎬 AI Video 480p (per sec)
17
${isIndia?'₹1.43':'$0.017'}
🎬 AI Video 720p (per sec)
25
${isIndia?'₹2.10':'$0.025'}
✂️ Background Removal
1
${isIndia?'₹0.08':'$0.001'}
🎨 Background Replace
3
${isIndia?'₹0.25':'$0.003'}
🖼️ Image Generation
1
${isIndia?'₹0.08':'$0.001'}
🔍 4x Upscaling
2
${isIndia?'₹0.17':'$0.002'}
👤 Face Restoration
5
${isIndia?'₹0.42':'$0.00105'}
🧹 Object Removal
5
${isIndia?'₹0.42':'$0.00105'}
🎵 AI Music
5
${isIndia?'₹0.42':'$0.00105'}
${showCancel?`
Want to cancel your subscription?
You keep your remaining credits after cancellation.
`:''}
Need a custom plan?
For high-volume usage, custom models, or enterprise features — contact us.
`;
const up=new URLSearchParams(window.location.search);
if(up.get('payment')==='success'){
// Google Ads purchase conversion (PayPal/PayU redirect flow fired nothing before 2026-07-27)
try{ if(window.gtag){ gtag('event','conversion',{
send_to:'AW-17925044948/NgEACP626PEbENT1qeNC',
transaction_id:(up.get('product')||up.get('plan')||'plan')+'_'+Date.now()
}); } }catch(e){}
const paidProduct=up.get('product')||up.get('plan')||'';
const planMap={starter:'starter',pro:'pro',scale:'scale',usd_10k:'starter',usd_50k:'pro'};
const effectivePlan=planMap[paidProduct]||'';
const topupOnly=/^usd_/.test(paidProduct)&&!effectivePlan;
if(topupOnly){
showPaymentMsg('🎉 Payment successful! Your credits have been added or will appear shortly. This was a credit top-up only — your plan remains unchanged, and Starter/Pro/Scale validity is not activated. Starter requires the listed $10 plan amount.','var(--green)');
history.replaceState(null,'','/app/');
setTimeout(()=>{loadAuth();refreshBalance();location.hash='dashboard';},1200);
}else{
const planName=effectivePlan||paidProduct||'plan';
showPaymentMsg('🎉 Payment successful! Your '+planName+' plan is now active.','var(--green)');
history.replaceState(null,'','/app/');
setTimeout(()=>showBillingModal(planName, ()=>{ location.hash='dashboard'; refreshBalance(); }), 600);
}
}
if(up.get('payment')==='cancelled'){
showPaymentMsg('Payment was cancelled. No charges were made.','var(--muted)');
history.replaceState(null,'','/app/#pricing');
}
}
function showPaymentMsg(text,color){const m=$('#payment-msg');if(m){m.style.display='block';m.style.background=color+'15';m.style.border='1px solid '+color;m.style.color=color;m.textContent=text}}
async function subscribePlan(planKey){
const btn=$('#btnpp-'+planKey)||$('#btn-'+planKey);
if(btn){btn.disabled=true;btn.textContent='Redirecting to PayPal...'}
try{
const d=await api('POST','/v1/payments/create-subscription',{plan:planKey});
if(d.approval_url){
toast('Redirecting to PayPal...','success');
window.location.href=d.approval_url;
} else {
throw new Error('No approval URL returned');
}
}catch(e){
showPaymentMsg('Error: '+e.message,'#ef4444');
if(btn){btn.disabled=false;btn.textContent='Pay with PayPal'}
}
}
async function cancelSubscription(){
if(!confirm('Are you sure you want to cancel your subscription? You will keep your current plan until the end of your billing period.'))return;
try{
const d=await api('POST','/v1/payments/cancel-subscription');
showPaymentMsg(d.message||'Subscription cancelled. You keep access until the end of your billing period.','var(--purple)');
setTimeout(()=>renderPricing(),1500);
}catch(e){showPaymentMsg('Error: '+e.message,'#ef4444')}
}
/* ── Razorpay (Indian payments) ── */
function topupNeedsSubWarning(productKey){
// Top-up packs add credits but do NOT activate a plan; post-trial free accounts
// cannot spend credits until subscribed (server gate trial_ended_subscribe).
const isTopup=/^(pack_|usd_(1k|5k)$)/.test(productKey);
const hasActivePlan=!!(state.plan&&state.plan!=='free');
if(!isTopup||hasActivePlan) return true;
return confirm('⚠️ Important — please read before paying\n\nYou do NOT have an active subscription. This purchase is a CREDIT TOP-UP only:\n\n• It will NOT activate a plan.\n• Your credits will stay LOCKED until you subscribe to a plan (Starter — $10\/mo or ₹500\/mo).\n• credits carry forward only when recharged before validity expires and unlock the moment you subscribe.\n\nRecommended: choose the Starter plan instead of this top-up.\n\nPress OK to pay anyway (credits stay locked until you subscribe), or Cancel to go back.');
}
async function razorpayPay(productKey, btnLabel){
if(!topupNeedsSubWarning(productKey)) return;
const btn=document.getElementById('btn-'+productKey);
if(btn){btn.disabled=true;btn.textContent='Processing...';}
try{
let d;
try{
d=await api('POST','/v1/razorpay/create-order',{product:productKey});
}catch(err){
const em=String(err&&err.message||'');
if(em.indexOf('TOPUP_NO_SUBSCRIPTION')<0) throw err;
const ok=await topupConsentFlow('You do not have an active subscription.');
if(!ok){ if(btn){btn.disabled=false;btn.textContent=btnLabel||'Pay with Razorpay';} return; }
d=await api('POST','/v1/razorpay/create-order',{product:productKey,topup_ack:true});
}
const options={
key:d.razorpay_key_id,
amount:d.amount,
currency:d.currency,
name:d.name,
description:d.description,
order_id:d.order_id,
prefill:{email:d.prefill_email||state.email},
theme:{color:'#635bff'},
handler:async function(response){
try{
const v=await api('POST','/v1/razorpay/verify-payment',{
razorpay_order_id:response.razorpay_order_id,
razorpay_payment_id:response.razorpay_payment_id,
razorpay_signature:response.razorpay_signature,
product:productKey,
});
// Google Ads + GA4 purchase conversion
try{ if(window.gtag){
const inrAmount = parseFloat(d.amount||0)/100;
gtag('event','purchase',{
transaction_id: response.razorpay_payment_id,
value: inrAmount,
currency: 'INR',
items: [{item_id: productKey, item_name: v.plan||productKey, price: inrAmount, quantity: 1}]
});
gtag('event','conversion',{
send_to:'AW-17925044948/NgEACP626PEbENT1qeNC',
value: inrAmount,
currency: 'INR',
transaction_id: response.razorpay_payment_id
});
}}catch(e){}
const msg=v.plan
?`🎉 ${v.plan.charAt(0).toUpperCase()+v.plan.slice(1)} plan activated! ${v.credits_added.toLocaleString()} credits added.`
:`🎉 ${v.credits_added.toLocaleString()} credits added to your account!`;
showPaymentMsg(msg,'var(--green)');
setTimeout(()=>{loadAuth();renderPricing()},2000);
}catch(e){showPaymentMsg('Verification failed: '+e.message+'. Contact support@pixelapi.dev','#ef4444')}
},
modal:{ondismiss:function(){if(btn){btn.disabled=false;btn.textContent=btnLabel||'Pay with Razorpay';}}},
};
const rzp=new Razorpay(options);
rzp.open();
}catch(e){
showPaymentMsg('Error: '+e.message,'#ef4444');
if(btn){btn.disabled=false;btn.textContent=btnLabel||'Pay with Razorpay';}
}
}
/* Ask for a mobile number once — Razorpay requires it on international
payment links (2026-08-07). Stored server-side so we never ask twice. */
function askPhoneForCheckout(){
return new Promise(res=>{
window.__phoneGate=res;
openModal('
'
+'
One quick detail
'
+'
Our card processor needs a mobile number to complete an international payment. We only use it for this transaction and your receipt.
'
+''
+''
+'
'
+''
+''
+'
');
setTimeout(()=>{
const i=document.getElementById('checkoutPhone'); if(i){i.focus();
i.addEventListener('keydown',e=>{if(e.key==='Enter')submitCheckoutPhone();});}
const cb=document.querySelector('#modal .modal-close');
if(cb)cb.onclick=()=>{closeModal();window.__phoneGate(null);};
},0);
});
}
function submitCheckoutPhone(){
const i=document.getElementById('checkoutPhone');
const e=document.getElementById('checkoutPhoneErr');
const v=(i&&i.value||'').trim();
const digits=v.replace(/[^0-9]/g,'');
if(digits.length<8||digits.length>15||new Set(digits).size<=2){
if(e)e.textContent='Please enter a valid mobile number with country code.';
return;
}
closeModal(); window.__phoneGate(v);
}
/* Top-up consent (Om 2026-08-07): credits bought without an active plan are
LOCKED until the user subscribes, so we must get an explicit 'I agree' before
taking money. Checkbox — not just a button — and the confirm stays disabled
until it is ticked. The server enforces the same rule (TOPUP_NO_SUBSCRIPTION),
this is the humane front end for it. */
function askTopupConsent(msg){
return new Promise(res=>{
window.__topupGate=res;
openModal('
'
+'
Before you pay — please read
'
+'
'+(msg||'')+'
'
+'
'
+'These credits will stay locked until you subscribe to a paid plan, and payments are non-refundable.
'
+''
+'
'
+''
+''
+''
+'
');
setTimeout(()=>{const cb=document.querySelector('#modal .modal-close');
if(cb)cb.onclick=()=>{closeModal();window.__topupGate(false);};},0);
});
}
async function topupConsentFlow(msg){
const r=await askTopupConsent(msg);
if(r==='subscribe'){ navigate('upgrade'); return false; }
return r===true;
}
/* ── Razorpay (International — USD credit/debit card) ── */
async function razorpayPayUSD(productKey, btnLabel){
if(!topupNeedsSubWarning(productKey)) return;
// International cards: use hosted Razorpay Payment Link (avoids inline
// Checkout Click-to-Pay/SRC "DPA entity data not found" failures).
const btn=document.getElementById('btn-'+productKey);
if(btn){btn.disabled=true;btn.textContent='Processing...';}
try{
// 2026-08-07: Razorpay now requires a mobile number on international payment
// links. Ask once (we store it), then retry — never dead-end the customer.
let d;
try{
d=await api('POST','/v1/razorpay/create-payment-link-usd',{product:productKey});
}catch(err){
const _em=String(err&&err.message||'');
if(_em.indexOf('TOPUP_NO_SUBSCRIPTION')>=0){
const ok=await topupConsentFlow('You do not have an active subscription.');
if(!ok){ if(btn){btn.disabled=false;btn.textContent=btnLabel||'Pay with Card';} return; }
try{
d=await api('POST','/v1/razorpay/create-payment-link-usd',{product:productKey,topup_ack:true});
}catch(err2){
if(!String(err2&&err2.message||'').includes('PHONE_REQUIRED')) throw err2;
const phone=await askPhoneForCheckout();
if(!phone){ if(btn){btn.disabled=false;btn.textContent=btnLabel||'Pay with Card';} return; }
d=await api('POST','/v1/razorpay/create-payment-link-usd',{product:productKey,contact:phone,topup_ack:true});
}
}
else if(!_em.includes('PHONE_REQUIRED')) throw err;
else {
const phone=await askPhoneForCheckout();
if(!phone){ if(btn){btn.disabled=false;btn.textContent=btnLabel||'Pay with Card';} return; }
try{
d=await api('POST','/v1/razorpay/create-payment-link-usd',{product:productKey,contact:phone});
}catch(err3){
if(String(err3&&err3.message||'').indexOf('TOPUP_NO_SUBSCRIPTION')<0) throw err3;
const ok=await topupConsentFlow('You do not have an active subscription.');
if(!ok){ if(btn){btn.disabled=false;btn.textContent=btnLabel||'Pay with Card';} return; }
d=await api('POST','/v1/razorpay/create-payment-link-usd',{product:productKey,contact:phone,topup_ack:true});
}
}
}
if(!d||!d.short_url){throw new Error('Could not create payment link');}
// GA4 begin_checkout (best-effort) before redirect
try{ if(window.gtag){
const usdAmount = parseFloat(d.amount_display||0);
gtag('event','begin_checkout',{
value: usdAmount, currency: 'USD',
items: [{item_id: productKey, item_name: productKey, price: usdAmount, quantity: 1}]
});
}}catch(e){}
// Hand off to the hosted Razorpay page. On success Razorpay redirects back
// to /app/?payment=success&plan=; crediting is done server-side by
// the payment.captured webhook (link notes carry user_id/product).
showPaymentMsg('Redirecting to secure Razorpay checkout…','var(--green)');
window.location.assign(d.short_url);
}catch(e){
showPaymentMsg('Error: '+e.message,'#ef4444');
if(btn){btn.disabled=false;btn.textContent=btnLabel||'Pay with Card';}
}
}
/* ── Settings ── */
async function renderSettings(){
let subInfo = { subscription_status: 'none', plan_expires_at: null };
try { subInfo = await api('GET', '/v1/payments/subscription-status'); } catch(e) {}
let billingInfo = {};
try { billingInfo = await api('GET', '/v1/account/billing'); } catch(e) {}
const isActive = subInfo.subscription_status === 'active';
const isCancelled = subInfo.subscription_status === 'cancelled';
const expiresAt = subInfo.plan_expires_at ? new Date(subInfo.plan_expires_at).toLocaleDateString('en-US', {month:'long',day:'numeric',year:'numeric'}) : null;
let subSection = '';
if (isActive) {
subSection = `