# Affiliate Postback URLs Source: https://docs.elasticfunnels.io/affiliates/postback-urls Send signed JSON postbacks to your affiliates' endpoints on commission-relevant lifecycle events. ElasticFunnels can POST a small, **standardized JSON payload** to a URL on your affiliate's side every time a commission-relevant event happens — independent of which gateway processed the order. This is the *outbound* side of affiliate tracking. The payload is intentionally **lean**: it contains only what an affiliate needs to reconcile a conversion in their own system. **Customer details, billing addresses, and product line items are deliberately excluded.** If you need rich gateway-aware payloads (with customer info, products, etc.) for your *own* systems, use the [Merchant Postback URLs](/merchants/postback-urls) instead. ## How it differs from the legacy macro postback Affiliates have an existing single-URL **GET** mechanism with macros like `{subid}`, `{amount}`, `{txn_id}` — that one is unchanged and still works. The system documented here is **additive**: | | Legacy `postback_url` | Outbound Postback URLs (this page) | | - | - | - | | Method | `GET` with query macros | `POST` with JSON body | | URLs | One per affiliate | One per **event** | | Auth | None | HMAC `X-EF-Signature` (+ optional Bearer token) | | Retries | Best-effort, no log | Queued + exponential backoff + delivery log | | Events | Purchase only | Purchase, refund, chargeback, renewal, cancel | | Affiliate types | `affcenter` only | All affiliate types | You can use either, both, or neither. ## Configuration In **Affiliates → Edit affiliate → Postback URLs** you'll find: 1. **HMAC Signing Secret** — auto-generated per affiliate. Used to verify each request. Keep it secret. You can rotate it at any time; old endpoints will start failing signature checks until they pick up the new secret. 2. **Per-event Postback URLs** — one row per supported event. Each row has: * `URL` — `https://example.com/webhooks/affiliate-purchase` * `Bearer Token (optional)` — sent as `Authorization: Bearer …` if set * `Enabled` — toggle without losing the URL 3. **Send test** — fires a synthetic payload (with `"test": true`) for that event. 4. **Recent Deliveries** — log of every attempt with HTTP status, payload, response body, and a manual retry button. ## Supported events | Event key | When it fires | | - | - | | `purchase` | A purchase conversion is approved for this affiliate. | | `refund` | An order or order line is refunded. | | `chargeback` | A chargeback is recorded against the order. | | `subscription_renewal` | A recurring subscription is successfully billed. | | `subscription_renewal_failed` | A recurring billing attempt fails. | | `subscription_cancel` | The subscription is cancelled or stopped. | Affiliate postbacks intentionally do **not** include `shipped`, `abandon`, or `failed_payment` — those aren't commission-bearing events. ## Request format ### HTTP ``` POST https://your-endpoint.example.com/webhooks/affiliate-purchase Content-Type: application/json User-Agent: ElasticFunnels-AffiliatePostback/1.0 X-EF-Event: purchase X-EF-Delivery-Id: 01HXYZ... X-EF-Timestamp: 1735689600 X-EF-Signature: sha256= X-EF-Brand-Id: 42 X-EF-Affiliate-Id: 123 Authorization: Bearer ``` ### Signature verification The signature is `HMAC_SHA256(secret, ".")`, hex-encoded and prefixed with `sha256=`. Always verify both the signature **and** the timestamp (reject anything older than \~5 minutes to defeat replays). ```php PHP theme={null} $secret = getenv('EF_AFFILIATE_POSTBACK_SECRET'); $payload = file_get_contents('php://input'); $timestamp = $_SERVER['HTTP_X_EF_TIMESTAMP'] ?? ''; $received = $_SERVER['HTTP_X_EF_SIGNATURE'] ?? ''; if (abs(time() - (int)$timestamp) > 300) { http_response_code(400); exit('Timestamp too old'); } $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $payload, $secret); if (!hash_equals($expected, $received)) { http_response_code(401); exit('Bad signature'); } $event = json_decode($payload, true); // process $event... http_response_code(200); echo 'ok'; ``` ```javascript Node.js (Express) theme={null} import crypto from 'node:crypto'; app.post('/webhooks/affiliate', express.raw({ type: 'application/json' }), (req, res) => { const secret = process.env.EF_AFFILIATE_POSTBACK_SECRET; const ts = req.get('X-EF-Timestamp') || ''; const sig = req.get('X-EF-Signature') || ''; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) { return res.status(400).send('Timestamp too old'); } const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(`${ts}.${req.body.toString()}`) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) { return res.status(401).send('Bad signature'); } const event = JSON.parse(req.body.toString()); // process event... res.sendStatus(200); }); ``` ```python Python theme={null} import hmac, hashlib, time, json from flask import Flask, request, abort SECRET = os.environ['EF_AFFILIATE_POSTBACK_SECRET'].encode() @app.post('/webhooks/affiliate') def webhook(): body = request.get_data() ts = request.headers.get('X-EF-Timestamp', '') sig = request.headers.get('X-EF-Signature', '') if abs(time.time() - int(ts)) > 300: abort(400, 'Timestamp too old') expected = 'sha256=' + hmac.new(SECRET, f'{ts}.'.encode() + body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, sig): abort(401, 'Bad signature') event = json.loads(body) # process event... return '', 200 ``` ## Response handling Return any **2xx** response to acknowledge delivery. Anything else (timeouts, non-2xx, network errors) is recorded as a failure and retried with exponential backoff at **1m, 5m, 30m, 2h, 12h** (5 attempts total) before being marked `dead`. Dead deliveries can be manually retried from the Recent Deliveries log. ## Payload schema Every event uses the same envelope. Numeric amounts may be **negative** for clawback events (`refund`, `chargeback`, `subscription_renewal_failed`). ```json theme={null} { "event": "purchase", "event_id": "ef_aff_evt_01HXYZABC...", "occurred_at": "2026-04-18T15:32:01+00:00", "test": false, "brand_id": 42, "affiliate": { "id": 123, "type": "affcenter", "merchant_account_id": 8801, "default_subid": "spring_promo" }, "offer": { "funnel_id": 17, "funnel_code": "vip-funnel", "name": "VIP Funnel" }, "amount": 49.00, "currency": "USD", "commission": 12.00, "transaction": { "order_id": "GW-ORDER-1001", "public_order_id": "EF-1001", "transaction_id": "ch_3PXyz", "conversion_code": "cv_01HXYZ...", "original_conversion_code": null, "type": "purchase", "status": "approved", "is_subscription": false }, "tracking": { "click_code": "ck_AbCdEf", "session_id": "sess_01HXY...", "subid": "campaign_a", "subid2": "creative_1", "subid3": null, "subid4": null, "subid5": null, "utm_source": "facebook", "utm_medium": "paid", "utm_campaign": "spring_2026", "utm_term": null, "utm_content": "ad_set_3", "gclid": null, "fbclid": "fb.1.1700000000.AbCdEf", "vtid": null } } ``` ### Field reference | Field | Notes | | - | - | | `event` | One of the supported event keys above. | | `event_id` | Unique, prefixed `ef_aff_evt_…`. Use it to dedupe on your side. | | `occurred_at` | ISO-8601, server time of the underlying conversion event. | | `test` | `true` for "Send test" payloads, `false` for live events. | | `affiliate.*` | Identifies the affiliate the conversion is attributed to. | | `offer.name` | The funnel title — typically what you see as your "offer name". | | `offer.funnel_code` | Stable, URL-safe slug of the funnel. | | `amount` | Order amount in the currency below. Negative for clawbacks. | | `commission` | The affiliate's calculated commission for this event. | | `transaction.transaction_id` | Gateway transaction id when available. | | `transaction.conversion_code` | ElasticFunnels conversion identifier. | | `transaction.original_conversion_code` | For refunds/chargebacks: the original purchase's conversion code. | | `tracking.subid` … `subid5` | Affiliate-defined subids captured from the click. | | `tracking.click_code` | EF's click identifier; correlate with your own click logs. | ### What's deliberately **not** in the payload * Customer name, email, phone, address, IP — affiliates don't need PII to reconcile. * Product-level details (SKUs, line items, shipping/billing). * Anything specific to the underlying gateway. If you need that data for your own systems, use the [Merchant Postback URLs](/merchants/postback-urls) — those are signed by the merchant secret and contain the full conversion record. ## Operational details * Deliveries are processed asynchronously. * Failed deliveries are retried automatically after their backoff window has elapsed. * The Recent Deliveries section in the affiliate form shows delivery attempts and outcomes. * Rotating the signing secret immediately invalidates the old one. Coordinate the change with your affiliate before rotating in production. ## Testing locally Point a URL at [webhook.site](https://webhook.site) or `ngrok`, hit **Send test** for the event you want, and you'll see the payload + headers arrive within seconds. # Ad Spend Metrics Source: https://docs.elasticfunnels.io/analytics/ad-spend-metrics Track advertising performance, costs, and return on investment across all your marketing campaigns ## At a glance **Quick Summary**: Ad spend metrics help you measure the effectiveness and profitability of your advertising campaigns. These metrics show how much you're spending, what you're getting in return, and where to optimize your budget. **Most Important**: Return on Ad Spend (ROAS), Cost Per Acquisition (CPA), and Cost Per Click (CPC) are your core advertising performance indicators. **Bottom Line**: Profitable advertising requires ROAS above 3:1 minimum, with 4:1+ being good performance. Monitor these metrics to optimize budget allocation and campaign performance. *** ## Core Ad Spend Metrics ### Return on Ad Spend (ROAS) **What it is**: Revenue generated for every dollar spent on advertising **How it's calculated**: Revenue ÷ Ad Spend **Example**: $5,000 revenue from $1,000 ad spend = 5:1 ROAS **Why it's crucial**: * Shows direct profitability of ad campaigns * Helps determine budget allocation * Compares performance across different channels * Guides scaling decisions **Benchmarks**: * 3:1 ROAS: Break-even point (minimum) * 4:1 ROAS: Good performance * 6:1+ ROAS: Excellent performance * 8:1+ ROAS: Outstanding performance **Industry variations**: * E-commerce: 4:1 - 6:1 typical * SaaS: 3:1 - 5:1 typical * High-ticket items: 2:1 - 4:1 acceptable * Low-margin products: 6:1+ needed *** ### Cost Per Acquisition (CPA) **What it is**: How much you pay to acquire one customer through advertising **How it's calculated**: Total Ad Spend ÷ Number of Customers Acquired **Example**: $1,000 ad spend, 50 new customers = $20 CPA **Why it matters**: * Must be significantly less than customer lifetime value * Shows efficiency of your acquisition funnel * Helps set profitable bid limits * Guides budget allocation decisions **Target guidelines**: * CPA should be 1/3 to 1/5 of customer lifetime value * Lower CPA allows for more aggressive scaling * Track CPA trends over time for optimization **Ways to improve**: * Improve landing page conversion rates * Refine audience targeting * Test different ad creatives * Optimize bidding strategies *** ### Cost Per Click (CPC) **What it is**: Average amount you pay for each click on your ads **How it's calculated**: Total Ad Spend ÷ Total Clicks **Example**: $500 ad spend, 1,000 clicks = $0.50 CPC **Why it's important**: * Shows competitiveness of your market * Indicates ad relevance and quality * Helps forecast campaign costs * Compares efficiency across campaigns **Factors affecting CPC**: * Industry competition * Ad quality and relevance * Targeting specificity * Bidding strategy * Time of day/seasonality **Optimization tactics**: * Improve ad quality scores * Use negative keywords * Refine audience targeting * Test different bidding strategies * Optimize ad scheduling *** ## Advanced Ad Spend Metrics ### Customer Acquisition Cost (CAC) **What it is**: Total cost to acquire one customer (includes all marketing expenses) **How it's calculated**: Total Marketing Spend ÷ Number of New Customers **Example**: $2,000 total marketing spend, 100 new customers = $20 CAC **Difference from CPA**: CAC includes all marketing costs, not just paid ads **What it includes**: * Includes organic marketing efforts * Shows true cost of customer acquisition * Better for long-term planning * More accurate for ROI calculations **Components included**: * Paid advertising spend * Content creation costs * Marketing team salaries * Marketing tools and software * Agency fees *** ### Click-Through Rate (CTR) **What it is**: Percentage of people who click your ads after seeing them **How it's calculated**: (Clicks ÷ Impressions) × 100 **Example**: 1,000 clicks from 50,000 impressions = 2% CTR **Why it matters**: * Shows ad relevance and appeal * Affects ad platform quality scores * Influences CPC and ad delivery * Indicates audience targeting accuracy **Good benchmarks by platform**: * Google Ads: 2-3% average * Facebook Ads: 1-2% average * LinkedIn Ads: 0.4-0.6% average * Display ads: 0.5-1% average **Improvement strategies**: * Test compelling headlines * Use strong call-to-actions * Improve visual elements * Refine audience targeting * A/B test ad copy variations *** ### Cost Per Thousand Impressions (CPM) **What it is**: Cost to show your ad to 1,000 people **How it's calculated**: (Ad Spend ÷ Impressions) × 1,000 **Example**: $100 spend for 50,000 impressions = $2 CPM **When to focus on CPM**: * Brand awareness campaigns * Broad reach objectives * Video view campaigns * Top-of-funnel marketing **Factors affecting CPM**: * Audience size and competition * Ad placement and format * Seasonality and timing * Geographic targeting * Industry and niche *** ## Platform-Specific Metrics ### Google Ads Metrics **Quality Score**: * Measures ad relevance and landing page experience * Affects CPC and ad positioning * Scale of 1-10 (higher is better) * Improves with better CTR and relevance **Search Impression Share**: * Percentage of available impressions your ads received * Shows potential for increased visibility * Lost impression share indicates missed opportunities **Average Position** (Legacy): * Historical metric showing ad placement * Replaced by impression share metrics * Still useful for understanding competition *** ### Facebook/Meta Ads Metrics **Cost Per Result**: * Platform-specific cost per desired action * Varies by campaign objective * More relevant than generic CPC for optimization **Frequency**: * Average number of times each person saw your ad * High frequency can lead to ad fatigue * Optimal range: 1.5-3 for most campaigns **Relevance Score** (Legacy): * Replaced by quality ranking, engagement rate ranking, and conversion rate ranking * Indicates ad performance relative to competition *** ### LinkedIn Ads Metrics **Social Actions**: * Likes, comments, shares on your ads * Indicates engagement and content quality * Can improve organic reach **Demographic Performance**: * Detailed breakdowns by job title, company size, industry * Helps refine B2B targeting * Shows which segments perform best *** ## Budget Management Metrics ### Daily Budget Utilization **What it is**: Percentage of daily budget actually spent **How to calculate**: (Actual Daily Spend ÷ Daily Budget) × 100 **Why it matters**: * Shows if budgets are constraining performance * Indicates bid competitiveness * Helps identify scaling opportunities **Optimization actions**: * Increase budgets for underutilized high-performers * Adjust bids for better budget utilization * Expand targeting for more volume *** ### Budget Allocation Efficiency **What it is**: How effectively budget is distributed across campaigns **Key considerations**: * Allocate more budget to high-ROAS campaigns * Reduce spend on underperforming segments * Test budget increases on promising campaigns * Maintain minimum budgets for statistical significance *** ### Lifetime Budget Pacing **What it is**: Rate at which lifetime budgets are being consumed **Why it's important**: * Prevents budget exhaustion too early * Ensures consistent ad delivery * Helps plan campaign duration *** ## Attribution and Tracking ### Attribution Models **First-Click Attribution**: * Credits first touchpoint with conversion * Good for understanding awareness drivers * May undervalue nurturing touchpoints **Last-Click Attribution**: * Credits final touchpoint before conversion * Simple but may miss influence of earlier ads * Default in many platforms **Multi-Touch Attribution**: * Distributes credit across multiple touchpoints * More accurate for complex customer journeys * Requires advanced tracking setup *** ### Conversion Tracking Setup **Essential tracking**: * Purchase/conversion events * Conversion values * Cross-device tracking * Offline conversion imports **Advanced tracking**: * View-through conversions * Assisted conversions * Customer lifetime value * Return customer identification *** ## Optimization Strategies ### Campaign-Level Optimization **Budget reallocation**: * Move budget from low-ROAS to high-ROAS campaigns * Pause underperforming campaigns * Scale successful campaigns gradually * Test new campaign types **Bidding optimization**: * Use automated bidding for efficiency * Set appropriate target ROAS/CPA * Adjust bids based on performance data * Consider dayparting for better timing *** ### Ad Group/Ad Set Optimization **Audience refinement**: * Exclude converting audiences from prospecting * Create lookalike audiences from best customers * Use negative audiences to reduce waste * Test audience expansion options **Creative optimization**: * Rotate ad creatives regularly * Test different value propositions * Use dynamic creative optimization * Analyze creative performance data *** ### Keyword and Targeting Optimization **Search campaigns**: * Add negative keywords regularly * Expand successful keyword themes * Adjust match types based on performance * Use search term reports for insights **Display and social campaigns**: * Exclude poor-performing placements * Refine demographic targeting * Test interest and behavior targeting * Use custom audiences effectively *** ## Reporting and Analysis ### Daily Monitoring **Key metrics to check**: * Spend vs. budget * ROAS performance * CPA trends * Major campaign changes **Alert thresholds**: * ROAS drops below target * CPA exceeds acceptable limits * Budget utilization issues * Significant traffic changes *** ### Weekly Analysis **Performance reviews**: * Campaign performance comparison * Audience segment analysis * Creative performance evaluation * Competitive landscape changes **Optimization planning**: * Identify scaling opportunities * Plan creative refreshes * Budget reallocation decisions * New campaign testing *** ### Monthly Strategic Review **Detailed analysis**: * Overall account performance * Customer acquisition trends * Lifetime value correlation * Market condition impacts **Strategic planning**: * Budget planning for next month * New platform testing * Campaign structure optimization * Long-term growth strategies *** ## Common Issues and Solutions ### High CPA Problems **Possible causes**: * Poor landing page conversion * Broad or irrelevant targeting * Weak ad creative * High competition **Solutions**: * Optimize landing pages * Refine audience targeting * Test new ad creatives * Adjust bidding strategy *** ### Low ROAS Issues **Possible causes**: * Targeting wrong audience * Poor product-market fit * Inefficient funnel * Attribution problems **Solutions**: * Analyze customer data * Test different audiences * Optimize conversion funnel * Review attribution settings *** ### Budget Waste **Common causes**: * Broad targeting * Poor negative keyword lists * Irrelevant placements * Outdated campaigns **Prevention strategies**: * Regular account audits * Automated rules and alerts * Performance monitoring * Continuous optimization *** ## Getting More Help For assistance with: * Campaign setup and optimization * Attribution modeling * Advanced tracking implementation * Performance analysis and reporting Contact our support team or explore our detailed advertising optimization guides. # Conversion Metrics Source: https://docs.elasticfunnels.io/analytics/conversions Understand key conversion metrics like conversion rate, AOV, EPC, and how to optimize your funnel performance ## At a glance **Quick Summary**: Conversion metrics measure how well your funnels turn visitors into customers and revenue. Key metrics include conversion rate, average order value (AOV), earnings per click (EPC), and customer lifetime value. **Most Important**: Conversion Rate (% of visitors who buy), AOV (average purchase amount), and EPC (revenue per visitor) are your core performance indicators. **Bottom Line**: Higher conversion rates and AOV mean more revenue from the same traffic. Focus on these metrics to optimize your funnel performance. **Related Guides**: For advertising metrics, see [Ad Spend Metrics](/analytics/ad-spend-metrics). For video engagement, see [Video Metrics](/analytics/video-metrics). For detailed user behavior tracking, see [Tracking Metrics](/analytics/tracking-metrics). *** ## Core Conversion Metrics ### Conversion Rate **What it is**: The percentage of visitors who complete a purchase **How it's calculated**: (Number of customers ÷ Number of visitors) × 100 **Example**: 1,000 visitors, 50 purchases = 5% conversion rate **Why it matters**: * Your most important metric for funnel performance * Small improvements have big revenue impact * Industry average is typically 1-3% for e-commerce **Good benchmarks**: * 1-2%: Needs improvement * 3-5%: Good performance * 5%+: Excellent performance *** ### Sessions vs Customers **Sessions**: Total number of website visits (including repeat visitors) **Customers**: Unique individuals who made a purchase **Why both matter**: Sessions show traffic volume, customers show actual buyers. The ratio between them is your conversion rate. *** ### Average Order Value (AOV) **What it is**: The average amount customers spend per purchase **How it's calculated**: Total revenue ÷ Number of orders **Example**: $10,000 revenue from 100 orders = $100 AOV **Why it matters**: * Directly impacts revenue without needing more traffic * Easier to increase than conversion rate * Shows effectiveness of upsells and cross-sells **Ways to improve**: * Add upsells and cross-sells * Bundle products together * Offer volume discounts * Increase product prices strategically ## Revenue Metrics ### Gross Revenue **What it is**: Total sales amount before any deductions **Includes**: All product sales, shipping, taxes, and fees **Use for**: Understanding total business volume *** ### Net Revenue **What it is**: Revenue after deductions like refunds, chargebacks, and fees **More accurate for**: Actual money you keep from sales **Why it's important**: Shows true business performance after costs *** ### Earnings Per Click (EPC) **What it is**: Average revenue generated per website visitor **How it's calculated**: Total revenue ÷ Total clicks/visits **Example**: $5,000 revenue from 1,000 clicks = $5.00 EPC **Why it's crucial**: * Shows true value of your traffic * Helps determine profitable advertising spend * Compares performance across different traffic sources **Types**: * **Gross EPC**: Before any deductions * **Net EPC**: After refunds and fees (more accurate) ## Advanced Metrics ### Customer Acquisition Cost (CAC) **What it is**: How much you spend to acquire one customer **How it's calculated**: Total marketing spend ÷ Number of new customers **Example**: $1,000 ad spend, 50 new customers = $20 CAC **Why it matters**: Must be less than customer lifetime value to be profitable *** ### Lifetime Value (LTV) **What it is**: Total revenue a customer generates over their relationship with you **Includes**: Initial purchase + repeat purchases + upsells **Key ratio**: LTV should be 3-5x higher than CAC for healthy business *** ## Traffic and Engagement Metrics *Note: For detailed ad spend metrics including ROAS, CPC, CPA, and CTR, see our [Ad Spend Metrics](/analytics/ad-spend-metrics) guide.* ## Funnel Performance Metrics ### Add to Cart Rate **What it is**: Percentage of visitors who add products to their cart **Shows**: Interest level in your products **Typical range**: 5-15% depending on industry *** ### Cart Abandonment Rate **What it is**: Percentage of people who add to cart but don't complete purchase **How it's calculated**: (Carts created - Purchases) ÷ Carts created × 100 **Industry average**: 60-70% (meaning 30-40% complete purchase) *** ### Checkout Completion Rate **What it is**: Percentage of people who complete purchase after starting checkout **Target**: 85%+ completion rate **Low rates indicate**: Checkout process issues, unexpected costs, or technical problems ## Understanding Your Data ### What Good Performance Looks Like **High-performing funnels typically have**: * Conversion rate: 3-8% * AOV: \$75-200+ (varies by industry) * EPC: \$2-10+ * Cart completion: 85%+ * ROAS: 4:1 or higher ### Red Flags to Watch For * Conversion rate under 1% * High traffic but low revenue * Declining AOV over time * High refund rates * Poor mobile performance ### Seasonal Considerations * Holiday seasons typically show higher AOV * Summer months may have lower conversion rates * Back-to-school and Black Friday are peak periods * Plan campaigns around these patterns ## Improving Your Metrics ### Boost Conversion Rate * Improve page load speed * Add customer testimonials and reviews * Simplify checkout process * Test different headlines and offers * Add urgency and scarcity elements * Optimize for mobile devices ### Increase Average Order Value * Offer product bundles * Add "frequently bought together" suggestions * Create volume discounts * Implement one-click upsells * Show related products * Offer free shipping thresholds ### Optimize Traffic Quality * Target more specific audiences * Use negative keywords in ads * Focus on high-intent traffic sources * Improve ad copy and targeting * Test different traffic sources ## Tracking and Monitoring ### Daily Monitoring Check these metrics daily: * Conversion rate * Revenue * Traffic volume * Major funnel steps ### Weekly Analysis Review these weekly: * AOV trends * Traffic source performance * Campaign ROAS * Customer acquisition costs ### Monthly Deep Dive Analyze monthly: * Customer lifetime value * Seasonal trends * Funnel optimization opportunities * Competitive performance ## Common Questions ### "What's a good conversion rate for my industry?" Conversion rates vary widely by industry, traffic source, and price point. Focus on improving your own baseline rather than comparing to others. ### "Should I focus on more traffic or better conversion rate?" Generally, improving conversion rate is more cost-effective than buying more traffic. A 1% improvement in conversion rate often has bigger impact than 20% more traffic. ### "How do I know if my AOV is good?" Compare your AOV to your customer acquisition cost. If AOV is 3-5x higher than CAC, you're in good shape. ### "Why do my metrics fluctuate so much?" Daily fluctuations are normal due to traffic patterns, seasonality, and external factors. Focus on weekly and monthly trends instead of daily changes. ## Getting More Help For assistance with: * Setting up conversion tracking * Interpreting your specific metrics * Creating optimization strategies * Troubleshooting tracking issues Contact our support team or explore our detailed optimization guides. # Split Test Analytics Source: https://docs.elasticfunnels.io/analytics/split-test Understand split testing metrics, statistical significance, and how to interpret your A/B test results ## At a glance **Quick Summary**: Split testing lets you compare different versions of your pages to see which performs better. The system automatically tracks visitors, measures conversions, and tells you which version wins with statistical confidence. **Key Metrics**: Conversion rate, statistical significance, confidence intervals, sample size, and revenue impact. **Bottom Line**: Run tests until you get statistically significant results, then implement the winning version to improve your funnel performance. *** ## What is Split Testing? Split testing (also called A/B testing) is a method of comparing two or more versions of a webpage, offer, or element to determine which one performs better. Instead of guessing what works, you let real visitor data decide. ### How It Works 1. **Create Variants**: Make different versions of what you want to test (headlines, buttons, layouts, etc.) 2. **Split Traffic**: Visitors are randomly shown different versions (usually 50/50) 3. **Measure Results**: Track conversions, sales, and other key metrics 4. **Determine Winner**: Statistical analysis tells you which version actually performs better ## Key Metrics Explained ### Conversion Rate **What it is**: The percentage of visitors who complete your desired action (purchase, signup, etc.) **Example**: If 100 people visit your page and 5 make a purchase, your conversion rate is 5% **Why it matters**: This is usually your primary metric for determining which variant performs better ### Custom Goals **What it is**: Track specific user actions beyond just purchases (email signups, video completions, downloads, etc.) **How to use**: Define custom goals in your split test settings that match interactions in your page builder **Example**: Track "email-signup" events to see which variant gets more newsletter subscriptions ### Statistical Significance **What it is**: A measure of confidence that your results are real, not just random chance **How to read it**: * ✅ **Significant**: You can trust the results - implement the winner * ⚠️ **Not Significant**: Keep testing - results could be due to random variation * ❌ **Insufficient Data**: Not enough visitors yet to draw conclusions **Rule of thumb**: Wait for 95% statistical significance before making decisions ### Confidence Intervals **What it is**: A range showing the likely true performance difference between variants **Example**: "Variant B improves conversion rate by 2.1% to 7.3%" * This means Variant B is definitely better than the original * The true improvement is somewhere between 2.1% and 7.3% **How to interpret**: * Range includes zero (e.g., "-1.2% to +4.8%"): **Not significant** - keep testing * Range is all positive (e.g., "2.1% to 7.3%"): **Winner found** - implement this variant * Range is all negative (e.g., "-5.2% to -1.1%"): **Loser** - stick with original ### Sample Size **What it is**: The number of visitors needed to get reliable results **Why it matters**: * Too few visitors = unreliable results * The system calculates minimum sample sizes automatically * Larger improvements need fewer visitors to detect ### P-Value **What it is**: The probability of seeing your results (or more extreme) if there were actually no real difference between variants **How to read it**: * p \< 0.05: Statistically significant (unlikely to see these results if there's no real difference) * p > 0.05: Not significant yet (results could reasonably occur even with no real difference) ## Understanding Your Results ### When Results Are Ready Your split test results are reliable when you have: * ✅ **Statistical significance** (p-value \< 0.05) * ✅ **Sufficient sample size** (system will indicate this) * ✅ **Confidence interval** that doesn't include zero * ✅ **Test ran for adequate time** (usually at least 1-2 weeks) ### Making Decisions **Clear Winner**: * High statistical significance * Confidence interval shows consistent improvement * **Action**: Implement the winning variant **No Clear Winner**: * Results not statistically significant * Confidence interval includes zero * **Action**: Continue testing or try different variants **Clear Loser**: * Statistically significant negative results * **Action**: Stop the test, keep your original version ## Advanced Features ### Automated Winner Selection **What it is**: The system can automatically select winners when statistical criteria are met **Configuration options**: * **Significance Level**: Set your confidence threshold (95%, 99%, etc.) * **Practical Significance**: Require minimum improvement percentage * **Sample Size Limits**: Set minimum/maximum visitors per variant * **Duration Controls**: Set minimum and maximum test durations * **Guardrail Metrics**: Ensure winners don't hurt other important metrics ### Statistical Settings **Significance Level**: Control your confidence threshold (0.01 = 99%, 0.05 = 95%) **Statistical Power**: Probability of detecting real effects (0.8 = 80% power) **Minimum Detectable Effect**: Smallest improvement you want to detect **Multiple Testing Correction**: Adjust for testing multiple metrics simultaneously ### Early Stopping & Sequential Testing **Early Stopping**: Allow tests to conclude as soon as significance is reached **Sequential Testing**: Check for significance at regular intervals with proper alpha spending **Alpha Spending Functions**: Pocock, O'Brien-Fleming methods for controlling false positives ### Timezone Support All dates and times are displayed in your brand's configured timezone for accurate local time tracking. ## Best Practices ### Before Starting * **Test one thing at a time**: Don't change multiple elements simultaneously * **Have a clear hypothesis**: Know what you expect and why * **Set success metrics**: Define what "winning" means before you start * **Plan for adequate traffic**: Ensure you have enough visitors for reliable results * **Configure automation**: Set up automated winner selection criteria if desired ### During Testing * **Don't peek too early**: Wait for statistical significance * **Run for full business cycles**: Include weekends, different days * **Avoid external changes**: Don't make other changes during the test * **Monitor for technical issues**: Ensure both variants are working properly ### After Results * **Implement winners quickly**: Don't delay acting on clear results * **Document learnings**: Keep track of what worked and what didn't * **Plan next tests**: Use insights to inform future testing ideas * **Monitor post-implementation**: Ensure results hold up after full rollout ## Common Questions ### "How long should I run my test?" Run until you achieve statistical significance AND have adequate sample size. This usually takes 1-4 weeks depending on your traffic volume. ### "Can I stop early if I see good results?" With early stopping enabled, you can stop as soon as statistical significance is reached. However, ensure you've met minimum duration and sample size requirements. ### "What if my test shows no winner?" This is valuable information! It means your variants perform similarly. Try testing more dramatic changes or different elements. ### "How does automated winner selection work?" The system runs checks every 5 minutes and can automatically select winners based on your configured criteria: * Statistical significance threshold * Minimum sample size requirements * Practical significance thresholds * Guardrail metric protection * Duration limits ### "What are guardrail metrics?" Metrics that protect your business - the system ensures the winning variant doesn't significantly hurt important metrics like revenue, profit, or user experience. ### "How much improvement should I expect?" Typical improvements range from 5-30%. Small improvements (1-2%) are hard to detect and may not be worth implementing. ### "Can I test more than 2 variants?" Yes, but more variants require more traffic and time to reach significance. Start with 2 variants when possible. ## Configuration Modes ### Simple Mode Choose from preset configurations designed for common testing scenarios: * **Sprint Test**: Ultra-fast results in 3-7 days for obvious changes * **Standard Test**: 7-14 days for reliable results on most changes * **Deep Test**: 14-30 days for thorough testing of important changes * **Revenue Test**: Optimized for revenue and AOV improvements * **Engagement Test**: Focus on user engagement and interaction metrics ### Advanced Mode Full control over all statistical parameters: * Custom significance levels and statistical power * Bayesian vs Frequentist statistical methods * Sequential testing with alpha spending functions * Practical significance thresholds * Custom sample size limits and duration controls ## Revenue Impact Split testing directly impacts your bottom line by: * **Increasing conversion rates**: More visitors become customers * **Improving average order value**: Better offers and upsells * **Reducing bounce rates**: More engaging content keeps visitors on-site * **Optimizing customer lifetime value**: Better experiences lead to repeat customers Even small improvements compound over time. A 10% conversion rate improvement on a page with 1000 monthly visitors and $100 average order value generates an extra $1,000 monthly revenue. ### Automated Optimization With automated winner selection enabled, your funnels continuously optimize themselves: * Tests run automatically based on your criteria * Winners are selected when statistical thresholds are met * Traffic is redirected to winning variants without manual intervention * Guardrail metrics protect against negative impacts ## Getting Help If you need assistance with: * Setting up tests * Interpreting results * Troubleshooting issues * Planning testing strategies Contact our support team or check our detailed guides for specific testing scenarios. # Tracking Metrics Source: https://docs.elasticfunnels.io/analytics/tracking-metrics Detailed tracking metrics for user behavior, engagement, and funnel performance analysis ## At a glance **Quick Summary**: Tracking metrics provide detailed insights into user behavior, engagement patterns, and funnel performance. These metrics help you understand how users interact with your content and identify optimization opportunities. **Most Important**: Sessions, Page Views, Engagement Rate, and Session Duration are your core tracking performance indicators. **Bottom Line**: Better tracking leads to better optimization. Monitor these metrics to understand user behavior and improve funnel performance. *** ## Core Tracking Metrics ### Sessions **What it is**: Individual visits to your website or funnel, regardless of duration **How it's tracked**: Unique session identifiers that expire after inactivity **Example**: A user visits your site, browses for 10 minutes, leaves, then returns 2 hours later = 2 sessions **Why it matters**: * Foundation metric for calculating rates and averages * Shows traffic volume and user interest * Used to calculate conversion rates and engagement metrics **Technical details**: * Session expires after 30 minutes of inactivity (configurable) * New session starts with each new visit * Cross-device sessions may be counted separately *** ### Page Views **What it is**: Total number of pages viewed across all sessions **How it's tracked**: Each page load or route change triggers a page view event **Different from sessions**: Multiple page views can occur within a single session **Why it's valuable**: * Shows content consumption patterns * Indicates user engagement depth * Helps identify popular content * Used for calculating pages per session **Optimization insights**: * High page views per session indicate good engagement * Low page views may suggest navigation issues * Track which pages drive the most views *** ### Unique Clicks **What it is**: Number of distinct sessions that resulted in at least one click **How it's calculated**: Cardinality count of unique session IDs with click events **Different from total clicks**: Counts each session only once, regardless of multiple clicks **Why it's important**: * Shows actual user engagement vs bot traffic * More accurate than total clicks for engagement analysis * Better metric for calculating click-through rates **Use cases**: * Measuring campaign effectiveness * Calculating true engagement rates * Identifying quality traffic sources *** ## Engagement Metrics ### Engagement Rate **What it is**: Percentage of sessions that include meaningful user interactions **How it's calculated**: (Sessions with Engagements ÷ Total Sessions) × 100 **What counts as engagement**: * Clicks on interactive elements * Form submissions * Video plays * Downloads * Time-based engagement thresholds **Why it's crucial**: * Shows content quality and relevance * Indicates user interest and intent * Helps identify optimization opportunities * Correlates with conversion potential **Good benchmarks**: * 40-60%: Typical performance * 60-80%: Good performance * 80%+: Excellent performance *** ### Session Duration **What it is**: Average time users spend on your site per session **How it's calculated**: Total Time on Page ÷ Number of Sessions **Technical measurement**: Time between first page load and last recorded activity **Why it matters**: * Indicates content quality and user interest * Shows if users find value in your content * Helps identify pages that hold attention * Correlates with conversion likelihood **Ways to improve**: * Improve content quality and relevance * Enhance page loading speed * Add engaging interactive elements * Optimize content structure and flow *** ### Time on Page **What it is**: Total time users spend viewing specific pages **How it's measured**: Time between page load and next page load or session end **Aggregation**: Sum of all time spent across all sessions **Use for**: * Identifying most engaging content * Understanding user behavior patterns * Optimizing page content and layout * Calculating average session duration **Limitations**: * Last page of session may not be accurately measured * Inactive tabs may inflate time measurements * Requires JavaScript-based tracking for accuracy *** ## Funnel-Specific Metrics ### Funnel Clicks **What it is**: Total number of clicks on funnel-specific elements **Includes**: * CTA button clicks * Product link clicks * Navigation clicks within funnel * Form field interactions **Why track separately**: Distinguishes funnel engagement from general site navigation **Optimization insights**: * High funnel clicks indicate good user flow * Low clicks may suggest unclear CTAs * Click patterns show user journey preferences *** ### Funnel Click-Through Rate (CTR) **What it is**: Percentage of sessions that result in funnel clicks **How it's calculated**: (Funnel Clicks ÷ Sessions) × 100 **Why it's important**: * Shows funnel effectiveness * Indicates CTA performance * Helps optimize user flow * Measures engagement quality **Optimization tactics**: * Test different CTA designs * Improve CTA placement * Optimize messaging and copy * Reduce friction in user flow *** ### Add to Cart Rate **What it is**: Percentage of visitors who add products to their cart **How it's calculated**: (Add to Cart Events ÷ Sessions) × 100 **Why it's critical**: * Shows product interest level * Indicates pricing acceptance * Measures funnel effectiveness * Predicts conversion potential **Typical ranges**: 5-15% depending on industry and traffic source **Improvement strategies**: * Optimize product presentation * Improve pricing strategy * Add social proof elements * Simplify add-to-cart process *** ## Advanced Tracking Metrics ### Engagement Time **What it is**: Total time users spend actively engaging with content **Different from session duration**: Only counts active interaction time **Measurement criteria**: * Mouse movement and clicks * Keyboard interactions * Scroll activity * Video/audio engagement **Why it's more accurate**: Filters out inactive time and background tabs **Use cases**: * Content quality assessment * User experience optimization * Engagement comparison across pages * True attention measurement *** ### Split Test Views **What it is**: Number of times different variations in A/B tests are viewed **How it's tracked**: Separate counting for each test variation **Why it's essential**: * Ensures statistical significance * Validates test results * Helps determine winning variations * Guides optimization decisions **Best practices**: * Ensure even traffic distribution * Track sufficient sample sizes * Monitor for external factors * Document test parameters *** ### UTM Parameter Tracking **What it is**: Tracking of campaign parameters in URLs for attribution **Standard parameters**: * utm\_source: Traffic source (google, facebook, email) * utm\_medium: Marketing medium (cpc, social, email) * utm\_campaign: Campaign name * utm\_term: Keyword (for paid search) * utm\_content: Ad content differentiation **Why it's crucial**: * Accurate campaign attribution * ROI measurement by source * Optimization insights * Budget allocation guidance **Implementation tips**: * Use consistent naming conventions * Document parameter standards * Validate tracking setup * Regular attribution analysis *** ## E-commerce Specific Metrics ### Add to Carts **What it is**: Total number of add-to-cart events across all sessions **Different from add-to-cart rate**: Raw count vs percentage **Why track both**: * Volume understanding (total events) * Performance measurement (rate) * Trend analysis over time * Capacity planning **Optimization focus**: * Product page improvements * Pricing strategy adjustments * User experience enhancements * Inventory management insights *** ### Cart Conversion Rate **What it is**: Percentage of cart additions that result in purchases **How it's calculated**: (Purchases ÷ Add to Carts) × 100 **Industry averages**: 30-40% completion rate **Why it's critical**: * Shows checkout process effectiveness * Identifies abandonment issues * Measures pricing acceptance * Indicates trust and security perception **Improvement strategies**: * Simplify checkout process * Add trust signals and security badges * Optimize payment options * Reduce unexpected costs *** ## Time-Based Metrics ### Time on Page Before First Purchase **What it is**: Average time users spend on site before making their first purchase **Why it's valuable**: * Shows consideration period length * Helps optimize content strategy * Indicates decision-making patterns * Guides retargeting timing **Use for**: * Content planning and development * Email sequence timing * Retargeting campaign setup * Sales process optimization *** ### Affiliate Clicks **What it is**: Clicks on affiliate or partner links within your funnel **Tracking considerations**: * Separate from internal navigation * Attribution to specific partners * Commission calculation basis * Performance measurement **Why monitor**: * Partner relationship management * Revenue attribution * Commission accuracy * Partnership optimization *** ## Implementation and Setup ### Basic Tracking Requirements **Essential events to track**: * Page views and route changes * Click events on key elements * Form submissions and interactions * Session start and end times * User engagement indicators **Technical setup**: * JavaScript tracking code * Event listener implementation * Session management * Data validation and cleaning *** ### Advanced Tracking Features **Enhanced data collection**: * User agent and device information * Geographic location data * Referrer and traffic source * Custom event parameters * Cross-device identification **Privacy considerations**: * GDPR compliance requirements * Cookie consent management * Data anonymization options * User opt-out mechanisms *** ## Data Quality and Validation ### Common Tracking Issues **Bot traffic filtering**: * Identify and exclude automated traffic * Validate human interaction patterns * Monitor for unusual activity spikes * Implement bot detection mechanisms **Data accuracy checks**: * Validate event firing consistency * Check for duplicate events * Monitor tracking code errors * Verify cross-browser compatibility *** ### Performance Monitoring **Tracking performance metrics**: * Event processing speed * Data collection accuracy * System reliability uptime * Error rate monitoring **Ways to improve**: * Minimize tracking code impact * Implement efficient data collection * Use asynchronous loading * Regular performance audits *** ## Reporting and Analysis ### Real-Time Monitoring **Key metrics for immediate attention**: * Current active sessions * Real-time conversion events * Traffic source performance * System health indicators **Alert setup**: * Unusual traffic patterns * Conversion rate drops * Technical tracking issues * Performance threshold breaches *** ### Historical Analysis **Trend identification**: * Long-term performance patterns * Seasonal behavior changes * Growth trajectory analysis * Comparative period performance **Strategic insights**: * User behavior evolution * Content performance trends * Channel effectiveness changes * Optimization opportunity identification *** ## Getting More Help For assistance with: * Tracking implementation setup * Custom event configuration * Data analysis and interpretation * Performance optimization strategies Contact our support team or explore our detailed tracking implementation guides. # Video Metrics Source: https://docs.elasticfunnels.io/analytics/video-metrics Track video engagement and performance metrics across different video players and platforms **Player Compatibility Notice**: Video tracking accuracy depends on your video player implementation. Some players may not properly track all metrics or may have limited support. **Known Limitations**: * **vTurb V2**: Only partially supported - some metrics may not track accurately * **Custom players**: Must implement standard video events and have `video` tag name. **Recommendation**: Test your specific player setup to verify which metrics are accurately tracked before relying on the data for optimization decisions. *** ## At a glance **Quick Summary**: Video metrics help you understand how users interact with video content in your funnels. These metrics work with various video players and provide insights into engagement, completion rates, and viewing behavior. **Most Important**: Video Play Rate, Video Completion Rate, and Average Watch Time are your core video performance indicators. **Bottom Line**: Higher video engagement typically leads to better conversion rates. Monitor these metrics to optimize your video content and placement. **Player Compatibility**: These metrics work with most modern video players including HTML5, Vimeo, YouTube, Wistia, and custom players that support standard video events. *** ## Core Video Engagement Metrics ### Video Play Rate **What it is**: The percentage of visitors who start playing your video **How it's calculated**: (Video Play Sessions ÷ Total Sessions) × 100 **Example**: 500 video plays from 1,000 sessions = 50% play rate **Why it matters**: * Shows how compelling your video thumbnail and placement are * Indicates initial user interest in your content * Helps optimize video positioning and preview images **Good benchmarks**: * 30-50%: Good performance * 50-70%: Excellent performance * 70%+: Outstanding performance **Player requirements**: Requires video player to fire 'play' events *** ### Video Completion Rate **What it is**: The percentage of video viewers who watch the entire video **How it's calculated**: (Video Completion Sessions ÷ Video Play Sessions) × 100 **Example**: 200 completions from 500 plays = 40% completion rate **Why it matters**: * Measures content quality and engagement * Indicates if video length is appropriate * Shows effectiveness of your video content **Good benchmarks**: * 20-40%: Typical performance * 40-60%: Good performance * 60%+: Excellent performance **Player requirements**: Requires video player to fire 'ended' or completion events *** ### Average Video Watch Time **What it is**: The average amount of time users spend watching your video **How it's calculated**: Total Video Watch Time ÷ Video Sessions **Example**: 5,000 seconds total watch time from 100 sessions = 50 seconds average **Why it matters**: * Shows actual engagement level with your content * Helps identify optimal video length * Indicates where viewers typically drop off **Optimization tips**: * Hook viewers in the first 10 seconds * Keep key messages early in the video * Test different video lengths * Add captions for better accessibility **Player requirements**: Requires video player to track time watched accurately *** ## Advanced Video Metrics ### Video Unmute Rate **What it is**: The percentage of video viewers who unmute the video (for autoplay videos) **How it's calculated**: (Video Unmute Sessions ÷ Video Play Sessions) × 100 **Why it matters**: * Critical for autoplay video strategies * Shows active engagement vs passive viewing * Helps optimize audio content and captions **Good benchmarks**: * 10-20%: Typical for autoplay videos * 20-30%: Good performance * 30%+: Excellent performance **Player requirements**: Requires video player to track mute/unmute events *** ### Video CTA Show Rate **What it is**: The percentage of video sessions where a call-to-action is displayed **How it's calculated**: (Video CTA Shows ÷ Video Sessions) × 100 **Why it matters**: * Measures CTA visibility and timing * Helps optimize when CTAs appear * Shows potential conversion opportunities **Ways to improve**: * Show CTAs at optimal engagement points * Test different CTA timing * A/B test CTA designs and messaging **Player requirements**: Requires custom implementation to track CTA displays *** ### Total Video Watch Time **What it is**: The cumulative time all users spend watching your video content **Use for**: * Understanding overall content consumption * Calculating average watch times * Measuring total engagement volume **Why it's valuable**: * Shows aggregate content value * Helps justify video production costs * Indicates content popularity trends *** ## Video Session Metrics ### Video Sessions **What it is**: Total number of sessions where video content was accessed **Different from**: Video plays (some sessions may not result in plays) **Use for**: * Understanding video exposure * Calculating play rates * Measuring video page traffic *** ### Video Play Sessions **What it is**: Number of sessions where users actually started playing the video **Why track separately**: Distinguishes between video exposure and actual engagement **Use for**: * Calculating completion rates * Understanding true video engagement * Optimizing video placement and thumbnails *** ### Video Completion Sessions **What it is**: Number of sessions where users watched the video to completion **Critical for**: * Measuring content effectiveness * Understanding user commitment * Optimizing video length and content *** ## Player-Specific Considerations ### HTML5 Video Players **Supported events**: play, pause, ended, timeupdate, volumechange **Tracking accuracy**: High - direct event access **Setup requirements**: Standard HTML5 video element with event listeners ### YouTube Embedded Videos **Supported events**: onStateChange, onPlaybackQualityChange **Tracking accuracy**: Good - via YouTube API **Setup requirements**: YouTube IFrame API integration **Limitations**: Some events may be restricted by YouTube policies ### Vimeo Embedded Videos **Supported events**: play, pause, ended, timeupdate **Tracking accuracy**: High - via Vimeo Player API **Setup requirements**: Vimeo Player SDK integration **Benefits**: Detailed analytics and customization options ### Wistia Videos **Supported events**: play, pause, end, timechange **Tracking accuracy**: Excellent - API-level tracking **Setup requirements**: Wistia embed code with API enabled **Benefits**: Built-in heatmaps and engagement analytics ### vturb v2 Player **Support level**: Partially supported **Tracking accuracy**: Limited - some metrics may not be available **Known issues**: * May not fire all standard video events consistently * Some engagement metrics might not track properly * Completion tracking may be unreliable **Recommendation**: Verify specific metrics work in your implementation before depending on them *** ### Custom Video Players **Requirements**: Must implement standard video events **Tracking accuracy**: Varies by implementation **Setup**: Custom event tracking implementation needed **Considerations**: Ensure consistent event naming and timing *** ## Implementation Requirements ### Basic Tracking Setup **Required events**: * Video load/ready * Play start * Pause * Video end/completion * Time updates **Optional events**: * Mute/unmute * Quality changes * Fullscreen toggle * Seek events ### Data Collection **Essential data points**: * Session ID * Video ID or URL * Timestamp * Event type * Current time position * Video duration **Enhanced tracking**: * User agent * Device type * Connection speed * Video quality * Engagement score *** ## Optimization Strategies ### Improving Play Rates **Video thumbnails**: * Use compelling, high-quality thumbnails * Test different thumbnail styles * Include faces or action shots * Avoid misleading thumbnails **Video placement**: * Position videos prominently * Use autoplay strategically * Add play button overlays * Optimize for mobile viewing ### Increasing Completion Rates **Content optimization**: * Hook viewers in first 10 seconds * Keep videos concise and focused * Use engaging visuals and audio * Include clear value propositions **Technical optimization**: * Ensure fast loading times * Optimize for different devices * Provide quality options * Add captions and transcripts ### Maximizing Watch Time **Content strategies**: * Structure content with clear progression * Use storytelling techniques * Include interactive elements * Maintain consistent pacing **Technical strategies**: * Minimize buffering issues * Optimize video compression * Provide smooth playback * Enable quality adaptation *** ## Common Issues and Solutions ### Low Play Rates **Possible causes**: * Poor thumbnail quality * Unclear video purpose * Slow loading times * Poor placement **Solutions**: * A/B test thumbnails * Add descriptive titles * Optimize video loading * Improve video positioning ### High Drop-off Rates **Possible causes**: * Slow start to content * Poor audio/video quality * Content doesn't match expectations * Too long for the context **Solutions**: * Front-load key information * Improve production quality * Align content with promises * Test shorter versions ### Technical Tracking Issues **Common problems**: * Events not firing * Duplicate event tracking * Cross-browser compatibility * Mobile tracking issues * Player-specific limitations (e.g., vturb v2 partial support) **Solutions**: * Test across all browsers * Implement event deduplication * Use progressive enhancement * Test on actual mobile devices * Verify player-specific event support before implementation **vturb v2 Specific Issues**: * Some video events may not fire consistently * Consider alternative tracking methods for critical metrics * Test thoroughly in your specific implementation * Have fallback tracking for important conversion events *** ## Reporting and Analysis ### Daily Monitoring **Key metrics to check**: * Video play rate * Completion rate * Average watch time * Technical issues ### Weekly Analysis **Deeper insights**: * Content performance trends * Device and browser breakdowns * User behavior patterns * Conversion correlation ### Monthly Optimization **Strategic reviews**: * Content strategy effectiveness * Technical performance optimization * Player technology evaluation * ROI analysis of video content *** ## Getting More Help For assistance with: * Video player integration * Custom tracking implementation * Performance optimization * Analytics setup and interpretation Contact our support team or explore our detailed video integration guides. # Authentication Source: https://docs.elasticfunnels.io/api-reference/authentication Learn how to authenticate your API requests ## Overview The ElasticFunnels API uses **API keys** for authentication. Each API key is tied to a specific user and project (brand), inheriting all the permissions of that user for that project. Keep your API keys secure! Never share them publicly or commit them to version control. Treat them like passwords. ## Generating an API Key 1. Navigate to your project dashboard 2. Go to **Settings** → **API** 3. Click **Generate API Key** 4. Copy and securely store your API key You can regenerate your API key at any time, but this will immediately invalidate the old key. Make sure to update any integrations using the old key. ## Using Your API Key Include your API key in the request header using the `EF-Access-Key` header: ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/pages/all \ -H "EF-Access-Key: your_api_key_here" ``` ```javascript JavaScript theme={null} const headers = { 'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json' }; const response = await fetch('https://app.elasticfunnels.io/api/brands/{brand_id}/pages/all', { headers: headers }); ``` ```python Python theme={null} import requests headers = { 'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json' } response = requests.get( 'https://app.elasticfunnels.io/api/brands/{brand_id}/pages/all', headers=headers ) ``` ```php PHP theme={null} Your API key inherits all permissions from your user account for the specific project. API keys are: * **User-specific**: Each key is tied to your user account * **Project-specific**: Each key is tied to a specific project (brand) * **Permission-aware**: Your API requests have the same permissions as your user role (Owner, Admin, Editor, etc.) ### Example Permission Scenarios **Owner/Admin**: Full access to all endpoints including team management, billing, and settings **Editor**: Access to content management endpoints (pages, products, funnels) but not billing or team management **Viewer**: Read-only access to analytics and content ## Best Practices Regenerate your API keys periodically for enhanced security Store API keys in environment variables, never in your codebase Use separate API keys for different integrations to easily revoke access Keep track of which integrations use which keys ## Regenerating Your API Key If your API key is compromised or you need to revoke access: 1. Go to **Settings** → **API** 2. Click **Regenerate API Key** 3. Confirm the action 4. Update all integrations with the new key Regenerating an API key immediately invalidates the old key. Any integrations using the old key will stop working until updated. ## Testing Your Authentication You can test your API key with a simple GET request to retrieve your brand details: ```bash theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id} \ -H "EF-Access-Key: your_api_key_here" ``` A successful response will return your project information with a `200 OK` status. # Affiliates Source: https://docs.elasticfunnels.io/api-reference/endpoints/affiliates Manage affiliate accounts, commissions, and payout processing Affiliate endpoints sit under `/api/brands/{brand}/affiliates/` with `moduleAccess:affiliates` middleware. Authenticate with `EF-Access-Key`. *** ## List Affiliates ``` GET /api/brands/{brand}/affiliates ``` Results per page (1–100, default: 25) Filter by type: `external` or `affcenter` Search by name or merchant account ID `newest`, `oldest`, `created_at`, `updated_at` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/affiliates \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 5, "name": "John Smith", "email": "john@affiliate.com", "status": "active", "type": "external", "commission_rate": 30, "commission_type": "percent", "merchant": { "id": 1, "merchant": "NMI", "account_id": "123" }, "commissionGroup": null, "brand_id": 42 } ], "per_page": 25, "total": 18, "last_page": 1 } ``` *** ## List All Affiliates (Unpaginated) ``` GET /api/brands/{brand}/affiliates/all ``` Returns `id`, `name`, `type`, and `merchant` for all affiliates — useful for dropdowns. *** ## Quick Access Stats ``` GET /api/brands/{brand}/affiliates/quick-access ``` ```json Response theme={null} { "all": 18, "external": 12, "affcenter": 6 } ``` *** ## Organization Affiliates Options Search affiliates from the organization's affiliate center — used for linking org affiliates to a brand. ``` GET /api/brands/{brand}/affiliates/organization-affiliates-options ``` Search by name, email, or nickname Ensure this affiliate is included even if outside the search results ```json Response theme={null} [ { "id": 3, "name": "Jane Affiliate", "email": "jane@example.com", "nickname": "jane_aff", "displayLabel": "Jane Affiliate (jane@example.com)" } ] ``` *** ## Get Affiliate ``` GET /api/brands/{brand}/affiliates/{affiliate} ``` ```json Response theme={null} { "id": 5, "name": "John Smith", "email": "john@affiliate.com", "phone": null, "status": "active", "type": "external", "commission_rate": 30, "commission_type": "percent", "is_whitelisted": false, "merchant_id": 1, "merchant": null } ``` *** ## Create Affiliate ``` POST /api/brands/{brand}/affiliates ``` Affiliate display name (max 255) `active`, `disabled`, `blacklisted`, `pending`, `pending_email`, or `pending_approve` Affiliate email. Must be unique per brand. Phone number (max 255) `external` (manually managed) or `affcenter` (linked to org affiliate center) Commission percentage or fixed amount `percent` or `fixed` Link to a brand merchant integration Bypass approval requirements ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/affiliates \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "John Smith", "email": "john@affiliate.com", "status": "active", "type": "external", "commission_rate": 30, "commission_type": "percent" }' ``` ```json 200 Created theme={null} { "affiliate": { "id": 5, "name": "John Smith", "email": "john@affiliate.com", "status": "active", "type": "external", "commission_rate": 30, "commission_type": "percent", "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } } ``` ```json 422 Duplicate email theme={null} { "errors": { "email": ["The email has already been taken."] } } ``` ```json 403 Plan limit theme={null} { "error": "Upgrade required to create affiliates", "plan_error": { "message": "Your current plan does not allow creating affiliates.", "upgrade_info": {} } } ``` *** ## Update Affiliate ``` PUT /api/brands/{brand}/affiliates/{affiliate} ``` Same body as Create — all fields optional on update. *** ## Delete Affiliate ``` DELETE /api/brands/{brand}/affiliates/{affiliate} ``` ```json Response theme={null} { "success": true } ``` *** ## Export Affiliates ``` POST /api/brands/{brand}/affiliates/export ``` Filter conditions (same as list query params) Limit export to specific affiliate IDs Columns to include in the export ```json Response theme={null} { "success": true, "message": "Export started. You will receive an email when it is ready." } ``` *** ## Payouts Payouts represent commission payments owed to affiliates for a billing period. ### List Payouts ``` GET /api/brands/{brand}/affiliates/payouts ``` Filter by affiliate ID Filter by status: `pending`, `approved`, `paid`, `rejected`, `cancelled` Results per page (1–100, default: 25) ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/affiliates/payouts?status=pending" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 3, "brand_affiliate_id": 5, "status": "pending", "amount": 450.00, "currency": "USD", "period_start": "2024-12-01", "period_end": "2024-12-31", "conversions_count": 15, "affiliate": { "id": 5, "name": "John Smith" }, "created_at": "2025-01-01T00:00:00Z" } ], "per_page": 25, "total": 4 } ``` ### Create Payout Generate a payout for an affiliate based on their conversions in a date range. ``` POST /api/brands/{brand}/affiliates/payouts ``` Affiliate ID to create the payout for Start date (ISO 8601) End date (ISO 8601, must be on or after `period_start`) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/affiliates/payouts \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "brand_affiliate_id": 5, "period_start": "2024-12-01", "period_end": "2024-12-31" }' ``` ### Update Payout Status ``` PUT /api/brands/{brand}/affiliates/payouts/{payout} ``` `approved`, `rejected`, `paid`, or `cancelled` Required when `status` is `rejected` (max 1000 chars) Required when `status` is `paid` (e.g. `bank_transfer`, `paypal`) Optional payment reference/transaction ID (max 255) ```bash cURL — mark as paid theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/affiliates/payouts/3 \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"status": "paid", "payment_method": "bank_transfer", "payment_reference": "TXN-987654"}' ``` ### Upload Payout Invoice ``` POST /api/brands/{brand}/affiliates/payouts/{payout}/invoice ``` Invoice PDF file (multipart/form-data) *** ## Notes * `type: "affcenter"` affiliates are linked to your organization's affiliate center and require the `viewOrganizationAffiliates` permission * Some organization portals require an uploaded invoice before a payout can be marked as `paid` * Payout amounts are calculated from the affiliate's commissions on approved conversions within the period # Analytics Source: https://docs.elasticfunnels.io/api-reference/endpoints/analytics Retrieve analytics metrics and performance data ## Get Available Metrics Retrieve the list of available metrics you can track. The brand/project ID Filter metrics relevant to split tests (optional) ```bash Request theme={null} GET /api/brands/{brand}/analytics/metrics ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/analytics/metrics \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "revenue": { "type": "revenue", "name": "Revenue", "base": "revenue", "color": "#10b981", "icon": "...", "info": "Total revenue from orders", "group_name": "Sales", "modules": ["conversions"] }, "sales": { "type": "sales", "name": "Sales", "base": "sales", "color": "#3b82f6", "group_name": "Sales", "modules": ["conversions"] }, "page_views": { "type": "page_views", "name": "Page Views", "base": "page_views", "group_name": "Traffic", "modules": ["tracking"] } } ``` *** ## Get Metrics Data Retrieve actual metrics values for a date range. The brand/project ID Start date (ISO 8601 format) End date (ISO 8601 format) Timezone (e.g., "America/New\_York") Array of metric keys to retrieve Filter by specific field values (e.g., `filter[aff_id]=123`) Filter by specific page Filter by specific split test Optional. Only use when your integration or account documentation specifies a value. ```bash Request theme={null} GET /api/brands/{brand}/analytics/metrics/data ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/analytics/metrics/data?start=2024-12-01T00:00:00Z&end=2024-12-10T23:59:59Z&selected_metrics[]=revenue&selected_metrics[]=sales&selected_metrics[]=page_views" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "revenue": { "value": 15750.00, "formatted_value": "$15,750.00", "previous_value": 12300.00, "change": 28.05, "change_symbol": "+", "current_range": "Dec 1 - Dec 10", "previous_range": "Nov 20 - Nov 30", "previous_range_label": "Previous Period", "lower_is_better": false, "neutral": false }, "sales": { "value": 315, "formatted_value": "315", "previous_value": 246, "change": 28.05, "change_symbol": "+", "lower_is_better": false }, "page_views": { "value": 10500, "formatted_value": "10,500", "previous_value": 8750, "change": 20.00, "change_symbol": "+" } } ``` *** ## Get Analytics Cards For the full card catalog (batch vs per-card URLs, subscription analytics, split-test charts), see **[Analytics dashboard cards](/api-reference/endpoints/analytics-cards)**. Retrieve specialized analytics cards data (video analytics, fulfillment metrics, etc.). The brand/project ID Start date (ISO 8601 format) End date (ISO 8601 format) Array of card types to retrieve ```bash Request theme={null} GET /api/brands/{brand}/analytics/metrics/cards ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/analytics/metrics/cards?start=2024-12-01T00:00:00Z&end=2024-12-10T23:59:59Z&enabled_cards[]=video_play_rate&enabled_cards[]=time_on_page_before_purchase" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "cards": { "video_analytics": { "video_play_rate": { "value": 0.65, "formatted_value": "65%" }, "average_video_watch_time": { "value": 185.5, "formatted_value": "3:05" } }, "advanced_metrics": { "time_on_page_before_purchase": { "value": 320, "formatted": "5:20", "sample_size": 315 } } }, "enabled_cards": ["video_play_rate", "time_on_page_before_purchase"], "filters_applied": { "date_range": ["2024-12-01T00:00:00Z", "2024-12-10T23:59:59Z"], "page_id": null, "split_test_id": null } } ``` *** ## Get Grouped Metrics Retrieve metrics grouped by a specific field (e.g., by product, page, affiliate). The brand/project ID Grouping field: `product`, `page`, `aff_id`, `day`, `hour`, `country`, `device`, `utm_source`, etc. Start date (ISO 8601 format) End date (ISO 8601 format) Array of metric keys to retrieve ```bash Request theme={null} GET /api/brands/{brand}/analytics/metrics/{field}/data ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/analytics/metrics/product/data?start=2024-12-01T00:00:00Z&end=2024-12-10T23:59:59Z&selected_metrics[]=revenue&selected_metrics[]=sales" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} [ { "row_key": "PROD-001", "row_name": "Main Product", "revenue": { "value": 9875.00, "formatted_value": "$9,875.00" }, "sales": { "value": 198, "formatted_value": "198" } }, { "row_key": "PROD-002", "row_name": "Upsell Product", "revenue": { "value": 5875.00, "formatted_value": "$5,875.00" }, "sales": { "value": 117, "formatted_value": "117" } } ] ``` *** ## Export Grouped Metrics Export grouped metrics to CSV or Excel. The brand/project ID Grouping field Array of metric keys to export Export format: `csv` or `excel` (default: `csv`) Date range object with start/end dates ```bash Request theme={null} POST /api/brands/{brand}/analytics/metrics/{field}/export ``` ```bash cURL theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/analytics/metrics/product/export" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "metrics": ["revenue", "sales", "aov"], "format": "csv", "date_range": { "start": "2024-12-01", "end": "2024-12-10" } }' ``` ```json Response theme={null} { "message": "Export has been queued. You will receive an email when it's ready.", "status": "queued", "field": "product" } ``` *** ## Available Grouping Fields * `hour` - Hour of day (0-23) * `day` - Calendar day * `week` - Week number * `month` - Month * `year` - Year * `hour_of_day` - Time grouping * `aff_id` - Affiliate (requires permission) * `subid` - Sub ID * `merchant_id` - Merchant * `page` - Landing page * `product` - Product * `funnel_id` - Funnel * `country` - Visitor country * `region` - Visitor region * `shipping_country` - Shipping destination * `shipping_state` - Shipping state * `device` - Device type (desktop/mobile/tablet) * `os` - Operating system * `browser` - Browser type * `screen_resolution` - Screen size * `language` - Browser language * `utm_source` - Traffic source * `utm_medium` - Marketing medium * `utm_campaign` - Campaign * `utm_term` - Keyword/term * `utm_content` - Ad content * `referrer` - Referring website *** ## Compound Grouping You can group by multiple fields simultaneously using semicolon separation: ```bash theme={null} GET /api/brands/{brand}/analytics/metrics/product;page/data ``` This groups metrics by both product AND page. *** ## Filtering Apply filters using the `filter[field]` query parameter: ```bash theme={null} # Filter by affiliate GET /api/brands/{brand}/analytics/metrics/data?filter[aff_id]=123 # Filter by device GET /api/brands/{brand}/analytics/metrics/data?filter[device]=mobile # Filter by country GET /api/brands/{brand}/analytics/metrics/data?filter[country]=US # Multiple filters GET /api/brands/{brand}/analytics/metrics/data?filter[device]=mobile&filter[country]=US ``` *** ## Custom Metrics You can create custom metrics using formulas: ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/analytics/metrics/data?start=2024-12-01T00:00:00Z&end=2024-12-10T23:59:59Z&selected_metrics[]=metric_0" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "metric": { "0": { "name": "Profit Margin", "formula": "($revenue - $cogs - $ad_spend) / $revenue * 100", "as": "pct" } } }' ``` Custom metric formulas support: * Basic math operators: `+`, `-`, `*`, `/`, `()` * Variables: `$metric_name` (e.g., `$revenue`, `$sales`) * Format types: `currency`, `pct` (percentage), `time`, `number` *** ## Common Metrics | Metric | Description | Module Required | | - | - | - | | `revenue` | Total revenue | conversions | | `sales` | Number of orders | conversions | | `aov` | Average order value | conversions | | `conversion_rate` | Conversion percentage | conversions, tracking | | `page_views` | Total page views | tracking | | `sessions` | Unique sessions | tracking | | `engagement_rate` | User engagement | tracking | | `time_on_page` | Avg time on page | tracking | | `ad_spend` | Advertising cost | integrations | | `roas` | Return on ad spend | conversions, integrations | | `cogs` | Cost of goods sold | conversions | | `net_revenue` | Revenue minus costs | conversions | *** ## Notes * All dates should be in ISO 8601 format * The system automatically compares current period with previous period of equal length * Some metrics require specific modules to be enabled * Permission-restricted metrics (like `aff_id`) require appropriate user permissions * Analytics storage may vary by brand configuration When grouping by `aff_id`, ensure your API key's user has the `affiliates.view` permission *** ## UTM Performance Break down conversions and revenue by a single UTM dimension across a date range. ``` GET /api/brands/{brand}/utm-performance/{type} ``` The brand/project ID UTM dimension: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` Start date (ISO 8601) End date (ISO 8601) Timezone (e.g. `America/New_York`) ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/utm-performance/utm_source?start=2024-12-01T00:00:00Z&end=2024-12-10T23:59:59Z" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} [ { "utm_source": "facebook", "revenue": 9200.00, "sales": 184, "aov": 50.00, "conversion_rate": 0.038, "page_views": 4842 }, { "utm_source": "google", "revenue": 4100.00, "sales": 82, "aov": 50.00, "conversion_rate": 0.041, "page_views": 2000 } ] ``` *** ## Specific Analytics Cards Individual endpoints for specialised analytics data. All accept `start` and `end` (ISO 8601) query params. ### Time Before First Purchase How long visitors waited before converting — useful for identifying checkout hesitation. ``` GET /api/brands/{brand}/analytics/metrics/time-before-first-purchase ``` ```json Response theme={null} { "buckets": [ { "label": "< 1 min", "count": 92, "pct": 0.29 }, { "label": "1–5 min", "count": 118, "pct": 0.37 }, { "label": "5–15 min", "count": 64, "pct": 0.20 }, { "label": "15–60 min", "count": 30, "pct": 0.10 }, { "label": "> 1 hour", "count": 11, "pct": 0.04 } ], "median_seconds": 187, "sample_size": 315 } ``` ### Add-to-Carts by Video Watch Time Identifies at which point in a video visitors add to cart — use to find optimal CTA placement. ``` GET /api/brands/{brand}/analytics/metrics/add-to-carts-by-video-time ``` ```json Response theme={null} { "buckets": [ { "video_pct": 0, "atcs": 12 }, { "video_pct": 25, "atcs": 31 }, { "video_pct": 50, "atcs": 54 }, { "video_pct": 75, "atcs": 29 }, { "video_pct": 100, "atcs": 8 } ] } ``` ### Customer Gender Distribution ``` GET /api/brands/{brand}/analytics/metrics/customer-gender ``` ```json Response theme={null} { "male": { "count": 182, "pct": 0.58 }, "female": { "count": 121, "pct": 0.38 }, "unknown": { "count": 12, "pct": 0.04 } } ``` ### Video Completion by Gender ``` GET /api/brands/{brand}/analytics/metrics/video-completion-by-gender ``` ```json Response theme={null} { "male": { "avg_completion_pct": 64, "sample": 182 }, "female": { "avg_completion_pct": 71, "sample": 121 } } ``` ### Products by Pages Conversion performance broken down by page → product pair. ``` GET /api/brands/{brand}/analytics/metrics/products-by-pages/data GET /api/brands/{brand}/analytics/metrics/products-by-pages/summary ``` ```json data response theme={null} [ { "page_id": 101, "page_title": "Nutra Landing", "product_id": 5, "product_title": "1 Bottle", "revenue": 4600.00, "sales": 92, "conversion_rate": 0.038 } ] ``` ### Upsell Take Rate ``` GET /api/brands/{brand}/analytics/metrics/upsell-take-rate/summary ``` ```json Response theme={null} { "upsells": [ { "product_id": 8, "product_title": "3-Bottle Bundle", "shown": 315, "taken": 87, "take_rate": 0.276, "revenue": 7830.00 } ] } ``` ### Units Sold ``` GET /api/brands/{brand}/analytics/metrics/units-sold/summary ``` ```json Response theme={null} { "total_units": 847, "by_product": [ { "product_id": 5, "title": "1 Bottle", "units": 315 }, { "product_id": 6, "title": "3 Bottles", "units": 532 } ] } ``` *** ## Report Builder Build, save, and export custom cross-dimensional analytics reports. ### Generate Report ``` POST /api/brands/{brand}/analytics/reports/generate ``` Dimension keys to group by (e.g. `["product", "utm_source"]`). See [grouping fields](#available-grouping-fields). Metric keys (e.g. `["revenue", "sales", "conversion_rate"]`) Start date (ISO 8601) End date (ISO 8601) Key-value filters (e.g. `{"device": "mobile", "country": "US"}`) Max rows returned (default: 100) ```bash cURL theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/analytics/reports/generate" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "group_by": ["utm_source", "product"], "metrics": ["revenue", "sales", "conversion_rate"], "start": "2024-12-01T00:00:00Z", "end": "2024-12-10T23:59:59Z", "filters": {"device": "mobile"}, "limit": 50 }' ``` ```json Response theme={null} { "rows": [ { "utm_source": "facebook", "product": "1 Bottle", "revenue": { "value": 4600.00, "formatted_value": "$4,600.00" }, "sales": { "value": 92, "formatted_value": "92" }, "conversion_rate": { "value": 0.038, "formatted_value": "3.8%" } } ], "total_rows": 14, "truncated": false } ``` ### Get Available Grouping Fields (Report Builder) ``` GET /api/brands/{brand}/analytics/reports/grouping-fields ``` ### Get Reporting Periods ``` GET /api/brands/{brand}/analytics/reports/reporting-periods ``` Returns available pre-set periods: `today`, `yesterday`, `last_7_days`, `last_30_days`, `this_month`, `last_month`, etc. ### Save Report ``` POST /api/brands/{brand}/analytics/reports/save ``` Report name Same body as `generate` — stored and re-runnable on demand. ### List Saved Reports ``` GET /api/brands/{brand}/analytics/reports/saved ``` ### Load Saved Report ``` GET /api/brands/{brand}/analytics/reports/saved/{reportId} ``` ### Export Saved Report ``` POST /api/brands/{brand}/analytics/reports/saved/{reportId}/export ``` ### Delete Saved Report ``` DELETE /api/brands/{brand}/analytics/reports/saved/{reportId} ``` ### Export Current Report ``` POST /api/brands/{brand}/analytics/reports/export ``` `csv` (default) or `excel` Other body fields same as Generate. Queues an export job and emails a download link when complete. ```json 200 Queued theme={null} { "message": "Export has been queued. You will receive an email when it's ready.", "status": "queued" } ``` *** ## Analytics Segments Create and manage reusable audience segments for filtering reports. ### List Segments ``` GET /api/brands/{brand}/analytics/segments ``` ### Get Segment ``` GET /api/brands/{brand}/analytics/segments/{segment} ``` ### Create Segment ``` POST /api/brands/{brand}/analytics/segments ``` Segment name Array of condition objects. Each has `field`, `operator` (`eq`, `neq`, `gte`, `lte`, `in`), and `value`. How conditions combine: `and` (default) or `or` ```bash cURL theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/analytics/segments" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "High-Value Mobile Buyers", "operator": "and", "conditions": [ { "field": "device", "operator": "eq", "value": "mobile" }, { "field": "revenue", "operator": "gte", "value": 100 } ] }' ``` ```json 200 Created theme={null} { "id": 4, "name": "High-Value Mobile Buyers", "operator": "and", "conditions": [ { "field": "device", "operator": "eq", "value": "mobile" }, { "field": "revenue", "operator": "gte", "value": 100 } ], "brand_id": 42, "created_at": "2024-12-11T09:00:00.000000Z" } ``` ### Update Segment ``` PUT /api/brands/{brand}/analytics/segments/{segment} ``` ### Delete Segment ``` DELETE /api/brands/{brand}/analytics/segments/{segment} ``` ### Preview Segment Run a segment definition against live data without saving it. ``` POST /api/brands/{brand}/analytics/segments/preview ``` Body: same as Create. ```json Preview response theme={null} { "count": 487, "sample": [ { "session_id": "abc123", "revenue": 147.00, "device": "mobile" } ] } ``` ### Install Default Segments ``` POST /api/brands/{brand}/analytics/segments/install-defaults ``` Installs a curated starter set (e.g. "New Buyers", "Repeat Customers", "Abandoned Carts"). # Automations Source: https://docs.elasticfunnels.io/api-reference/endpoints/automations Create and manage automation workflows, read execution stats, and manage builder configs All Automations endpoints sit under `/api/brands/{brand}/automations/` with `moduleAccess:automations` middleware. Authenticate with `EF-Access-Key`. *** ## List Automations ``` GET /api/brands/{brand}/automations ``` Results per page (1–100, default: 25) Search by title `newest` (default), `oldest`, `created_at`, `updated_at` `asc` or `desc` (used with `created_at` / `updated_at`) ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/automations?q=welcome&per_page=50" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 12, "title": "Welcome Email Sequence", "status": "active", "trigger_node_type": "form_submission", "entry_node": "start", "postback_code": "abc-123", "collection_code": "lead-capture", "created_at": "2024-12-01T10:00:00.000000Z", "updated_at": "2024-12-10T15:00:00.000000Z" } ], "per_page": 25, "total": 8, "last_page": 1 } ``` *** ## List Automations (Unpaginated) ``` GET /api/brands/{brand}/automations/all ``` Returns `{ id, title }` for all automations — useful for dropdowns. ```json Response theme={null} [ { "id": 12, "title": "Welcome Email Sequence" }, { "id": 15, "title": "Abandoned Cart Recovery" } ] ``` *** ## Get Automation ``` GET /api/brands/{brand}/automations/{automation} ``` ```json Response theme={null} { "id": 12, "title": "Welcome Email Sequence", "postback_code": "abc-123", "trigger_node_type": "form_submission" } ``` *** ## Create Automation ``` POST /api/brands/{brand}/automations ``` Automation name (max 255 chars) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/automations \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"title": "Post-Purchase Upsell"}' ``` ```json 200 Created theme={null} { "automation": { "id": 16, "title": "Post-Purchase Upsell", "status": "draft", "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } } ``` ```json 403 Plan limit theme={null} { "error": "Upgrade required to create automations", "plan_error": { "message": "Your current plan does not allow creating automations.", "upgrade_info": {} } } ``` *** ## Update Automation ``` PUT /api/brands/{brand}/automations/{automation} ``` New title (max 255) `active`, `inactive`, or `draft` Trigger type (e.g. `form_submission`, `purchase`, `tag_added`) Entry node code Custom postback code (max 255) Collection code to attach *** ## Delete Automation ``` DELETE /api/brands/{brand}/automations/{automation} ``` ```json Response theme={null} { "success": true } ``` *** ## Clone Automation Creates a full copy of an automation within the same brand. ``` POST /api/brands/{brand}/automations/{automation}/clone ``` New name for the clone. Defaults to `" (Copy)"`. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/automations/12/clone \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"title": "Welcome Sequence v2"}' ``` ```json Response theme={null} { "automation": { "id": 17, "title": "Welcome Sequence v2", "status": "draft" } } ``` *** ## Copy Automation to Another Brand ``` POST /api/brands/{brand}/automations/copy ``` Source automation ID Target brand ID to copy into Title for the copied automation in the target brand ```json 200 Copied theme={null} { "automation_id": 23 } ``` ```json 404 Not found theme={null} { "error": "Automation not found." } ``` *** ## Export Automations Queue a CSV export of automation records. ``` POST /api/brands/{brand}/automations/export ``` Filter conditions Limit export to specific automation IDs Columns to include ```json Response theme={null} { "success": true, "message": "Export started. You will receive an email when it is ready." } ``` *** ## Get Builder Config Returns the full visual-builder JSON for an automation. If a draft revision exists, the draft is returned instead of the published version. ``` GET /api/brands/{brand}/automations/{automation}/builder ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/automations/12/builder \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 12, "title": "Welcome Email Sequence", "config": { "nodes": { /* builder nodes */ }, "connections": { /* node connections */ } }, "version": 3, "trigger_node_type": "form_submission" } ``` ``` 200 (empty string) — automation exists but has no builder config yet ``` *** ## Save Builder Config Saves the visual-builder JSON. Pass `draft=true` to save as a draft revision without publishing. ``` POST /api/brands/{brand}/automations/{automation}/builder ``` The complete builder config object (same shape as returned by GET) If `true`, saves as a draft revision. Published configs are deployed to S3 and activate the automation. ```bash cURL — save as draft theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/automations/12/builder?draft=true" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ /* builder JSON */ }' ``` *** ## Discard Draft Revision Discards the current draft revision and reverts to the last published version. ``` POST /api/brands/{brand}/automations/{automation}/discard-revision ``` ```json Response theme={null} { "id": 12, "title": "Welcome Email Sequence", "config": { /* last published config */ } } ``` *** ## Get Automation Stats Returns execution counts per node, aggregated from the event log. ``` GET /api/brands/{brand}/automations/{automation}/stats ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/automations/12/stats \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "start-node-abc": { "success": 315, "failed": 2, "skipped": 5 }, "send-email-def": { "success": 308, "failed": 9 } } ``` *** ## Execution Summary Global summary of automation runs for the brand. ``` GET /api/brands/{brand}/automations/summary ``` ```json Response theme={null} { "total_runs": 1420, "successful_tasks": 1387, "failed_tasks": 33, "task_status_counts": { "success": 1387, "failed": 33 }, "period_start_date": "2024-12-01T00:00:00Z", "period_end_date": "2024-12-31T23:59:59Z" } ``` *** ## Execution Summary by Automation ``` GET /api/brands/{brand}/automations/summary-by-automation ``` Returns execution stats grouped per automation ID. *** ## Automation Event Log ``` GET /api/brands/{brand}/automations/{automation}/logs ``` Returns paginated execution log for a specific automation. *** ## Automation Events ``` GET /api/brands/{brand}/automations/{automation}/events ``` Returns available event types that have fired for this automation. *** ## Notes * `postback_code` is the unique trigger code used in postback URLs (`/webhooks/{code}`) * Builder configs are deployed to S3 on publish — **draft saves do not activate** the automation * Stats are read from Elasticsearch and may have a short indexing delay * The `copy` endpoint copies across brands; `clone` copies within the same brand # Blogs Source: https://docs.elasticfunnels.io/api-reference/endpoints/blogs Manage blogs, articles, and blog categories Blog endpoints sit under `/api/brands/{brand}/blogs/` with `brandAccess` middleware. Authenticate with `EF-Access-Key`. *** ## List Blogs ``` GET /api/brands/{brand}/blogs ``` Results per page (1–100, default: 25) Search by name `newest`, `oldest`, `created_at`, `updated_at` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/blogs \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 2, "name": "Health Tips Blog", "slug": "health-tips", "domain_id": 7, "status": "published", "comments_enabled": false, "article_url_pattern": "slug_only", "created_at": "2024-10-01T00:00:00.000000Z" } ], "per_page": 25, "total": 2, "last_page": 1 } ``` *** ## List Blogs (Unpaginated) ``` GET /api/brands/{brand}/blogs/all ``` Returns `{ id, name }` for all blogs with a saved config — useful for dropdowns. *** ## Get Blog ``` GET /api/brands/{brand}/blogs/{blog} ``` ```json Response theme={null} { "id": 2, "name": "Health Tips Blog", "domain_id": 7, "brand_template_id": null, "slug": "health-tips", "article_url_pattern": "slug_only", "article_url_segment": null, "category_url_segment": "category", "tag_url_segment": "tag", "search_url_segment": "search", "comments_enabled": false, "comments_require_email_confirmation": false, "seo_settings": [], "template": null } ``` *** ## Create Blog ``` POST /api/brands/{brand}/blogs ``` Blog name ID of the domain this blog lives on URL slug (kebab-case, max 255). Must be unique per brand + domain. ID of the template to apply (`context=blog`) `slug_only`, `segment`, or `category` URL prefix for articles (max 64) URL prefix for category pages (default: `category`, max 64) URL prefix for tag pages (default: `tag`, max 64) URL prefix for search results (default: `search`, max 64) Enable comment system for articles Require email confirmation before comments appear SEO metadata object (meta title, description, OG tags, Twitter card fields) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/blogs \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Health Tips Blog", "domain_id": 7, "slug": "health-tips", "article_url_pattern": "slug_only" }' ``` ```json 200 Created theme={null} { "blog": { "id": 2, "name": "Health Tips Blog", "slug": "health-tips", "domain_id": 7, "comments_enabled": false, "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } } ``` ```json 422 Missing name theme={null} { "errors": { "name": ["The name field is required."] } } ``` ```json 422 Duplicate slug theme={null} { "errors": { "slug": ["The slug has already been taken."] } } ``` `name` is **always required** — even on `PUT`/`PATCH` updates. A partial update that omits `name` will return a 422 validation error. *** ## Update Blog ``` PUT /api/brands/{brand}/blogs/{blog} ``` Same body as Create. `name` is still required. *** ## Delete Blog ``` DELETE /api/brands/{brand}/blogs/{blog} ``` ```json Response theme={null} { "blog": { /* soft-deleted blog object */ } } ``` *** ## Clone Blog Creates a copy of a blog within the same brand. ``` POST /api/brands/{brand}/blogs/{blog}/clone ``` Name for the cloned blog (max 255). Defaults to `" (Copy)"` if omitted. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/blogs/2/clone \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"title": "Health Tips Blog v2"}' ``` ```json Response theme={null} { "id": 3, "blog": { /* cloned BrandBlog model */ } } ``` *** ## Export Blogs ``` POST /api/brands/{brand}/blogs/export ``` Filter conditions (same as list query params) Limit export to specific blog IDs Columns to include ```json Response theme={null} { "success": true, "message": "Export started. You will receive an email when it is ready." } ``` *** ## Blog Categories ### List Categories ``` GET /api/brands/{brand}/blogs/{blog}/categories ``` ```json Response theme={null} [ { "id": 1, "blog_id": 2, "name": "Nutrition", "slug": "nutrition", "hero_image": null, "seo_settings": {} } ] ``` ### Create Category ``` POST /api/brands/{brand}/blogs/{blog}/categories ``` Category name (max 255) URL slug. Auto-generated from `name` if omitted; appends numeric suffix on collision. Image URL (max 2048 chars) SEO metadata (meta title, description, OG tags, Twitter card) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/blogs/2/categories \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "Nutrition", "slug": "nutrition"}' ``` ```json 201 Created theme={null} { "id": 1, "blog_id": 2, "name": "Nutrition", "slug": "nutrition" } ``` ### Update Category ``` PUT /api/brands/{brand}/blogs/{blog}/categories/{category} ``` Same body as Create (all fields optional on update). ### Delete Category ``` DELETE /api/brands/{brand}/blogs/{blog}/categories/{category} ``` ```json Response theme={null} { "deleted": true } ``` *** ## Notes * Blog slugs must be globally unique per `brand_id` + `domain_id` combination * The `clone` endpoint copies the blog's `config`, `html`, `css`, and `interactions` to the new blog. Slug uniqueness is handled automatically with a numeric suffix * The builder (`GET/POST .../blogs/{blog}/builder`) and articles CRUD endpoints are managed through the page builder and article editor controllers respectively # Brands (Projects) Source: https://docs.elasticfunnels.io/api-reference/endpoints/brands Manage your projects and retrieve project information ## Get Brand Details Retrieve details about a specific project (brand). The brand/project ID ```bash Request theme={null} GET /api/brands/{brand} ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id} \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 123, "name": "My Awesome Funnel", "subdomain": "myawesomefunnel", "timezone": "America/New_York", "currency": "USD", "subscription_status": "active", "created_at": "2024-01-15T10:30:00.000000Z", "updated_at": "2024-12-10T08:20:00.000000Z" } ``` *** ## List All Brands Get a list of all projects you have access to. ```bash Request theme={null} GET /api/brands/all ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/all \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "data": [ { "id": 123, "name": "My Awesome Funnel", "subdomain": "myawesomefunnel", "subscription_status": "active" }, { "id": 456, "name": "Another Project", "subdomain": "anotherproject", "subscription_status": "active" } ] } ``` *** ## Update Brand Update project settings (requires admin/owner permissions). The brand/project ID Project name Project timezone (e.g., "America/New\_York") Currency code (e.g., "USD", "EUR") ```bash Request theme={null} PUT /api/brands/{brand} ``` ```bash cURL theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id} \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Updated Project Name", "timezone": "America/Los_Angeles" }' ``` ```json Response theme={null} { "id": 123, "name": "Updated Project Name", "subdomain": "myawesomefunnel", "timezone": "America/Los_Angeles", "currency": "USD", "subscription_status": "active", "updated_at": "2024-12-10T15:45:00.000000Z" } ``` *** ## Get Brand Metrics Retrieve configured metrics for the project. The brand/project ID ```bash Request theme={null} GET /api/brands/{brand}/metrics ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/metrics \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "metrics": [ "orders", "revenue", "aov", "conversion_rate", "roas" ] } ``` # Collections Source: https://docs.elasticfunnels.io/api-reference/endpoints/collections Manage form collections (schemas) and their entries All Collections endpoints sit under `/api/brands/{brand}/collections/` with `moduleAccess:collections` middleware. Authenticate with `EF-Access-Key`. Collections are dynamic form schemas. Each collection has a set of typed **fields**, and every form submission creates an **entry** validated against that schema. *** ## List Collections ``` GET /api/brands/{brand}/collections ``` Results per page (1–100, default: 25) Search by name or code `newest` (default), `oldest`, `created_at`, `updated_at` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/collections \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 3, "code": "contact-form", "name": "Contact Form", "public_export": false, "total_entries": 142 } ], "per_page": 25, "total": 3, "last_page": 1 } ``` *** ## List Collections (Unpaginated) Returns `id`, `name`, `code` only — useful for dropdowns. ``` GET /api/brands/{brand}/collections/all ``` ```json Response theme={null} [ { "id": 3, "name": "Contact Form", "code": "contact-form" }, { "id": 4, "name": "Lead Capture", "code": "lead-capture" } ] ``` *** ## Get Collection Returns the full collection schema including all field definitions. ``` GET /api/brands/{brand}/collections/{collection} ``` Collection numeric ID **or** `code` slug ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/collections/contact-form \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 3, "code": "contact-form", "name": "Contact Form", "public_export": false, "total_entries": 142, "on_new_entry": "send_admin_email", "send_to_email": "admin@example.com", "recaptcha_enabled": false, "recaptcha_site_key": null, "recaptcha_secret_key": null, "ticket_source_id": null, "fields": [ { "id": 1, "code": "full_name", "name": "Full Name", "type": "text", "placeholder": "Enter your name", "options": null, "required": true, "searchable": true, "private": false, "public_encrypt": false, "public_exclude": false, "order": 0, "default_value": null }, { "id": 2, "code": "email", "name": "Email", "type": "email", "placeholder": "you@example.com", "required": true, "order": 1 } ] } ``` *** ## Get Collection Fields ``` GET /api/brands/{brand}/collections/{collection}/fields ``` Returns the raw field list for a collection (numeric ID only for this route). *** ## Create Collection ``` POST /api/brands/{brand}/collections ``` Collection name (max 255 chars) Array of field definition objects (at least one) Field label (max 255) `text`, `email`, `number`, `checkbox`, `password`, `textarea`, `select`, or `hidden` Whether the field is required on submission Input placeholder (max 255) Required when `type` is `select` — array of option strings (at least one) Default value for the field (max 255) Display order (integer, min 0) Whether entries can be searched by this field Hide field from public-facing builder views Allow public CSV download of entries `send_admin_email`, `send_email_to`, or `create_new_ticket` Required when `on_new_entry` is `send_email_to`. Valid email address. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/collections \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Lead Capture", "on_new_entry": "send_admin_email", "fields": [ { "name": "Full Name", "type": "text", "required": true, "order": 0 }, { "name": "Email", "type": "email", "required": true, "order": 1 }, { "name": "Source", "type": "select","required": false, "order": 2, "options": ["Facebook", "Google", "Organic"] } ] }' ``` ```json 200 Created theme={null} { "id": 5, "code": "lead-capture", "name": "Lead Capture", "total_entries": 0, "fields": [ /* ... */ ] } ``` ```json 422 Missing required field name theme={null} { "errors": { "fields.0.name": ["Field name is required."], "fields.0.type": ["Field type is required."] } } ``` ```json 422 Select missing options theme={null} { "errors": { "fields.2.options": ["You must provide at least one option for select fields."] } } ``` *** ## Update Collection ``` PUT /api/brands/{brand}/collections/{collection} ``` Collection numeric ID Same body as Create. To update an existing field, include its `id`; to delete a field, pass `"deleted": true` on the field object. *** ## Delete Collection ``` DELETE /api/brands/{brand}/collections/{collection} ``` ```json Response theme={null} { "success": true } ``` *** ## List Entries ``` GET /api/brands/{brand}/collections/{collection}/entries ``` Collection numeric ID Results per page (default: 30) Search entry titles ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/collections/3/entries?per_page=50" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "data": [ { "id": "es-doc-abc123", "title": "Jane Doe", "full_name": "Jane Doe", "email": "jane@example.com", "source": "Facebook", "created_at": "2024-12-10T09:00:00Z", "updated_at": "2024-12-10T09:00:00Z" } ], "current_page": 1, "total": 142 } ``` *** ## Get Entry ``` GET /api/brands/{brand}/collections/{collection}/entries/{entry} ``` Returns a single entry with all field values flattened to top-level keys. *** ## Create Entry ``` POST /api/brands/{brand}/collections/{collection}/entries ``` The request body is validated **dynamically** against the collection's field schema. Field codes (e.g. `email`, `full_name`) become the body keys. Required fields and type rules are enforced per field definition. One key per collection field, using the field's `code` as the key. Type rules: `email` → valid email, `number` → numeric, `checkbox` → boolean, `select` → must be one of the defined options, all others → string. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/collections/3/entries \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "full_name": "Jane Doe", "email": "jane@example.com", "source": "Facebook" }' ``` ```json 201 Created theme={null} { "message": "Entry created successfully", "data": { "full_name": "Jane Doe", "email": "jane@example.com", "source": "Facebook" }, "id": "es-doc-abc123" } ``` ```json 422 Invalid field value theme={null} { "errors": { "email": ["The email must be a valid email address."], "source": ["The selected source is invalid."] } } ``` *** ## Update Entry ``` PUT /api/brands/{brand}/collections/{collection}/entries/{entry} ``` Same body as Create — only provided fields are merged into the existing entry. *** ## Delete Entry ``` DELETE /api/brands/{brand}/collections/{collection}/entries/{entry} ``` *** ## Bulk Delete Entries ``` DELETE /api/brands/{brand}/collections/{collection}/entries/bulk-delete ``` Array of entry IDs to delete *** ## Export Entries Queue a CSV export of all entries in a collection. The results are emailed to the specified address. ``` POST /api/brands/{brand}/collections/{collection}/export ``` Email address to send the export to Array of field codes to include in the export Export only new entries since last export ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/collections/3/export \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"email": "admin@example.com", "fields": ["full_name", "email", "source"]}' ``` ```json Response theme={null} { "message": "Export started. You will receive an email when it is ready." } ``` # Components Source: https://docs.elasticfunnels.io/api-reference/endpoints/components Create, read, update, and delete reusable page components via API All component endpoints authenticate via `EF-Access-Key`. The brand user's role must have `components` module access. *** ## List Components Return all components for a brand with pagination. This endpoint does not include `html`, `css`, or `config` in the response — use [Get Component Content](#get-component-content) or [Get Component Builder Config](#get-component-builder-config) for full content. The brand/project ID Results per page (1–100, default: 25) Page number (default: 1) ```bash Request theme={null} GET /api/brands/{brand}/components ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/components?per_page=100" \ -H "EF-Access-Key: your_api_key_here" ``` ```javascript JavaScript theme={null} const res = await fetch( 'https://app.elasticfunnels.io/api/brands/{brand_id}/components', { headers: { 'EF-Access-Key': 'your_api_key_here' } } ); const { data, total, current_page, last_page } = await res.json(); ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 12, "name": "Hero Section", "code": "custom-hero-section", "type": "editor", "screenshot": null, "brand_id": 42, "created_at": "2024-11-01T09:00:00.000000Z", "updated_at": "2024-12-10T14:30:00.000000Z", "tags": [] }, { "id": 13, "name": "Guarantee Badge", "code": "custom-guarantee-badge", "type": null, "screenshot": null, "brand_id": 42, "created_at": "2024-11-05T10:00:00.000000Z", "updated_at": "2024-11-05T10:00:00.000000Z", "tags": [] } ], "per_page": 25, "total": 2, "last_page": 1, "from": 1, "to": 2 } ``` *** ## Get All Components (unpaginated) Return all components for a brand in a single array, without pagination (no `builder config` filter is applied). This endpoint returns a lightweight shape: `id`, `name`, `code`, `type`, `screenshot`, and (unless `?short=true`) `config`. It does **not** return `html` or `css`. For HTML use [Get Component Content](#get-component-content); for GrapesJS config use [Get Component Builder Config](#get-component-builder-config). The brand/project ID Filter by type. Pass `editor` to return only visual-builder components. When `true`, omits `config` from the response — returns only `id`, `name`, `code`, `type`, and `screenshot`. Useful for lightweight dropdowns. ```bash Request theme={null} GET /api/brands/{brand}/components/all ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/components/all" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} [ { "id": 12, "name": "Hero Section", "code": "custom-hero-section", "type": "editor", "config": { "data": { "type": "wrapper", "components": [] }, "styles": [] }, "screenshot": null } ] ``` *** ## Get Component Retrieve a single component's **metadata** (not full content). Accepts either a numeric ID or the component's `code` string. This endpoint returns only `id`, `name`, `code`, and `screenshot`. It does **not** include `html`, `css`, `config`, `type`, or timestamps. Use the content endpoints below for body data. The brand/project ID Numeric ID **or** `code` string (e.g. `custom-hero-section`) ```bash Request theme={null} GET /api/brands/{brand}/components/{component} ``` ```bash cURL (by ID) theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/components/12 \ -H "EF-Access-Key: your_api_key_here" ``` ```bash cURL (by code) theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/components/custom-hero-section \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 12, "name": "Hero Section", "code": "custom-hero-section", "screenshot": null } ``` ```json 404 Not found theme={null} { "message": "No query results for model [BrandPageComponent]." } ``` *** ## Create Component Create a new reusable component. This is the only endpoint that accepts `html`, `css`, and `config` — [Update Component](#update-component) is metadata-only. The brand/project ID Display name shown in the component library Unique identifier used to embed the component in pages (e.g. `custom-guarantee-badge`). Auto-generated if omitted. Components are automatically prefixed with `custom-`. HTML markup for the component CSS styles scoped to this component Builder config object. Required for visual-builder components; omit for plain HTML components. ```bash Request theme={null} POST /api/brands/{brand}/components ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/components \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Guarantee Badge", "code": "custom-guarantee-badge", "html": "
\"60-Day
", "css": ".badge { max-width: 200px; margin: 20px auto; text-align: center; }" }' ``` ```python Python theme={null} import requests r = requests.post( 'https://app.elasticfunnels.io/api/brands/{brand_id}/components', headers={'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json'}, json={ 'name': 'Guarantee Badge', 'code': 'custom-guarantee-badge', 'html': '
...
', 'css': '.badge { max-width: 200px; }', }, ) component = r.json()['pageComponent'] print(component['id'], component['code']) ``` ```javascript JavaScript theme={null} const res = await fetch( 'https://app.elasticfunnels.io/api/brands/{brand_id}/components', { method: 'POST', headers: { 'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Guarantee Badge', code: 'custom-guarantee-badge', html: '
...
', css: '.badge { max-width: 200px; }', }), } ); const { pageComponent } = await res.json(); ``` ```json 200 Created theme={null} { "pageComponent": { "id": 14, "name": "Guarantee Badge", "code": "custom-guarantee-badge", "type": null, "html": "
...
", "css": ".badge { max-width: 200px; margin: 20px auto; text-align: center; }", "brand_id": 42, "created_at": "2024-12-10T16:00:00.000000Z", "updated_at": "2024-12-10T16:00:00.000000Z" } } ``` ```json 422 Validation error theme={null} { "errors": { "name": ["The name field is required."] } } ```
*** ## Update Component Update a component's **metadata only** — name and code slug. This endpoint does **not** accept or persist `html`, `css`, or `config`. To update a component's HTML content use [Update Component Content](#update-component-content). To replace a component entirely, delete it and re-create it with [Create Component](#create-component). **`html`, `css`, and `config` are silently ignored on PUT.** The endpoint returns `200` even if you include them — but the content is not saved. The brand/project ID Numeric ID **or** `code` string Display name. **Required even if you are not changing it** — send the current name. Rename the component's code slug. If changed, **all pages that reference this component are automatically updated** to use the new code. ```bash Request theme={null} PUT /api/brands/{brand}/components/{component} ``` ```bash cURL (rename code) theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/components/14 \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Guarantee Badge", "code": "custom-guarantee-v2" }' ``` ```bash cURL (rename by current code) theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/components/custom-guarantee-badge \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Guarantee Badge v2" }' ``` ```json 200 Updated theme={null} { "id": 14, "name": "Guarantee Badge v2", "code": "custom-guarantee-badge", "type": null, "brand_id": 42, "updated_at": "2024-12-11T09:15:00.000000Z" } ``` ```json 422 Missing required name theme={null} { "errors": { "name": ["The name field is required."] } } ``` ```json 404 Component not found theme={null} { "message": "No query results for model [BrandPageComponent]." } ``` **Common causes of errors on PUT:** | Symptom | Cause | Fix | | - | - | - | | `422` with `name is required` | `name` is always required by the validator, even if unchanged | Always include `"name"` in the body | | `422` code uniqueness error | The new `code` is already taken by another component in the same brand | Choose a unique code | | `401` redirect to login | Missing or invalid `EF-Access-Key` header, or key doesn't belong to this brand | Check header name and key value | | `403` module access | Brand user role doesn't have `components` access | Update role permissions in Project Settings | | `html`/`css`/`config` not updating | These fields are not writable via PUT | Use `POST /{component}/editor` to update HTML; use DELETE + POST to replace a component entirely | *** ## Update Component Content Update the HTML content of a component. Use this endpoint to publish new HTML or save a draft for later review. This is separate from [Update Component](#update-component), which only handles metadata. The brand/project ID Numeric ID **or** `code` string The HTML content to save When `true`, the HTML is saved as a draft revision and the live component is **not** updated. Draft revisions are returned by [Get Component Content](#get-component-content) until discarded or published. When omitted (or `false`), the HTML is published immediately and any existing draft revisions are discarded. When saving a draft, pass an existing `revision_id` to update that specific draft instead of creating a new one. When publishing (not a draft), pass the `revision_id` you last read. If the server has a newer draft, the request returns **409** with `code: "revision_conflict"`. ```bash Request theme={null} POST /api/brands/{brand}/components/{component}/editor ``` ```bash cURL (publish) theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/components/custom-hero-section/editor \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "html": "

Updated headline

" }' ``` ```bash cURL (save as draft) theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/components/custom-hero-section/editor?draft=true" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "html": "

Draft headline

" }' ``` ```javascript JavaScript (publish) theme={null} await fetch( 'https://app.elasticfunnels.io/api/brands/{brand_id}/components/custom-hero-section/editor', { method: 'POST', headers: { 'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json', }, body: JSON.stringify({ html: '

Updated

' }), } ); ``` ```json 200 Published theme={null} { "success": true, "revision_id": null } ``` ```json 200 Saved as draft theme={null} { "success": true, "revision_id": 88 } ``` ```json 409 Revision conflict (publish) theme={null} { "success": false, "message": "Revision conflict: component changed before publish.", "code": "revision_conflict", "latest_revision_id": 89 } ``` *** ## Get Component Content Read the current content of a component. If a draft revision exists, the draft HTML is returned along with its `revision_id`. Pass `?published=true` to always get the live published content, ignoring any draft. The brand/project ID Numeric ID **or** `code` string When `true`, returns the published (live) HTML even if a draft revision exists. ```bash Request theme={null} GET /api/brands/{brand}/components/{component}/editor ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/components/custom-hero-section/editor" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response (no draft) theme={null} { "id": 12, "name": "Hero Section", "code": "custom-hero-section", "html": "

Published headline

" } ``` ```json Response (draft exists) theme={null} { "id": 12, "name": "Hero Section", "code": "custom-hero-section", "html": "

Draft headline

", "revision_id": 88 } ```
*** ## Discard Draft Discard all draft revisions for a component, reverting to the live published content. The brand/project ID Numeric ID **or** `code` string ```bash Request theme={null} POST /api/brands/{brand}/components/{component}/editor/discard ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/components/custom-hero-section/editor/discard \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "success": true } ``` *** ## Get Component Builder Config Read the full builder config for a component, ready to load into the builder. The server injects brand assets into `config.assets`, normalizes style selectors, and removes empty ID components before returning. The brand/project ID Numeric ID **or** `code` string Load a specific revision's config instead of the current published state. ```bash Request theme={null} GET /api/brands/{brand}/components/{component}/builder ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/components/12/builder" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "pageComponents": [ { "frames": [ { "component": { "type": "wrapper", "stylable": ["background", "background-color", "background-image"], "components": [ { "type": "text", "attributes": { "id": "c12-hero-heading" }, "components": [{ "type": "textnode", "content": "Hello World" }] } ] } } ], "type": "main" } ], "styles": [ { "selectors": ["#c12-hero-heading"], "style": { "color": "#333333", "font-size": "32px", "font-weight": "700" } }, { "selectors": ["#c12-hero-heading"], "mediaText": "768px", "atRuleType": "media", "style": { "font-size": "22px" } } ], "assets": [ { "src": "https://cdn.elasticfunnels.io/42/assets/media/logo.png", "type": "image" } ], "interactions": [], "fonts": [] } ``` *** ## Save Component Builder Config Save a full builder config — component tree, styles, rendered HTML, CSS, and interactions — for a component. The server processes the payload before storing it and returns the processed result. **You must replace your local state with the returned config and HTML** before any subsequent save (see [Server-side processing](#server-side-processing) below). The brand/project ID Numeric ID **or** `code` string The full builder config as a **JSON string** (not a nested object — stringify it before sending). Rendered HTML output from the builder Rendered CSS output from the builder Interaction definitions attached to this component Omit (or any value other than `revision`) to publish immediately. Pass `revision` to save as a draft without touching the live component. When saving a draft (`type=revision`), pass an existing `revision_id` to update that draft instead of creating a new one. Revisions older than 1 hour are always replaced with a new draft regardless. ```bash Request theme={null} POST /api/brands/{brand}/components/{component}/builder ``` ```bash cURL (publish) theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/components/12/builder \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "config": "{\"pageComponents\":[{\"frames\":[{\"component\":{\"type\":\"wrapper\",\"components\":[{\"type\":\"text\",\"attributes\":{\"id\":\"hero-heading\"},\"components\":[{\"type\":\"textnode\",\"content\":\"Hello World\"}]}]}}],\"type\":\"main\"}],\"styles\":[{\"selectors\":[\"#hero-heading\"],\"style\":{\"color\":\"#333\",\"font-size\":\"32px\"}}],\"interactions\":[],\"fonts\":[]}", "html": "
Hello World
", "css": "#hero-heading { color: #333; font-size: 32px; }" }' ``` ```bash cURL (save as draft) theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/components/12/builder \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "type": "revision", "config": "...", "html": "...", "css": "..." }' ``` ```javascript JavaScript theme={null} const config = { /* your builder config object */ }; const res = await fetch( 'https://app.elasticfunnels.io/api/brands/{brand_id}/components/12/builder', { method: 'POST', headers: { 'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json', }, body: JSON.stringify({ config: JSON.stringify(config), // must be a string html: renderedHtml, css: renderedCss, interactions: [], }), } ); // IMPORTANT: replace your local state with the server-processed result const { config: processedConfig } = await res.json(); localConfig = processedConfig; ``` ```json 200 Published theme={null} { "id": 12, "name": "Hero Section", "code": "custom-hero-section", "config": { "pageComponents": [ { "frames": [ { "component": { "type": "wrapper", "components": [ { "type": "text", "attributes": { "id": "c12-hero-heading" }, "components": [{ "type": "textnode", "content": "Hello World" }] } ] } } ], "type": "main" } ], "styles": [ { "selectors": ["#c12-hero-heading"], "style": { "color": "#333", "font-size": "32px" } } ], "interactions": [], "fonts": [] } } ``` ```json 200 Saved as draft theme={null} { "id": 12, "name": "Hero Section", "code": "custom-hero-section", "config": { "...": "processed config" }, "revision_id": 44 } ``` ```json 422 Malformed config theme={null} { "message": "Malformed data structure detected. Please refresh the page and try again." } ``` ### Server-side processing The server modifies the config, HTML, and CSS you send before storing them. The response always contains the **processed** versions. You must store and re-send the returned values — not your original pre-send state — otherwise the next save will be operating on stale data. What gets modified: | Field | What happens | | - | - | | `config.styles[*].selectors` | All `#id` selectors are normalized to `#c{componentId}-{baseId}`. Any existing `c\d+-` prefixes are stripped first, then the current component ID prefix is applied. This makes cloned and imported components safe to save without selector collisions. | | `config` component tree `attributes.id` | Same scoping applied recursively to every component node's `id` attribute. | | `html` element IDs and anchor hrefs | `id="..."` and `href="#..."` values are rewritten to `c{componentId}-{baseId}` format. | | `css` ID selectors | Same prefix normalization. Hex color values (`#fff`, `#333333`, etc.) are left untouched. | | `interactions` target/trigger selectors | `#id` references in interaction targets and trigger elements are rewritten to match. | | `config.styles` | Empty `state` fields removed; `atRuleType: 'media'` is set automatically when `mediaText` is present. | **Corruption guard**: If `config.data[0]` has a single key `"0"` (a known serialization artifact), the save is aborted with `422` to prevent data loss. This means the config was malformed before being sent — fetch a fresh copy with `GET /builder` and retry. **Snapshot on publish**: Before overwriting the live component, the server creates a revision snapshot of the current published state (at most once per hour). These snapshots are accessible via [Get Component Revisions](#get-component-revisions). *** ## Get Component Revisions List revision history for a component. Revisions are created automatically as hourly snapshots when publishing, and explicitly when saving a draft. The brand/project ID Numeric ID **or** `code` string ```bash Request theme={null} GET /api/brands/{brand}/components/{component}/revisions ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/components/12/revisions" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} [ { "id": 44, "created_at": "2024-12-11T09:00:00.000000Z", "updated_at": "2024-12-11T09:00:00.000000Z", "updated_by": 7, "updated_by_user": { "id": 7, "name": "Jane Smith", "email": "jane@example.com" } }, { "id": 43, "created_at": "2024-12-10T14:00:00.000000Z", "updated_at": "2024-12-10T14:00:00.000000Z", "updated_by": null, "updated_by_user": null } ] ``` *** ## Delete Component Delete a component. If the component is still referenced on one or more pages, the request returns a `409` error to prevent accidental data loss. Pass `?force=1` to delete regardless. The brand/project ID Numeric ID **or** `code` string Pass `1` to delete even if the component is still referenced on pages. Pages that reference the component by code will render an empty slot where the component was. ```bash Request theme={null} DELETE /api/brands/{brand}/components/{component} ``` ```bash cURL theme={null} curl -X DELETE https://app.elasticfunnels.io/api/brands/{brand_id}/components/14 \ -H "EF-Access-Key: your_api_key_here" ``` ```bash cURL (force delete) theme={null} curl -X DELETE "https://app.elasticfunnels.io/api/brands/{brand_id}/components/14?force=1" \ -H "EF-Access-Key: your_api_key_here" ``` ```json 200 Deleted theme={null} { "success": true } ``` ```json 409 Component in use theme={null} { "error": "component_in_use", "message": "Component is still used in 3 page(s). Pass force=1 to delete anyway.", "usage_count": 3 } ``` ```json 404 Not found theme={null} { "message": "No query results for model [BrandPageComponent]." } ``` *** ## Get Component Usage Check how many pages reference a component. Optionally return the list of pages for display in tooling. The brand/project ID Numeric ID **or** `code` string Pass `pages` to include the list of pages that reference this component (capped at 50). ```bash Request theme={null} GET /api/brands/{brand}/components/{component}/usage ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/components/custom-guarantee-badge/usage?include=pages" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "component_id": 14, "component_code": "custom-guarantee-badge", "usage_count": 3, "pages": [ { "id": 101, "slug": "checkout", "title": "Checkout Page" }, { "id": 102, "slug": "upsell-1", "title": "Upsell Step 1" }, { "id": 105, "slug": "landing-v2", "title": "Landing Page v2" } ] } ``` *** ## Clone Component Create a copy of a component within the same brand. The clone gets a new auto-generated code and can optionally receive a new name. The brand/project ID Numeric ID **or** `code` string of the component to clone Name for the cloned component. If omitted, the trailing number in the original name is auto-incremented (e.g. `Hero 1` → `Hero 2`). ```bash Request theme={null} POST /api/brands/{brand}/components/{component}/clone ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/components/14/clone \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Guarantee Badge (Copy)" }' ``` ```json Response theme={null} { "component_id": 15 } ``` *** ## Automated Component Provisioning Provision a full set of reusable brand components in one run. Because [PUT does not update content](#update-component), the correct pattern for programmatic provisioning is to check whether each component exists first, then create it if it does not — not an upsert. ```python Python theme={null} import requests BRAND_ID = 42 BASE = f'https://app.elasticfunnels.io/api/brands/{BRAND_ID}' CDN = f'https://cdn.elasticfunnels.io/{BRAND_ID}/assets/media' H = {'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json'} def get_existing_codes() -> set: """Fetch all component codes already in the brand.""" r = requests.get(f'{BASE}/components/all?short=true', headers=H) r.raise_for_status() return {c['code'] for c in r.json()} def create_component(code: str, name: str, html: str, css: str = '') -> dict: payload = {'name': name, 'code': code, 'html': html, 'css': css} r = requests.post(f'{BASE}/components', headers=H, json=payload) r.raise_for_status() comp = r.json()['pageComponent'] print(f' ✓ created {code} (id={comp["id"]})') return comp COMPONENTS = [ { 'code': 'custom-refund-icon', 'name': 'Refund Icon', 'html': f'
Money Back Guarantee
', 'css': '.refund { max-width: 80px; margin: 0 auto; }', }, { 'code': 'custom-guarantee-badge', 'name': '60-Day Guarantee Badge', 'html': f'
60-Day Money Back
', 'css': '.guarantee { max-width: 200px; margin: 20px auto; text-align: center; }', }, { 'code': 'custom-trust-seal', 'name': 'Trust Seal', 'html': f'
Secure Checkout
', 'css': '.trust { display: flex; justify-content: center; }', }, { 'code': 'custom-cc-icons', 'name': 'Credit Card Icons', 'html': f'
Visa Mastercard Amex
', 'css': '.cc { max-width: 260px; margin: 0 auto; }', }, ] print(f'Provisioning components for brand {BRAND_ID}…') existing = get_existing_codes() for comp in COMPONENTS: if comp['code'] in existing: print(f' – skipped {comp["code"]} (already exists)') else: create_component(**comp) print('Done.') ``` ```javascript Node.js theme={null} const BRAND_ID = 42; const BASE = `https://app.elasticfunnels.io/api/brands/${BRAND_ID}`; const CDN = `https://cdn.elasticfunnels.io/${BRAND_ID}/assets/media`; const H = { 'EF-Access-Key': API_KEY, 'Content-Type': 'application/json' }; async function getExistingCodes() { const res = await fetch(`${BASE}/components/all?short=true`, { headers: H }); if (!res.ok) throw new Error(`Failed to fetch components: ${res.status}`); const data = await res.json(); return new Set(data.map(c => c.code)); } async function createComponent({ code, name, html, css = '' }) { const res = await fetch(`${BASE}/components`, { method: 'POST', headers: H, body: JSON.stringify({ name, code, html, css }), }); if (!res.ok) throw new Error(`${code}: ${res.status} ${await res.text()}`); const { pageComponent } = await res.json(); console.log(` ✓ created ${code} (id=${pageComponent.id})`); return pageComponent; } const components = [ { code: 'custom-guarantee-badge', name: '60-Day Guarantee Badge', html: `
Guarantee
`, css: '.guarantee { max-width: 200px; margin: 20px auto; text-align: center; }' }, { code: 'custom-trust-seal', name: 'Trust Seal', html: `
Secure
`, css: '.trust { display: flex; justify-content: center; }' }, ]; const existing = await getExistingCodes(); for (const c of components) { if (existing.has(c.code)) { console.log(` – skipped ${c.code} (already exists)`); } else { await createComponent(c); } } ``` # Conversions Source: https://docs.elasticfunnels.io/api-reference/endpoints/conversions Retrieve and manage order conversions ## List Conversions Retrieve all conversions (orders) for a project. The brand/project ID Page number for pagination (default: 1) Number of results per page (default: 15) Search by order code, customer email, or name ```bash Request theme={null} GET /api/brands/{brand}/conversions ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/conversions?page=1&per_page=15" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "data": [ { "code": "ORD-12345", "customer_email": "customer@example.com", "customer_name": "John Doe", "total": 149.97, "currency": "USD", "status": "completed", "created_at": "2024-12-10T10:30:00.000000Z" } ], "meta": { "current_page": 1, "per_page": 15, "total": 150 } } ``` ### Funnel attribution on conversions List and detail responses can include a nested **`funnel`** object (`id`, `title`) only when the conversion was stored with a `funnel_id` — that comes from matching an **internal EF click** (click code / session), not from the merchant postback alone. | Field / signal | Meaning | | - | - | | `aff_source: "buygoods"` | Sale was recorded via a **Buygoods postback** — it does **not** mean a funnel was matched | | Empty / missing `funnel` | No internal click ID was matched — common when the buyer reached checkout **directly** (e.g. Buygoods checkout URL) instead of through an **EF-tracked buy link** with a click/subid EF recognizes | | `funnel` populated | Postback (or checkout) resolved to an EF click that had `funnel_id` on the session | There is no API setting to backfill funnel on postbacks without click attribution. To get funnel on affiliate sales, traffic must pass through EF-tracked links so Buygoods (or other networks) send back a subid/click EF can resolve. *** ## Get Conversion Details Retrieve details about a specific conversion. The brand/project ID The order code ```bash Request theme={null} GET /api/brands/{brand}/conversions/{code} ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/conversions/ORD-12345 \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "code": "ORD-12345", "customer_email": "customer@example.com", "customer_name": "John Doe", "customer_phone": "+1234567890", "total": 149.97, "currency": "USD", "status": "completed", "items": [ { "product_name": "Main Product", "price": 49.99, "quantity": 1, "type": "main" }, { "product_name": "Upsell Product", "price": 99.98, "quantity": 2, "type": "upsell" } ], "shipping_address": { "address_1": "123 Main St", "city": "New York", "state": "NY", "zip": "10001", "country": "US" }, "created_at": "2024-12-10T10:30:00.000000Z" } ``` *** ## Export Conversions Export conversions to CSV. The brand/project ID Start date (YYYY-MM-DD) End date (YYYY-MM-DD) Filter by status: `completed`, `refunded`, `chargeback` ```bash Request theme={null} POST /api/brands/{brand}/conversions/export ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/conversions/export \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "start_date": "2024-12-01", "end_date": "2024-12-10" }' ``` ```json Response theme={null} { "message": "Export is being processed", "export_id": "exp_abc123", "download_url": "https://app.elasticfunnels.io/api/brands/{brand_id}/exports/exp_abc123/download/{token}" } ``` *** ## Refund Conversion Process a refund for a conversion (requires admin/owner permissions). The brand/project ID The order code Refund amount (optional, defaults to full refund) Refund reason ```bash Request theme={null} POST /api/brands/{brand}/conversions/{code}/refund ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/conversions/ORD-12345/refund \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "reason": "Customer request" }' ``` ```json Response theme={null} { "message": "Refund processed successfully", "code": "ORD-12345", "refund_amount": 149.97, "status": "refunded" } ``` # Courses Source: https://docs.elasticfunnels.io/api-reference/endpoints/courses Manage online courses, modules, content, and student enrollments Course endpoints sit under `/api/brands/{brand}/courses/` with `brandAccess` middleware. Authenticate with `EF-Access-Key`. *** ## List Courses ``` GET /api/brands/{brand}/courses ``` Results per page (1–100, default: 25) Search by title, description, slug, or instructor name `newest`, `oldest`, `created_at`, `updated_at` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/courses \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 1, "title": "Gut Health Masterclass", "slug": "gut-health", "status": "published", "delivery_type": "module", "language_code": "en", "instructor_name": "Dr. Jane Smith", "domain_id": 7, "modules_count": 6, "enrollments_count": 124 } ], "per_page": 25, "total": 3, "last_page": 1 } ``` *** ## List Courses (Unpaginated) ``` GET /api/brands/{brand}/courses/all ``` Returns `{ id, title }` for all courses — useful for dropdowns. *** ## Get Course ``` GET /api/brands/{brand}/courses/{course} ``` ```json Response theme={null} { "id": 1, "title": "Gut Health Masterclass", "slug": "gut-health", "status": "published", "delivery_type": "module", "language_code": "en", "description": "Learn how to improve your gut health...", "instructor_name": "Dr. Jane Smith", "instructor_photo": "https://cdn.elasticfunnels.io/42/assets/media/instructor.jpg", "logo": null, "cover": "https://cdn.elasticfunnels.io/42/assets/media/cover.jpg", "domain_id": 7, "domain": { "id": 7, "domain": "courses.mybrand.com" }, "show_module_titles": true, "allow_comments": false, "details_page_id": null, "module_page_id": 101, "content_page_id": 102, "enrollment_email_template_id": null, "enrollments_count": 124 } ``` *** ## Create Course ``` POST /api/brands/{brand}/courses ``` Course title (max 255) Language code (e.g. `en`, `pt`) Course description `draft` or `published` `embedded` (content lives inline on a page) or `module` (dedicated course pages on a domain) Required when `delivery_type` is `module`. Must be an existing brand domain. Required when `delivery_type` is `module`. Must be unique per `brand_id` + `domain_id`. Instructor display name (max 255) Instructor photo — upload as file (multipart) or provide as URL string Course logo — upload as file or URL string Cover image — upload as file or URL string Show module titles in the course outline Enable commenting on content Course category name (max 255) Page ID for the course details/landing page Page ID for module listing Page ID for content viewing Email template ID to send upon enrollment ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/courses \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "Gut Health Masterclass", "language_code": "en", "description": "Learn how to improve your gut health naturally.", "status": "draft", "delivery_type": "module", "domain_id": 7, "slug": "gut-health", "instructor_name": "Dr. Jane Smith" }' ``` ```json 200 Created theme={null} { "course": { "id": 1, "title": "Gut Health Masterclass", "slug": "gut-health", "status": "draft", "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } } ``` ```json 403 Plan limit theme={null} { "error": "Upgrade required to create courses", "plan_error": { "message": "Your current plan does not allow creating courses.", "upgrade_info": {} } } ``` *** ## Update Course ``` PUT /api/brands/{brand}/courses/{course} ``` Same body as Create. `title`, `language_code`, `description`, `status`, and `delivery_type` are required. *** ## Delete Course ``` DELETE /api/brands/{brand}/courses/{course} ``` ```json Response theme={null} { "success": true } ``` *** ## Copy Course to Another Brand ``` POST /api/brands/{brand}/courses/copy ``` Source course ID Target brand ID Title for the copied course in the target brand ```json Response theme={null} { "course_id": 5 } ``` *** ## Enrollments ### List Enrollments ``` GET /api/brands/{brand}/courses/{course}/enrollments ``` ```json Response theme={null} { "data": [ { "id": "es-enroll-abc123", "email": "student@example.com", "name": "Jane Student", "user_id": 99, "notes": null, "created_at": "2024-12-01T00:00:00Z", "completed_content_ids": [10, 11], "progress_percent": 33, "last_content_id": 11, "last_module_id": 2 } ] } ``` ### Get Enrollment ``` GET /api/brands/{brand}/courses/{course}/enrollments/{enrollment} ``` Elasticsearch document ID for the enrollment ```json Response theme={null} { "data": { /* same enrollment object */ } } ``` ### Enroll Student ``` POST /api/brands/{brand}/courses/{course}/enrollments ``` Student email address. Returns 422 if already enrolled. Student display name (max 255) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/courses/1/enrollments \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"email": "student@example.com", "name": "Jane Student"}' ``` ```json 201 Enrolled theme={null} { "data": { "id": "es-enroll-abc123", "email": "student@example.com", "name": "Jane Student", "created_at": "2024-12-11T10:00:00Z" } } ``` ```json 422 Already enrolled theme={null} { "message": "Failed to create enrollment", "errors": { "email": ["This student is already enrolled in this course."] } } ``` ### Update Enrollment Notes ``` PUT /api/brands/{brand}/courses/{course}/enrollments/{enrollment} ``` Internal notes about the enrollment (max 10,000 chars) ### Complete Content Item Mark a specific content item as completed for an enrolled student. ``` POST /api/brands/{brand}/courses/{course}/enrollments/{enrollment}/complete ``` The course content item ID to mark as complete ```json Response theme={null} { "data": { "completed_content_ids": [10, 11, 12], "progress_percent": 50, "last_content_id": 12 } } ``` This endpoint is **idempotent** — marking an already-completed item again does not create duplicates. ### Remove Enrollment ``` DELETE /api/brands/{brand}/courses/{course}/enrollments/{enrollment} ``` ```json Response theme={null} { "success": true } ``` *** ## Modules ### List Modules ``` GET /api/brands/{brand}/courses/{course}/modules ``` ```json Response theme={null} { "data": [ { "id": 3, "title": "Module 1: Introduction", "description": "Overview of gut health fundamentals.", "order": 1, "contents": [ { "id": 10, "title": "What is Gut Flora?", "type": "video", "order": 1 }, { "id": 11, "title": "The Gut-Brain Axis", "type": "text", "order": 2 } ] } ] } ``` ### List Modules (Unpaginated) ``` GET /api/brands/{brand}/courses/{course}/modules/all ``` Returns `{ id, title }` array — cached. ### Create Module ``` POST /api/brands/{brand}/courses/{course}/modules ``` Module title (max 255) Module description ### Update Module ``` PUT /api/brands/{brand}/courses/{course}/modules/{module} ``` ### Delete Module ``` DELETE /api/brands/{brand}/courses/{course}/modules/{module} ``` ### Reorder Modules ``` POST /api/brands/{brand}/courses/{course}/modules/order ``` Map of `{ "": , ... }` *** ## Notes * Enrollment IDs are Elasticsearch document IDs (strings), not numeric database IDs * File uploads (`logo`, `cover`, `instructor_photo`) are stored on BunnyCDN — you can provide a URL string instead of uploading a file * The `copy` endpoint replicates the course's modules and all content to the target brand * `progress_percent` is recalculated automatically when content is marked complete # CRM Source: https://docs.elasticfunnels.io/api-reference/endpoints/crm Manage CRM entities, pipelines, stages, custom fields, and entries All CRM endpoints are under the prefix `/api/brands/{brand}/crm/` and authenticate via `EF-Access-Key`. The CRM is structured as: ``` Entity (e.g. "Leads", "Deals") └── Pipeline (e.g. "Sales Pipeline") └── Stage (e.g. "Prospect", "Qualified", "Closed") └── Fields (custom fields defined per entity) └── Entries (the actual records) ``` *** ## Entity Presets Before creating a custom entity you can list and apply built-in presets (e.g. Leads, Contacts, Deals) which come pre-configured with pipelines and fields. ``` GET /api/brands/{brand}/crm/entity-presets ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entity-presets \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "presets": [ { "key": "leads", "label": "Leads", "description": "Track inbound leads through a sales process" }, { "key": "contacts", "label": "Contacts", "description": "Manage contacts and customers" }, { "key": "deals", "label": "Deals", "description": "Deal tracking with revenue stages" } ] } ``` *** ## Entities Entities define the type of record tracked in the CRM (e.g. "Leads", "Customers"). Each brand can have multiple entities. ### List Entities ``` GET /api/brands/{brand}/crm/entities ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entities \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} [ { "id": 1, "name": "Leads", "slug": "leads", "singular_name": "Lead", "plural_name": "Leads", "icon": "user", "color": "#3b82f6", "brand_id": 42, "pipelines_count": 1, "fields_count": 5 } ] ``` ### Get Entity ``` GET /api/brands/{brand}/crm/entities/{entity} ``` Returns the entity with its full `pipelines` (including `stages`) and `fields` loaded. ```json Response theme={null} { "id": 1, "name": "Leads", "slug": "leads", "pipelines": [ { "id": 1, "name": "Sales Pipeline", "stages": [ { "id": 1, "name": "New Lead", "position": 0, "color": "#94a3b8" }, { "id": 2, "name": "Contacted", "position": 1, "color": "#3b82f6" }, { "id": 3, "name": "Qualified", "position": 2, "color": "#10b981" }, { "id": 4, "name": "Closed Won", "position": 3, "color": "#22c55e" } ] } ], "fields": [ { "id": 1, "name": "Email", "key": "email", "type": "text" }, { "id": 2, "name": "Phone", "key": "phone", "type": "text" }, { "id": 3, "name": "Source", "key": "source", "type": "select", "options": ["Facebook", "Google", "Organic"] } ] } ``` ### Create Entity ``` POST /api/brands/{brand}/crm/entities ``` Display name (e.g. `Leads`) URL-safe identifier — lowercase alphanumeric, hyphens, underscores (e.g. `leads`). Must be unique per brand. Singular label (e.g. `Lead`) Plural label (e.g. `Leads`) Icon identifier (max 64 chars) Hex color (max 64 chars) Optional preset key from [Entity Presets](#entity-presets). When set, pre-populates pipelines and fields. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entities \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Leads", "slug": "leads", "singular_name": "Lead", "plural_name": "Leads", "icon": "user", "color": "#3b82f6", "preset": "leads" }' ``` ```json 201 Created theme={null} { "id": 1, "name": "Leads", "slug": "leads", "singular_name": "Lead", "plural_name": "Leads", "icon": "user", "color": "#3b82f6", "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } ``` ```json 422 Duplicate slug theme={null} { "errors": { "slug": ["The slug has already been taken."] } } ``` ### Update Entity ``` PUT /api/brands/{brand}/crm/entities/{entity} ``` Same fields as Create, all optional on update. ### Delete Entity ``` DELETE /api/brands/{brand}/crm/entities/{entity} ``` *** ## Pipelines Pipelines belong to an entity and contain an ordered list of stages. ### List Pipelines ``` GET /api/brands/{brand}/crm/entities/{entity}/pipelines ``` ### Get Pipeline ``` GET /api/brands/{brand}/crm/pipelines/{pipeline} ``` ### Create Pipeline ``` POST /api/brands/{brand}/crm/entities/{entity}/pipelines ``` Pipeline name (e.g. `Sales Pipeline`) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entities/1/pipelines \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "Sales Pipeline"}' ``` ```json 201 Created theme={null} { "id": 1, "name": "Sales Pipeline", "crm_entity_id": 1, "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } ``` ### Update Pipeline ``` PUT /api/brands/{brand}/crm/pipelines/{pipeline} ``` ### Delete Pipeline ``` DELETE /api/brands/{brand}/crm/pipelines/{pipeline} ``` *** ## Stages Stages belong to a pipeline and represent progress steps (e.g. "Prospect → Qualified → Closed"). ### List Stages ``` GET /api/brands/{brand}/crm/pipelines/{pipeline}/stages ``` ### Get Stage ``` GET /api/brands/{brand}/crm/stages/{stage} ``` ### Create Stage ``` POST /api/brands/{brand}/crm/pipelines/{pipeline}/stages ``` Stage name (e.g. `Qualified`) Hex color for the stage badge Sort order (0-based integer). Use [Reorder](#reorder-stages) to batch-update positions. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/crm/pipelines/1/stages \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "Qualified", "color": "#10b981", "position": 2}' ``` ```json 201 Created theme={null} { "id": 3, "name": "Qualified", "color": "#10b981", "position": 2, "pipeline_id": 1, "created_at": "2024-12-11T10:00:00.000000Z" } ``` ### Update Stage ``` PUT /api/brands/{brand}/crm/stages/{stage} ``` ### Reorder Stages ``` PUT /api/brands/{brand}/crm/stages/reorder ``` Array of `{id, position}` objects in the desired order. ```bash cURL theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/crm/stages/reorder \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "stages": [ {"id": 1, "position": 0}, {"id": 3, "position": 1}, {"id": 2, "position": 2}, {"id": 4, "position": 3} ] }' ``` ### Delete Stage ``` DELETE /api/brands/{brand}/crm/stages/{stage} ``` *** ## Custom Fields Custom fields define the schema for entry data. Each entity has its own set of fields. ### List Fields ``` GET /api/brands/{brand}/crm/entities/{entity}/fields ``` ### Get Field ``` GET /api/brands/{brand}/crm/fields/{field} ``` ### Create Field ``` POST /api/brands/{brand}/crm/entities/{entity}/fields ``` Field label (e.g. `Email Address`) Slug key used in `values` on entries (e.g. `email`). Lowercase alphanumeric + underscores. Field type: `text`, `number`, `date`, `boolean`, `select`, `multi_select`, `url`, `email`, `phone` For `select` and `multi_select` types — array of option strings. Whether this field is required on entry creation (default: `false`) Display order Optional reporting metadata. Supported keys: `reportable` (boolean), `aggregation_type` (`numeric`, `categorical`, `date`, `boolean`), `baseline` (`min`/`max` numbers), `bucket_size` (positive number), and `preferred_chart` (`kpi`, `histogram`, `pie`, `bar`, `line`). ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entities/1/fields \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Lead Source", "key": "lead_source", "type": "select", "options": ["Facebook", "Google", "Organic", "Referral"], "reporting_config": { "reportable": true, "aggregation_type": "categorical", "preferred_chart": "bar" }, "required": false, "position": 2 }' ``` ```json 201 Created theme={null} { "id": 4, "name": "Lead Source", "key": "lead_source", "type": "select", "options": ["Facebook", "Google", "Organic", "Referral"], "reporting_config": { "reportable": true, "aggregation_type": "categorical", "preferred_chart": "bar" }, "required": false, "position": 2, "crm_entity_id": 1, "created_at": "2024-12-11T10:00:00.000000Z" } ``` ### Update Field ``` PUT /api/brands/{brand}/crm/fields/{field} ``` Accepts the same editable field properties as Create Field, including `reporting_config`. ### Delete Field ``` DELETE /api/brands/{brand}/crm/fields/{field} ``` *** ## Entries Entries are the individual records in a CRM entity (e.g. a single lead or deal). They are stored and queried via Elasticsearch. ### List Entries ``` GET /api/brands/{brand}/crm/entities/{entity}/entries ``` Entity ID Results per page (max 100, default: 25) Page number (default: 1) Filter by pipeline Filter by stage Filter by external reference type (e.g. `conversion`, `lead`) Filter by external reference ID Full-text search on `title` Attach matched customer record to each entry (default: `false`) ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entities/1/entries?pipeline_id=1&stage_id=2&per_page=50" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "data": [ { "id": "es-doc-id-abc", "title": "Jane Doe", "pipeline_id": 1, "stage_id": 2, "crm_entity_id": 1, "brand_id": 42, "reference_type": "conversion", "reference_id": "conv-123", "assigned_to_user_id": null, "values": [ { "key": "email", "value": "jane@example.com" }, { "key": "lead_source", "value": "Facebook" } ], "values_flat": { "email": "jane@example.com", "lead_source": "Facebook" }, "updated_at": "2024-12-10T14:00:00Z" } ], "current_page": 1, "last_page": 4, "per_page": 25, "total": 87 } ``` ### Get Entry ``` GET /api/brands/{brand}/crm/entries/{entry} GET /api/brands/{brand}/crm/entities/{entity}/entries/{entry} ``` ### Create Entry ``` POST /api/brands/{brand}/crm/entities/{entity}/entries ``` Human-readable label for the entry (e.g. the lead's name or email) ID of the pipeline this entry belongs to ID of the initial stage Flat key-value object of custom field values (e.g. `{"email": "jane@example.com", "lead_source": "Facebook"}`). Keys must match a defined field `key` on the entity. External ID to link the entry to another record (e.g. a conversion ID) Type of external reference (e.g. `conversion`, `lead`, `order`) Assign to a specific team member by user ID Assign to a CRM team by team ID ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entities/1/entries \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "Jane Doe", "pipeline_id": 1, "stage_id": 1, "reference_type": "conversion", "reference_id": "conv-123", "values": { "email": "jane@example.com", "phone": "+15551234567", "lead_source": "Facebook" } }' ``` ```python Python theme={null} import requests r = requests.post( 'https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entities/1/entries', headers={'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json'}, json={ 'title': 'Jane Doe', 'pipeline_id': 1, 'stage_id': 1, 'reference_type': 'conversion', 'reference_id': 'conv-123', 'values': { 'email': 'jane@example.com', 'lead_source': 'Facebook', }, }, ) entry = r.json() print(entry['id']) ``` ```json 201 Created theme={null} { "id": "es-doc-id-xyz", "title": "Jane Doe", "pipeline_id": 1, "stage_id": 1, "crm_entity_id": 1, "brand_id": 42, "reference_type": "conversion", "reference_id": "conv-123", "values": [ { "key": "email", "value": "jane@example.com" }, { "key": "lead_source", "value": "Facebook" } ], "values_flat": { "email": "jane@example.com", "lead_source": "Facebook" }, "created_at": "2024-12-11T10:00:00Z", "updated_at": "2024-12-11T10:00:00Z" } ``` ```json 422 Missing required fields theme={null} { "errors": { "title": ["The title field is required."], "pipeline_id": ["The pipeline id field is required."], "stage_id": ["The stage id field is required."] } } ``` ### Update Entry ``` PUT /api/brands/{brand}/crm/entries/{entry} PUT /api/brands/{brand}/crm/entities/{entity}/entries/{entry} ``` Same body as Create. All fields optional — only provided fields are updated. ```bash cURL — update values and reassign theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entries/es-doc-id-xyz \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "values": { "lead_source": "Google", "phone": "+15559876543" }, "assigned_to_user_id": 7 }' ``` ### Move Entry to Stage Move an entry to a different stage within the same pipeline. ``` PUT /api/brands/{brand}/crm/entries/{entry}/stage PUT /api/brands/{brand}/crm/entities/{entity}/entries/{entry}/stage ``` Target stage ID (must belong to the entry's current pipeline) ```bash cURL theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/crm/entries/es-doc-id-xyz/stage \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"stage_id": 3}' ``` ```json 200 Stage updated theme={null} { "id": "es-doc-id-xyz", "stage_id": 3, "updated_at": "2024-12-11T10:05:00Z" } ``` ### Delete Entry ``` DELETE /api/brands/{brand}/crm/entries/{entry} DELETE /api/brands/{brand}/crm/entities/{entity}/entries/{entry} ``` *** ## Automation: Create CRM Entry from Conversion A common pattern is creating a CRM entry whenever a conversion is recorded. Using the EF automation pipeline or an external webhook: ```python Python theme={null} import requests BRAND_ID = 42 API_KEY = "your_api_key_here" BASE = f"https://app.elasticfunnels.io/api/brands/{BRAND_ID}/crm" H = {"EF-Access-Key": API_KEY, "Content-Type": "application/json"} def create_lead_from_conversion(conversion: dict) -> dict: """Create a CRM entry from a conversion webhook payload.""" return requests.post( f"{BASE}/entities/1/entries", headers=H, json={ "title": f"{conversion['first_name']} {conversion['last_name']}", "pipeline_id": 1, "stage_id": 1, # "New Lead" "reference_type": "conversion", "reference_id": str(conversion["id"]), "values": { "email": conversion.get("email"), "phone": conversion.get("phone"), "lead_source": conversion.get("utm_source", "unknown"), }, }, ).json() ``` # Customers Source: https://docs.elasticfunnels.io/api-reference/endpoints/customers List, search, and export customer records Customer endpoints sit under `/api/brands/{brand}/customers/` with `moduleAccess:customers` middleware. Authenticate with `EF-Access-Key`. Customer records are built from conversion and tracking events (purchases, abandoned carts, chargebacks). They are stored in Elasticsearch and cannot be created via API directly — they are created automatically when conversions are recorded. *** ## List Customers ``` GET /api/brands/{brand}/customers ``` Results per page (1–100, default: 25) Filter by customer type: `customer` (purchases only) or `abandon` (abandoned carts only) Search by email, name, or phone Sort field. Any of the sortable columns below. `asc` or `desc` Subset of columns to return. Allowed values: `customer_email`, `customer_name`, `customer_first_name`, `customer_last_name`, `customer_phone`, `customer_country`, `customer_state`, `customer_city`, `last_order_date`, `first_order_date`, `total_orders`, `total_revenue`, `total_refunds`, `total_chargebacks`, `lifetime_value`, `order_frequency`, `products_purchased`, `is_abandoned_customer`, `abandoned_value` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/customers?sort=total_revenue&sort_direction=desc&per_page=50" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "customer_email": "jane@example.com", "customer_name": "Jane Doe", "customer_first_name": "Jane", "customer_last_name": "Doe", "customer_phone": "+15551234567", "customer_country": "US", "customer_state": "CA", "customer_city": "Los Angeles", "first_order_date": "2024-06-01T00:00:00Z", "last_order_date": "2024-12-01T00:00:00Z", "total_orders": 3, "total_revenue": 207.00, "total_refunds": 0, "total_chargebacks": 0, "lifetime_value": 207.00, "order_frequency": 90, "is_abandoned_customer": false, "abandoned_value": 0 } ], "per_page": 25, "total": 1842, "last_page": 74 } ``` *** ## Quick Access Stats Returns customer counts by type — useful for dashboard badges. ``` GET /api/brands/{brand}/customers/quick-access ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/customers/quick-access \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "all": 1842, "customers": 1205, "abandons": 637, "chargebacks": 14, "refunds": 38 } ``` *** ## Get Customer Details Retrieve the full profile for a single customer by email address — includes lifetime stats, order history, subscriptions, and notes. ``` GET /api/brands/{brand}/customers/{customerEmail}/details ``` Customer's email address (URL-encoded if it contains special characters) Include full order history (default: true) Include active subscriptions (default: true) ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/customers/jane%40example.com/details" \ -H "EF-Access-Key: your_api_key_here" ``` ```python Python theme={null} import requests, urllib.parse email = urllib.parse.quote("jane@example.com", safe="") r = requests.get( f'https://app.elasticfunnels.io/api/brands/{brand_id}/customers/{email}/details', headers={'EF-Access-Key': 'your_api_key_here'}, ) print(r.json()) ``` ```json Response theme={null} { "customer": { "email": "jane@example.com", "name": "Jane Doe", "phone": "+15551234567", "country": "US", "state": "CA", "city": "Los Angeles", "address": "123 Main St" }, "stats": { "total_orders": 3, "total_revenue": 207.00, "total_refunds": 0, "total_chargebacks": 0, "lifetime_value": 207.00, "first_order_date": "2024-06-01T00:00:00Z", "last_order_date": "2024-12-01T00:00:00Z", "average_order_value": 69.00 }, "orders": [ { "id": "conv-abc", "date": "2024-12-01T00:00:00Z", "total": 69.00, "products": ["1 Bottle"], "status": "approved" } ], "subscriptions": [ { "id": 12, "status": "active", "product": "Monthly Bundle", "next_charge_date": "2025-01-01" } ] } ``` ```json 404 Not found theme={null} { "message": "Customer not found." } ``` *** ## Export Customers Queue a CSV export of customer records. An email is sent when the export is ready. ``` POST /api/brands/{brand}/customers/export ``` Filter conditions (same keys as the list endpoint `?type`, `?q`, etc.) Limit export to specific email addresses Columns to include (subset of the allowed column list above) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/customers/export \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "columns": ["customer_email", "customer_name", "total_revenue", "last_order_date"], "filters": {"type": "customer"} }' ``` ```json Response theme={null} { "success": true, "message": "Export started. You will receive an email when it is ready." } ``` *** ## Notes * Customer records are read-only — they are created automatically from conversion events * The `{customerEmail}` path parameter should be URL-encoded * Sorting and filtering use Elasticsearch under the hood; large result sets may be slightly delayed * The `columns` parameter controls both what is fetched and what is returned, reducing response size for large lists # Domains Source: https://docs.elasticfunnels.io/api-reference/endpoints/domains Manage custom domains, SSL certificates, DNS validation, and tracking domains Domain endpoints sit under `/api/brands/{brand}/` (no `moduleAccess` required). Authenticate with `EF-Access-Key`. *** ## List Domains ``` GET /api/brands/{brand}/domains ``` Paginated list responses use Laravel's standard shape: the domain rows are in **`data`**, not `domains`. Example: `response.data[0].domain`. Results per page (1–100, default: 25) Search by domain name ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/domains \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 7, "domain": "checkout.mybrand.com", "dedicated_domain": true, "status": "validated", "ssl_ready": true, "cname_validated": true, "is_tracking_domain": false, "brand_id": 42, "created_at": "2024-06-01T00:00:00.000000Z" } ], "per_page": 25, "total": 2, "last_page": 1 } ``` *** ## List All Domains (Unpaginated) ``` GET /api/brands/{brand}/domains/all ``` Returns all domains as a **bare JSON array** at the root (no `data` or `domains` wrapper) — used for dropdown selectors. Each item includes `id`, `domain`, and `merchant_code`. *** ## Get Domain ``` GET /api/brands/{brand}/domains/{domain} ``` *** ## Add Domain ``` POST /api/brands/{brand}/domains ``` Fully-qualified domain name (e.g. `checkout.mybrand.com`). Must be globally unique. `true` for custom domains (requires DNS setup); `false` for subdomains on the platform's root domain. ```bash cURL — add custom domain theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/domains \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"domain": "checkout.mybrand.com", "dedicated_domain": true}' ``` ```bash cURL — add platform subdomain theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/domains \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"domain": "mybrand", "dedicated_domain": false}' ``` ```json 200 Custom domain added theme={null} { "id": 7, "domain": "checkout.mybrand.com", "dedicated_domain": true, "status": "pending-cname", "ssl_ready": false, "cname_validated": false } ``` ```json 422 Already taken theme={null} { "errors": { "domain": ["The domain has already been taken."] } } ``` *** ## Update Domain ``` PUT /api/brands/{brand}/domains/{domain} ``` *** ## Delete Domain ``` DELETE /api/brands/{brand}/domains/{domain} ``` ```json Response theme={null} { "success": true } ``` *** ## Get Validation Instructions Returns the DNS records that must be set to validate a custom domain. Call this after adding a domain to get the CNAME record to configure. ``` GET /api/brands/{brand}/domains/{domain}/validation-instructions ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/domains/7/validation-instructions \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "domain": "checkout.mybrand.com", "is_apex": false, "records": [ { "type": "TXT", "name": "_cf-custom-hostname.checkout.mybrand.com.", "value": "abc123-verification-token", "description": "Verifies domain ownership" }, { "type": "CNAME", "name": "checkout", "value": "proxy.elasticfunnels.io", "description": "Points your subdomain to our platform" } ], "platform_domain": "proxy.elasticfunnels.io", "status": "pending-cname", "cloudflare_status": null, "ssl_status": null, "cloudflare_verification_errors": null } ``` *** ## Validate Domain Queue an asynchronous DNS / Cloudflare validation job for a domain. This endpoint does **not** return the final validation result synchronously. **Workflow:** `GET validation-instructions` → configure DNS → `POST validate` → poll `GET /domains/{id}` until `status` and `cname_validated` update. ``` POST /api/brands/{brand}/domains/{domain}/validate ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/domains/7/validate \ -H "EF-Access-Key: your_api_key_here" ``` ```json 200 Queued (normal response) theme={null} { "queued": true } ``` After calling validate, the domain `status` is set to `pending-cloudflare` while background jobs run. Poll [Get Domain](#get-domain) or [List Domains](#list-domains) for `cname_validated` and `status` changes — do not expect `success` / `cname_validated` in the validate response body. *** ## Test Domain Configuration Run a full connectivity and configuration test for a domain. ``` GET /api/brands/{brand}/domains/{domain}/test-configuration ``` ```json Response theme={null} { "dns_ok": true, "ssl_ok": true, "http_reachable": true, "https_reachable": true, "status": "healthy" } ``` *** ## Request SSL Certificate Provision or re-provision an SSL certificate for a validated custom domain. ``` POST /api/brands/{brand}/domains/{domain}/request-ssl GET /api/brands/{brand}/domains/{domain}/request-ssl ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/domains/7/request-ssl \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "success": true, "message": "SSL certificate provisioning started. This may take a few minutes." } ``` *** ## Move Domain (Preview) Preview what pages and components would be affected by moving all content from one domain to another. ``` POST /api/brands/{brand}/domains/{domain}/move/preview ``` Target domain ID to move content to ```json Response theme={null} { "pages_affected": 12, "components_affected": 3, "preview": [ { "id": 101, "title": "Landing Page", "current_domain": "old.mybrand.com", "new_domain": "new.mybrand.com" } ] } ``` *** ## Move Domain (Apply) Execute the domain migration — update all pages and components from one domain to another. ``` POST /api/brands/{brand}/domains/{domain}/move/apply ``` Target domain ID ```json Response theme={null} { "success": true, "pages_updated": 12, "components_updated": 3 } ``` *** ## Tracking Domains Tracking domains are separate first-party domains used for click tracking and analytics (improves ad attribution). They require Cloudflare integration. ### List Tracking Domains ``` GET /api/brands/{brand}/tracking-domains ``` ### Add Tracking Domain ``` POST /api/brands/{brand}/tracking-domains ``` Fully-qualified tracking domain (e.g. `track.mybrand.com`). Must be globally unique. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/tracking-domains \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"domain": "track.mybrand.com"}' ``` ```json 201 Created theme={null} { "domain": { "id": 9, "domain": "track.mybrand.com", "is_tracking_domain": true, "status": "pending-cloudflare", "cname_validated": false, "cname_validation_record": "proxy.elasticfunnels.io" } } ``` ### Delete Tracking Domain ``` DELETE /api/brands/{brand}/tracking-domains/{domain} ``` *** ## Domain Status Values | Status | Meaning | | - | - | | `pending-cname` | Waiting for CNAME DNS record | | `pending-cloudflare` | Cloudflare hostname being provisioned | | `validated` | DNS validated, domain active | | `ssl-pending` | SSL certificate being issued | | `active` | Fully configured and live | *** ## Notes * Custom domains (`dedicated_domain: true`) require adding a CNAME record pointing to `proxy.elasticfunnels.io` before they can be validated * Platform subdomains are available instantly with no DNS changes required * Tracking domains go through Cloudflare provisioning automatically after creation — no manual CNAME setup needed * The Move endpoints transfer all page/component associations; the old domain remains but has no pages attached # Emails Source: https://docs.elasticfunnels.io/api-reference/endpoints/emails Manage email templates, access the visual builder, and retrieve automation variables Email endpoints sit under `/api/brands/{brand}/emails/` with `moduleAccess:emails` middleware. Authenticate with `EF-Access-Key`. *** ## List Emails ``` GET /api/brands/{brand}/emails ``` Results per page (1–100, default: 25) Search by name or subject `newest`, `oldest`, `created_at`, `updated_at` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/emails \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 3, "code": "welcome-email", "name": "Welcome Email", "subject": "Welcome to our community!", "from_name": "Jane at MyBrand", "from_email": "hello@mybrand.com", "reply_to_email": null, "automation_id": 12, "created_at": "2024-11-01T00:00:00.000000Z" } ], "per_page": 25, "total": 7, "last_page": 1 } ``` *** ## List Emails (Unpaginated) Returns all emails with rendered HTML included (CSS is merged inline). ``` GET /api/brands/{brand}/emails/all ``` ```json Response theme={null} [ { "id": 3, "name": "Welcome Email", "subject": "Welcome to our community!", "from_name": "Jane at MyBrand", "from_email": "hello@mybrand.com", "html": "..." } ] ``` *** ## Get Email ``` GET /api/brands/{brand}/emails/{email} ``` Email numeric ID or `code` slug ```json Response theme={null} { "id": 3, "code": "welcome-email", "name": "Welcome Email", "subject": "Welcome to our community!", "preview_text": "Thanks for joining!", "from_name": "Jane at MyBrand", "from_email": "hello@mybrand.com", "reply_to_email": null, "automation_id": 12, "variable_scope": null, "html": "...", "css": "...", "inline": null, "screenshot": null, "config": {} } ``` *** ## Create Email ``` POST /api/brands/{brand}/emails ``` Email template name (max 256). Defaults to `"Untitled Email"` if empty after validation. Email subject line (max 512) Preheader / preview text (max 512) Sender display name (max 256) Sender email address (max 256) Reply-to address (max 256) Link this email to a specific automation ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/emails \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome Email", "subject": "Welcome to our community!", "from_name": "Jane at MyBrand", "from_email": "hello@mybrand.com", "automation_id": 12 }' ``` ```json 200 Created theme={null} { "id": 3, "email": { "id": 3, "code": "welcome-email", "name": "Welcome Email", "subject": "Welcome to our community!", "from_name": "Jane at MyBrand", "from_email": "hello@mybrand.com", "automation_id": 12, "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } } ``` ```json 422 Missing required field theme={null} { "errors": { "name": ["The name field is required."] } } ``` *** ## Update Email ``` PUT /api/brands/{brand}/emails/{email} ``` Use the **numeric ID** in the path when updating — using a `code` slug will cause a 404 because the `UpdateEmail` authorization query only looks up by numeric ID. Same body as Create. `name` is required. *** ## Delete Email ``` DELETE /api/brands/{brand}/emails/{email} ``` ```json Response theme={null} { "success": true } ``` *** ## Get Builder Config Retrieve the visual email builder config. Add `?html=1` to get the rendered HTML version. ``` GET /api/brands/{brand}/emails/{email}/builder ``` If `1`, returns rendered HTML output instead of builder config ```bash cURL — get builder config theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/emails/3/builder \ -H "EF-Access-Key: your_api_key_here" # Get rendered HTML curl "https://app.elasticfunnels.io/api/brands/{brand_id}/emails/3/builder?html=1" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Builder config response theme={null} { "assets": [ /* brand file assets */ ], "data": { /* email content blocks */ }, "pageComponents": [ /* reusable components */ ], "fonts": [ "Montserrat", "Open Sans" ] } ``` ```json ?html=1 response theme={null} { "html": "...", "config": "{ /* JSON string of config */ }", "inline": "..." } ``` *** ## Save Builder Config ``` POST /api/brands/{brand}/emails/{email}/builder ``` Builder config as a **JSON string** (not a JSON object — must be `JSON.stringify`'d before sending) Raw HTML content CSS styles Comments data for the email template ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/emails/3/builder \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "config": "{\"data\": {}}", "html": "...", "css": "body { font-family: sans-serif; }" }' ``` ```json Response theme={null} { "id": 3, "name": "Welcome Email" } ``` *** ## Get Automation Variables Lists the merge-tag variables available for use in this email's content, based on the automation it belongs to. ``` GET /api/brands/{brand}/emails/{email}/automation-variables ``` ```json Email linked to automation theme={null} { "variables": [ { "key": "{{first_name}}", "value": "First Name", "group": "Customer" }, { "key": "{{product_name}}", "value": "Product Name", "group": "Order" }, { "key": "{{order_total}}", "value": "Order Total", "group": "Order" } ], "automation": { "id": 12, "title": "Welcome Email Sequence" }, "context": { /* automation context */ } } ``` ```json No automation linked theme={null} { "variables": [], "automation": null, "context": { "source": "none", "title": "No automation linked", "badge_label": "No automation" } } ``` *** ## Notes * The `config` field in the builder `POST` must be sent as a **JSON string** (double-encoded), not a JSON object * Saving the builder config automatically regenerates inline CSS and the `inline` version of the email * Use numeric IDs in the URL for `PUT` / `PATCH` — `code` strings only work for `GET`, `show`, and `DELETE` * The `all` endpoint merges CSS inline into the HTML for direct use in rendering/preview contexts # File Manager Source: https://docs.elasticfunnels.io/api-reference/endpoints/file-manager Upload, list, rename, and delete brand media assets via API All File Manager endpoints authenticate via the `EF-Access-Key` header. No session cookie or CSRF token is required. **`POST /api/brands/{brand}/media` does not exist** (returns 404). Use [Upload File](#upload-file) (`/file-manager/upload-file`) or [Legacy upload](#legacy-upload-brand-files) (`/files/upload`) below. *** ## Upload File Upload a single file to the brand's asset storage. ``` POST /api/brands/{brand}/file-manager/upload-file ``` The brand/project ID Binary file (`multipart/form-data`) Target subdirectory within the brand's assets folder (e.g. `media`, `media/icons`). Defaults to the root assets folder. Omit leading/trailing slashes. Override the stored filename. Defaults to the original filename from the `file` field. **Accepted MIME types:** images (`image/*`), video (`video/mp4`, `video/webm`, `video/quicktime`), fonts (`font/ttf`, `font/woff`, `font/woff2`, `font/otf`), code/text (`text/css`, `text/javascript`, `application/json`, `application/javascript`). ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/upload-file \ -H "EF-Access-Key: your_api_key_here" \ -F "file=@/path/to/cards.png" \ -F "path=media" \ -F "custom_filename=cards.png" ``` ```javascript JavaScript theme={null} const form = new FormData(); form.append('file', fileBlob, 'cards.png'); form.append('path', 'media'); const res = await fetch( 'https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/upload-file', { method: 'POST', headers: { 'EF-Access-Key': 'your_api_key_here' }, body: form } ); const result = await res.json(); // CDN URL: `https://cdn.elasticfunnels.io/{brand_id}/assets/media/${filename}` ``` ```json 201 Uploaded theme={null} { "HttpCode": 201, "Message": "File Uploaded.", "file_path": "ef-files/42/assets/media/cards.png" } ``` The CDN URL is not included in the upload response. Derive it as: `https://cdn.elasticfunnels.io/{brand_id}/assets/{path}/{filename}` Example: `https://cdn.elasticfunnels.io/42/assets/media/cards.png` *** ## Bulk Upload Files Upload up to **20 files** in a single request. Each file is validated and uploaded independently — files that fail validation are reported per-file without aborting the rest. ``` POST /api/brands/{brand}/file-manager/bulk-upload-files ``` The brand/project ID Array of files (multipart/form-data). Maximum 20 files per request. Target subdirectory for all files in this batch (e.g. `media`). All files land in the same folder. Optional array of custom filenames, positionally matched to `files[]`. Use to override individual filenames. `filenames[0]` overrides the name of `files[0]`, etc. **Per-file validation rules:** * File must be a valid upload (not corrupted in transit) * Maximum **10 MB** per file * MIME type must be in the allowed list (same as single upload) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/bulk-upload-files \ -H "EF-Access-Key: your_api_key_here" \ -F "files[]=@/assets/refund-icon.png" \ -F "files[]=@/assets/guarantee-badge.png" \ -F "files[]=@/assets/trust-seal.svg" \ -F "files[]=@/assets/virus.exe" \ -F "path=media" ``` ```python Python theme={null} import requests BRAND_ID = 42 BASE = f"https://app.elasticfunnels.io/api/brands/{BRAND_ID}/file-manager" HEADERS = {"EF-Access-Key": "your_api_key_here"} asset_paths = [ "assets/universal/refund-icon.png", "assets/universal/guarantee-badge.png", "assets/universal/trust-seal.svg", "assets/universal/cc-icons.png", "assets/product/bottle-shot.png", "assets/product/logo.png", ] files = [("files[]", (Path(p).name, open(p, "rb"))) for p in asset_paths] response = requests.post( f"{BASE}/bulk-upload-files", headers=HEADERS, files=files, data={"path": "media"}, ) result = response.json() print(f"Uploaded: {result['summary']['uploaded']}, Failed: {result['summary']['failed']}") for f in result["files"]: if f["status"] == "uploaded": print(f" ✓ {f['filename']} → {f['url']}") else: print(f" ✗ {f['filename']}: {f['error']}") ``` ```javascript Node.js theme={null} import FormData from 'form-data'; import fs from 'fs'; import fetch from 'node-fetch'; const BRAND_ID = 42; const form = new FormData(); const assets = [ 'assets/refund-icon.png', 'assets/guarantee-badge.png', 'assets/trust-seal.svg', ]; for (const p of assets) { form.append('files[]', fs.createReadStream(p)); } form.append('path', 'media'); const res = await fetch( `https://app.elasticfunnels.io/api/brands/${BRAND_ID}/file-manager/bulk-upload-files`, { method: 'POST', headers: { 'EF-Access-Key': 'your_api_key_here', ...form.getHeaders() }, body: form } ); const { summary, files } = await res.json(); console.log(`${summary.uploaded} uploaded, ${summary.failed} failed`); ``` ```json 200 Partial success theme={null} { "summary": { "total": 4, "uploaded": 3, "failed": 1 }, "files": [ { "filename": "refund-icon.png", "status": "uploaded", "url": "https://cdn.elasticfunnels.io/42/assets/media/refund-icon.png", "size": 18432, "mime_type": "image/png", "width": 200, "height": 200 }, { "filename": "guarantee-badge.png", "status": "uploaded", "url": "https://cdn.elasticfunnels.io/42/assets/media/guarantee-badge.png", "size": 34567, "mime_type": "image/png", "width": 400, "height": 400 }, { "filename": "trust-seal.svg", "status": "uploaded", "url": "https://cdn.elasticfunnels.io/42/assets/media/trust-seal.svg", "size": 1204, "mime_type": "image/svg+xml", "width": null, "height": null }, { "filename": "virus.exe", "status": "failed", "error": "MIME type \"application/x-msdownload\" is not allowed" } ] } ``` ```json 422 Too many files theme={null} { "errors": { "files": ["A maximum of 20 files can be uploaded in a single request."] } } ``` HTTP status is always **200** even when some files fail — inspect the `status` field on each entry. A `failed` entry never prevents other files from uploading. *** ## Check File Exists Check whether a file already exists at a given path. Useful for idempotency before uploading. ``` POST /api/brands/{brand}/file-manager/file-exists ``` The brand/project ID The filename only (e.g. `cards.png`) The subdirectory to look in (e.g. `media`). Defaults to the root assets folder. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/file-exists \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"filename": "cards.png", "path": "media"}' ``` ```json File exists theme={null} { "exists": true } ``` ```json File does not exist theme={null} { "exists": false } ``` *** ## Legacy: List brand files (database catalog) `POST /api/brands/{brand}/files` returns the brand's **existing image catalog** from the database. It does **not** accept file uploads despite using POST. ``` POST /api/brands/{brand}/files ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/files \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{}' ``` ```json Response theme={null} { "data": [ { "src": "https://cdn.elasticfunnels.io/42/assets/media/logo.png?t=1733846400", "type": "image", "width": 200, "height": 80 } ] } ``` *** ## Legacy: Upload brand files Upload one or more images to the legacy brand files store (stored under `/assets/media/` on the CDN). ``` POST /api/brands/{brand}/files/upload ``` Image files (`multipart/form-data`). Each file must pass the `image` validation rule. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/files/upload \ -H "EF-Access-Key: your_api_key_here" \ -F "files[]=@/path/to/logo.png" ``` ```json Response theme={null} { "data": [ { "src": "https://cdn.elasticfunnels.io/42/assets/media/logo.png", "type": "image", "width": 400, "height": 120 } ] } ``` Prefer [Upload File](#upload-file) (`/file-manager/upload-file`) for new integrations — it supports paths, custom filenames, and more MIME types. *** ## List Files Browse the raw Bunny CDN storage directory for the brand. Returns files and folders from storage directly (not the database). Sorted newest-first; folders are listed before files. ``` GET /api/brands/{brand}/file-manager/files ``` The brand/project ID Comma-separated list of extensions to filter by (e.g. `png,jpg,svg`) ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/files" \ -H "EF-Access-Key: your_api_key_here" ``` ```bash cURL (filter by extension) theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/files?extensions=png,jpg,svg" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} [ { "ObjectName": "media", "IsDirectory": true, "type": "folder", "BrowsePath": "media/", "Length": "0 KB", "sizeBytes": 0, "LastChanged": "2024-12-10T14:00:00Z" }, { "ObjectName": "cards.png", "IsDirectory": false, "type": "file", "BrowsePath": "media/cards.png", "src": "https://cdn.elasticfunnels.io/42/assets/media/cards.png", "Length": "33.76 KB", "sizeBytes": 34567, "width": 400, "height": 400, "srcset": "https://cdn.elasticfunnels.io/42/assets/media/cards.png?width=200 200w, ...", "sizes": "(max-width: 640px) 100vw, ...", "LastChanged": "2024-12-10T14:01:00Z" } ] ``` *** ## List Assets Return all brand assets tracked in the **database**. Unlike [List Files](#list-files) which reads from CDN storage, this returns structured records including file IDs and paths — useful for linking uploaded files to other API operations. ``` GET /api/brands/{brand}/file-manager/assets ``` The brand/project ID ISO 8601 timestamp — only return assets updated after this date (e.g. `2024-12-01T00:00:00Z`). Useful for incremental sync. ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/assets" \ -H "EF-Access-Key: your_api_key_here" ``` ```bash cURL (incremental sync) theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/assets?updated_after=2024-12-01T00:00:00Z" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} [ { "id": 101, "file_name": "cards.png", "file_path": "media/cards.png", "updated_at": "2024-12-10T14:01:00+00:00" }, { "id": 102, "file_name": "guarantee-badge.png", "file_path": "media/guarantee-badge.png", "updated_at": "2024-12-10T14:02:00+00:00" } ] ``` *** ## Delete File Delete a file or folder from asset storage. The corresponding database record is also removed. ``` POST /api/brands/{brand}/file-manager/delete-file ``` The brand/project ID Path of the file to delete, relative to the brand's assets folder (e.g. `media/cards.png`). No leading slash. Deletion is permanent. The following paths are **protected** and cannot be deleted via API: * `media` (the folder itself) * `products` and anything inside it (used in product forms) * Empty path (root assets folder) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/delete-file \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"path": "media/old-badge.png"}' ``` ```json Success theme={null} { "HttpCode": 200, "Message": "File Deleted Successfully." } ``` ```json Protected path theme={null} { "errors": { "message": "Cannot delete media folder" } } ``` ```json Out-of-bounds path theme={null} { "errors": { "message": "Invalid path: Cannot access files outside of brand assets folder" } } ``` ```json File not found theme={null} { "errors": { "message": "File not found" } } ``` *** ## Rename File Rename a file in asset storage. Because Bunny CDN does not support atomic rename, this downloads the original content, uploads it under the new name, and deletes the original. The database record is updated automatically. ``` POST /api/brands/{brand}/file-manager/rename-file ``` The brand/project ID Current path of the file relative to the brand's assets folder (e.g. `media/old-name.png`). No leading slash. New filename only — not a path (e.g. `new-name.png`). Must not contain `/` or `\`. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/rename-file \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"path": "media/old-name.png", "new_name": "cards.png"}' ``` ```json Success theme={null} { "success": true, "old_path": "media/old-name.png", "new_path": "media/cards.png", "url": "https://cdn.elasticfunnels.io/42/assets/media/cards.png" } ``` ```json File not found theme={null} { "errors": { "message": "File not found" } } ``` ```json Invalid new_name theme={null} { "errors": { "new_name": ["new_name must be a filename only, not a path."] } } ``` **Rename folder** (`POST …/file-manager/rename-folder`) is not yet supported via the API. It returns `501 Not Implemented`. Use the File Manager UI to rename folders. *** ## Create Folder Create a new folder inside the brand's asset storage. ``` POST /api/brands/{brand}/file-manager/create-folder ``` The brand/project ID Parent directory for the new folder (e.g. `media`) Folder name. Must match `^[a-z0-9_-]+$` — lowercase letters, numbers, hyphens, underscores only. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/file-manager/create-folder \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"path": "media", "name": "product-shots"}' ``` ```json Success theme={null} { "message": "Folder created successfully." } ``` ```json Already exists theme={null} { "errors": { "name": ["Folder already exists"] } } ``` ```json Invalid name theme={null} { "errors": { "name": ["Folder name can only contain letters, numbers, underscores, and hyphens."] } } ``` *** ## Automated Product Provisioning Complete example: bulk-upload all universal + product-specific assets for a new product, then idempotently re-run without re-uploading existing files. ```python Python theme={null} import requests from pathlib import Path BRAND_ID = 42 API_KEY = "your_api_key_here" BASE = f"https://app.elasticfunnels.io/api/brands/{BRAND_ID}/file-manager" CDN = f"https://cdn.elasticfunnels.io/{BRAND_ID}/assets" HEADERS = {"EF-Access-Key": API_KEY} def bulk_upload(local_paths: list[Path], folder: str) -> dict[str, str]: """Upload a batch; skip files that already exist. Returns {filename: cdn_url}.""" # Check which already exist to_upload = [] urls = {} for p in local_paths: r = requests.post( f"{BASE}/file-exists", headers=HEADERS, json={"filename": p.name, "path": folder}, ) if r.json().get("exists"): print(f" skip {p.name} (already on CDN)") urls[p.name] = f"{CDN}/{folder}/{p.name}" else: to_upload.append(p) if not to_upload: return urls # Bulk upload the rest files = [("files[]", (p.name, p.open("rb"))) for p in to_upload] r = requests.post( f"{BASE}/bulk-upload-files", headers=HEADERS, files=files, data={"path": folder}, ) r.raise_for_status() for entry in r.json()["files"]: if entry["status"] == "uploaded": print(f" ✓ {entry['filename']}") urls[entry["filename"]] = entry["url"] else: print(f" ✗ {entry['filename']}: {entry['error']}") return urls UNIVERSAL = [ Path("assets/universal/refund-icon.png"), Path("assets/universal/guarantee-badge.png"), Path("assets/universal/trust-seal.svg"), Path("assets/universal/cc-icons.png"), ] PRODUCT = [ Path("assets/product/bottle-shot.png"), Path("assets/product/logo.png"), ] print("Uploading universal assets…") universal_urls = bulk_upload(UNIVERSAL, "media") print("Uploading product assets…") product_urls = bulk_upload(PRODUCT, "media/products") all_urls = {**universal_urls, **product_urls} print(f"\nDone. {len(all_urls)} assets ready.") ``` # Funnels Source: https://docs.elasticfunnels.io/api-reference/endpoints/funnels Manage sales funnels ## List All Funnels Retrieve all funnels for a project. The brand/project ID ```bash Request theme={null} GET /api/brands/{brand}/funnels/all ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/funnels/all \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "data": [ { "id": 1, "name": "Main Sales Funnel", "status": "active", "pages_count": 4, "created_at": "2024-01-15T10:30:00.000000Z" } ] } ``` *** ## Get Funnel Details Retrieve details about a specific funnel. The brand/project ID The funnel ID ```bash Request theme={null} GET /api/brands/{brand}/funnels/{funnel} ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/funnels/{funnel_id} \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 1, "name": "Main Sales Funnel", "status": "active", "pages": [ { "id": 1, "name": "Landing Page", "type": "landing", "order": 1 }, { "id": 2, "name": "Checkout", "type": "checkout", "order": 2 }, { "id": 3, "name": "Upsell", "type": "upsell", "order": 3 }, { "id": 4, "name": "Thank You", "type": "thankyou", "order": 4 } ], "created_at": "2024-01-15T10:30:00.000000Z", "updated_at": "2024-12-10T08:20:00.000000Z" } ``` *** ## Create Funnel Create a new funnel. The brand/project ID Funnel name Funnel status: `active` or `inactive` (default: `active`) ```bash Request theme={null} POST /api/brands/{brand}/funnels ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/funnels \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "New Funnel", "status": "active" }' ``` *** ## Update Funnel Update an existing funnel. The brand/project ID The funnel ID ```bash Request theme={null} PUT /api/brands/{brand}/funnels/{funnel} ``` ```bash cURL theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/funnels/{funnel_id} \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Updated Funnel Name" }' ``` *** ## Delete Funnel Delete a funnel. The brand/project ID The funnel ID ```bash Request theme={null} DELETE /api/brands/{brand}/funnels/{funnel} ``` ```bash cURL theme={null} curl -X DELETE https://app.elasticfunnels.io/api/brands/{brand_id}/funnels/{funnel_id} \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Clone Funnel Create a copy of an existing funnel with all its pages. The brand/project ID The funnel ID to clone ```bash Request theme={null} POST /api/brands/{brand}/funnels/{funnel}/clone ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/funnels/{funnel_id}/clone \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 2, "name": "Copy of Main Sales Funnel", "status": "inactive", "pages_count": 4, "created_at": "2024-12-10T16:00:00.000000Z" } ``` # Pages Source: https://docs.elasticfunnels.io/api-reference/endpoints/pages Manage landing pages and retrieve page information ## List All Pages Retrieve all pages for a project. The brand/project ID ```bash Request theme={null} GET /api/brands/{brand}/pages/all ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/pages/all \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "data": [ { "id": 1, "name": "Landing Page", "slug": "landing-page", "status": "active", "type": "landing", "created_at": "2024-01-15T10:30:00.000000Z" }, { "id": 2, "name": "Thank You Page", "slug": "thank-you", "status": "active", "type": "thankyou", "created_at": "2024-01-16T11:20:00.000000Z" } ] } ``` *** ## Get Page Details Retrieve details about a specific page. The brand/project ID The page ID ```bash Request theme={null} GET /api/brands/{brand}/pages/{page} ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/pages/{page_id} \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 1, "name": "Landing Page", "slug": "landing-page", "status": "active", "type": "landing", "meta_title": "Amazing Product", "meta_description": "Get your product now", "url": "https://yourdomain.com/landing-page", "created_at": "2024-01-15T10:30:00.000000Z", "updated_at": "2024-12-10T08:20:00.000000Z" } ``` *** ## Create Page Create a new page. The brand/project ID Page title Page builder type: must be `editor` (optional if `html` is provided, required otherwise) URL-friendly slug (optional, auto-generated if not provided) Page status: `published`, `draft`, `offline`, `imported` (default: `draft`) Raw HTML content (automatically sets page\_type to `editor`). If this HTML contains ``, the Pages API stores that source markup as provided. Custom domain ID (optional) Funnel ID to associate with (optional) Folder ID to organize page (optional) Set as index page (default: false) Custom CSS styles Template type: `nutra-prelander`, `nutra-advertorial`, `leadgen`, `webinar-registration` Template ID to use (optional) SEO meta title Alternative title when tab is not focused SEO meta description Require user login to access Require password to access Page password (required if requires\_password is true) URL to redirect to Google Analytics measurement ID Meta (Facebook) Pixel ID Custom JavaScript code Mark as upsell page Show affiliate tracking pixels Disable default tracking scripts ```bash Request theme={null} POST /api/brands/{brand}/pages ``` ### Create Basic Page ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/pages \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "New Landing Page", "slug": "new-landing", "page_type": "editor", "status": "published" }' ``` ### Create Page with HTML ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/pages \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "Custom HTML Page", "slug": "custom-page", "status": "published", "html": "My Page

Hello World

", "css": "body { background: #f5f5f5; font-family: Arial, sans-serif; }" }' ``` * When you provide `html` in the request, `page_type` is automatically set to `editor`, so you don't need to specify it * The API only supports **editor** type pages. The visual drag-and-drop builder is available only through the web dashboard * On update, providing `html` will automatically convert the page to `editor` type if it isn't already * If your `html` contains ``, the create/update response returns stored source HTML, not the runtime-resolved winning variant HTML ```json Response theme={null} { "id": 3, "title": "New Landing Page", "slug": "new-landing", "status": "published", "page_type": "editor", "url": "https://yourdomain.com/new-landing", "created_at": "2024-12-10T15:45:00.000000Z" } ``` ### Inline Split Test HTML in Pages API When you send `` markup through `POST /api/brands/{brand}/pages` or `PUT /api/brands/{brand}/pages/{page}`: * The endpoint stores the full source HTML you send in `html` * The response does **not** replace the tag with the chosen runtime variant * The public Pages API does **not** inject the auto-managed `id` attribute during create/update Example request body: ```json theme={null} { "title": "Split Test Page", "slug": "split-test-page", "status": "published", "html": "

Original Headline

New Headline

" } ``` Example create response shape: ```json Response theme={null} { "page": { "id": 12, "title": "Split Test Page", "slug": "split-test-page", "status": "published", "page_type": "editor", "html": "

Original Headline

New Headline

" } } ``` Runtime rendering is different from the API response. When a visitor loads the page, the server replaces the entire block with the chosen variant wrapped like this: ```html theme={null}
``` If you use a save flow that returns normalized inline split test HTML with a server-generated `id`, replace your local/source HTML with that returned HTML before the next update. This only matters when your next request includes `html` again. If `html` is omitted because nothing changed, the existing stored HTML stays unchanged. Re-sending the original `` markup without the injected `id` in a later `html` update can cause the server to create a new split test record on every save. *** ## Update Page Update an existing page. Only include the fields you want to change. **Common causes of errors on PUT/PATCH:** | Symptom | Cause | Fix | | - | - | - | | `422` with `title is required` | `title` is always required by the validator, even if you're only changing HTML | Always include `"title"` in the body | | `401` / redirect to login | Missing or invalid `EF-Access-Key` header, or key doesn't belong to this brand | Check the header name (`EF-Access-Key`) and value | | `403` module access | Brand user role doesn't have `pages` access | Update role permissions in Project Settings | | Slug `422` uniqueness error | Provided `slug` already exists for another page on the same domain | Choose a unique slug or omit it | The brand/project ID The page ID Page title URL-friendly slug Page status: `published`, `draft`, `offline`, `imported` Custom domain ID Folder ID to organize page Set as index page Raw HTML content (automatically sets page\_type to `editor`). If this HTML contains ``, the Pages API stores that source markup as provided. Custom CSS styles SEO meta title Alternative title when tab is not focused SEO meta description Require user login to access Require password to access Page password URL to redirect to Disable right-click on page Prevent search engine indexing Only accessible through funnel flow Google Analytics measurement ID Meta (Facebook) Pixel ID Custom header code Custom JavaScript code (must be valid script tags only) Mark as upsell page Show affiliate tracking pixels Disable default tracking scripts ```bash Request theme={null} PUT /api/brands/{brand}/pages/{page} ``` ### Update Basic Information ```bash cURL theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/pages/{page_id} \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "Updated Page Name", "status": "published" }' ``` ### Update page title and status ```bash cURL theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/pages/{page_id} \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "Updated Page Name", "status": "published" }' ``` ### Update HTML content ```bash cURL theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/pages/{page_id} \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "My Custom Page", "html": "

Updated Content

", "css": "body { font-family: Arial; }" }' ``` Providing `html` automatically converts the page to `editor` type if it isn't already. ### Update SEO settings ```bash cURL theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/pages/{page_id} \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "My Landing Page", "seo_title": "Best Product Ever - Buy Now", "seo_description": "Discover the amazing benefits of our product", "prevent_indexing": false }' ``` ```json 200 Updated theme={null} { "id": 3, "brand_id": 42, "title": "My Custom Page", "slug": "my-custom-page", "status": "published", "page_type": "editor", "html": "

Updated Content

", "css": "body { font-family: Arial; }", "seo_title": "My Custom Page", "seo_description": null, "domain_id": null, "folder_id": null, "updated_at": "2024-12-11T09:15:00.000000Z" } ``` ```json 422 Missing title theme={null} { "errors": { "title": ["The title field is required."] } } ``` ```json 422 Duplicate slug theme={null} { "errors": { "slug": ["The slug has already been taken."] } } ``` ```json 401 Bad API key theme={null} { "message": "Invalid API key or brand access denied" } ```
*** ## Page Types and Fields ### API Page Type: Editor Only The API only supports creating **editor** pages. The visual drag-and-drop builder is only available through the web dashboard interface. **Editor Pages** allow direct HTML/CSS editing through the API: * `html` - Raw HTML content * `css` - Custom CSS styles * `page_type` - Must be `editor` (or automatically set when `html` is provided) ### Automatic Editor Type Assignment When you provide the `html` field in a create or update request, the page is automatically set to `editor` type, even if you don't specify `page_type`. ```json theme={null} { "title": "My Page", "html": "..." // page_type is automatically set to "editor" } ``` ### Update Behavior When updating pages: * **Only provided fields are updated** - omitted fields remain unchanged * `html` field is updated if provided in the request * `css` field is updated if provided in the request * Providing `html` will automatically set/update `page_type` to `editor` * Empty strings will clear the field value ### Custom JavaScript Validation The `custom_javascript` field must contain only valid `
Some content
``` *** ## Delete Page Delete a page. The brand/project ID The page ID ```bash Request theme={null} DELETE /api/brands/{brand}/pages/{page} ``` ```bash cURL theme={null} curl -X DELETE https://app.elasticfunnels.io/api/brands/{brand_id}/pages/{page_id} \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "message": "Page deleted successfully" } ``` *** ## Plan Limits and Errors When creating pages, the API checks against your plan limits: ### Plan Limit Exceeded Error ```json theme={null} { "error": "Plan limit exceeded", "plan_error": { "type": "plan_limit_exceeded", "feature": "pages", "message": "You have reached the maximum number of pages (10) allowed for your plan.", "current_count": 10, "limit": 10, "plan_name": "Starter", "upgrade_info": { "required_plan": "Professional", "feature_description": "Unlimited pages" } }, "errors": { "title": ["You have reached the maximum number of pages (10) allowed for your plan. Please upgrade to create more pages."] } } ``` **Status Code:** `422 Unprocessable Entity` ### Plan Feature Not Available Error ```json theme={null} { "error": "Plan limit exceeded", "plan_error": { "type": "plan_upgrade_required", "feature": "pages", "message": "Your current plan does not allow creating pages.", "plan_name": "Free Trial", "upgrade_info": { "required_plan": "Starter", "feature_description": "Create landing pages" } }, "errors": { "title": ["Your current plan does not allow creating pages. Please upgrade to access this feature."] } } ``` **Status Code:** `422 Unprocessable Entity` *** ## Clone Page Create a copy of an existing page. The brand/project ID The page ID to clone Name for the cloned page (optional, defaults to "Copy of \[original name]") ```bash Request theme={null} POST /api/brands/{brand}/pages/{page}/clone ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/pages/{page_id}/clone \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Cloned Landing Page" }' ``` ```json Response theme={null} { "id": 4, "name": "Cloned Landing Page", "slug": "cloned-landing-page", "status": "inactive", "type": "landing", "created_at": "2024-12-10T16:00:00.000000Z" } ``` # Products Source: https://docs.elasticfunnels.io/api-reference/endpoints/products Manage products and SKUs ## List All Products Retrieve all products for a project with pagination. The brand/project ID Page number for pagination (default: 1) Number of results per page (default: 25) ```bash Request theme={null} GET /api/brands/{brand}/products ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/products \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 2, "brand_id": 1, "code": "glucotil", "type": "physical", "title": "1 Bottle", "checkout_title": null, "price": 69, "retail_price": 99, "image": "https://storage.googleapis.com/asper-digital/1/1-bottle.png", "image_link": "https://storage.googleapis.com/asper-digital/1/1-bottle.png", "units": 1, "sku": "prod1", "classification": "main", "product_files": [], "unit_cost": null, "unit_weight_oz": null, "fulfillment_cost": null, "packaging_cost": null, "fuel_surcharge_cost": null, "cogs": null, "custom_attributes": null, "merchant_product_ids": [], "physical_units_per_product": null, "fulfillment_multiply_sku": 0, "sku_category": null, "sku_subcategory": null, "product_specifications": null, "shipping_profile_id": null, "is_subscription": false, "subscription_frequency": null, "subscription_frequency_unit": null, "subscription_trial_days": null, "subscription_first_charge_free": false, "average_shipping_cost": null, "bonuses": [] } ], "first_page_url": "https://app.elasticfunnels.io/api/brands/1/products?page=1", "from": 1, "last_page": 1, "last_page_url": "https://app.elasticfunnels.io/api/brands/1/products?page=1", "links": [ { "url": null, "label": "« Previous", "active": false }, { "url": "https://app.elasticfunnels.io/api/brands/1/products?page=1", "label": "1", "active": true }, { "url": null, "label": "Next »", "active": false } ], "next_page_url": null, "path": "https://app.elasticfunnels.io/api/brands/1/products", "per_page": 25, "prev_page_url": null, "to": 8, "total": 8 } ``` *** ## List All Products (Unpaginated) Get all products without pagination for dropdown lists. The brand/project ID ```bash Request theme={null} GET /api/brands/{brand}/products/all ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/products/all \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Get Product Details Retrieve details about a specific product. The brand/project ID The product ID ```bash Request theme={null} GET /api/brands/{brand}/products/{product} ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/products/2 \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 2, "brand_id": 1, "code": "glucotil", "type": "physical", "title": "1 Bottle", "checkout_title": null, "price": 69, "retail_price": 99, "image": "https://storage.googleapis.com/asper-digital/1/1-bottle.png", "image_link": "https://storage.googleapis.com/asper-digital/1/1-bottle.png", "units": 1, "sku": "prod1", "classification": "main", "product_files": [], "unit_cost": null, "unit_weight_oz": null, "fulfillment_cost": null, "packaging_cost": null, "fuel_surcharge_cost": null, "cogs": null, "custom_attributes": null, "merchant_product_ids": {}, "physical_units_per_product": null, "fulfillment_multiply_sku": 0, "sku_category": null, "sku_subcategory": null, "product_specifications": null, "shipping_profile_id": null, "is_subscription": false, "subscription_frequency": null, "subscription_frequency_unit": null, "subscription_trial_days": null, "subscription_first_charge_free": false, "average_shipping_cost": null, "bonuses": [] } ``` *** ## Product Fields Reference * `code` - Unique product code/identifier * `title` - Product display name * `checkout_title` - Alternative title for checkout (optional) * `type` - Product type: `physical`, `digital` * `classification` - Product classification: `main`, `upsell`, `downsell`, `bump`, `bonus` * `price` - Selling price (in currency units) * `retail_price` - Suggested retail/original price * `units` - Number of units included * `sku` - Stock Keeping Unit identifier * `unit_cost` - Cost per unit * `unit_weight_oz` - Weight per unit in ounces * `fulfillment_cost` - Cost to fulfill the order * `packaging_cost` - Packaging cost * `fuel_surcharge_cost` - Fuel surcharge * `cogs` - Cost of Goods Sold * `physical_units_per_product` - Physical units per product * `fulfillment_multiply_sku` - Multiply fulfillment by SKU quantity * `average_shipping_cost` - Average shipping cost * `image` - Product image URL * `image_link` - Full URL to product image * `product_files` - Array of downloadable files attached to the product * `is_subscription` - Whether this is a subscription product * `subscription_frequency` - Billing frequency (e.g., 1, 2, 3) * `subscription_frequency_unit` - Unit: `day`, `week`, `month`, `year` * `subscription_trial_days` - Number of trial days * `subscription_first_charge_free` - Whether first charge is free * `custom_attributes` - Array of custom key-value pairs * `merchant_product_ids` - Object mapping merchant names to product IDs * `product_specifications` - Array of product specifications * `shipping_profile_id` - Associated shipping profile * `sku_category` - SKU category * `sku_subcategory` - SKU subcategory * `bonuses` - Array of bonus products associated with this product *** ## Create Product Create a new product. The brand/project ID Unique product code Product title Product price Product type: `physical` or `digital` Classification: `main`, `upsell`, `downsell`, `bump`, or `bonus` Retail/original price Number of units Stock Keeping Unit Product image file (`multipart/form-data` only). Must be an uploaded image file — **not** a JSON URL string. Omit from JSON-only creates; upload via [File Manager](/api-reference/endpoints/file-manager#upload-file) and reference the CDN URL in HTML, or send `image` on a multipart create request. Whether this is a subscription product Billing frequency (required if is\_subscription is true) Frequency unit: `day`, `week`, `month`, `year` ```bash Request theme={null} POST /api/brands/{brand}/products ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/products \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "code": "product_123", "title": "Premium Product", "price": 49.99, "retail_price": 79.99, "type": "physical", "classification": "main", "units": 1, "sku": "PREM-001" }' ``` ### Subscription Product Example ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/products \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "code": "monthly_subscription", "title": "Monthly Subscription", "price": 29.99, "type": "physical", "classification": "main", "is_subscription": true, "subscription_frequency": 1, "subscription_frequency_unit": "month", "subscription_trial_days": 14, "subscription_first_charge_free": true }' ``` ### Create with image (multipart) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/products \ -H "EF-Access-Key: your_api_key_here" \ -F "code=product_123" \ -F "title=Premium Product" \ -F "price=49.99" \ -F "type=physical" \ -F "classification=main" \ -F "image=@/path/to/bottle.png" ``` Sending `"image": "https://..."` in a JSON body fails validation (`422`) or is ignored — the API expects a file upload for the `image` field. *** ## Update Product Update an existing product. Authenticate with `EF-Access-Key` on every request. Both **PUT** and **POST** are supported for the same update handler. If PUT returns a redirect to login, use **POST** with the same URL and body. The brand/project ID The product ID Product title (use `title`, not `name`) Product price Replace product image (`multipart/form-data` file upload, same rules as create) ```bash Request theme={null} PUT /api/brands/{brand}/products/{product} ``` Alternative (same handler): ``` POST /api/brands/{brand}/products/{product} ``` ```bash cURL (PUT) theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/products/{product_id} \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "Updated Product Title", "price": 59.99 }' ``` ```bash cURL (POST — preferred if PUT redirects) theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/products/{product_id} \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "title": "Updated Product Title", "price": 59.99 }' ``` ```json 200 Updated theme={null} { "id": 2, "title": "Updated Product Title", "code": "glucotil", "price": 59.99, "brand_id": 42 } ``` *** ## Delete Product Delete a product. The brand/project ID The product ID ```bash Request theme={null} DELETE /api/brands/{brand}/products/{product} ``` ```bash cURL theme={null} curl -X DELETE https://app.elasticfunnels.io/api/brands/{brand_id}/products/{product_id} \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Clone Product Create a copy of an existing product. The brand/project ID The product ID to clone ```bash Request theme={null} POST /api/brands/{brand}/products/{product}/clone ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/products/2/clone \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Product Classifications | Classification | Description | Usage | | - | - | - | | `main` | Main product offer | Primary product in funnel | | `upsell` | Upsell product | Offered after initial purchase | | `downsell` | Downsell product | Offered if upsell is declined | | `bump` | Order bump | Checkbox add-on at checkout | | `bonus` | Bonus product | Free gift or bonus item | *** ## Product Bonuses Products can have associated bonus products. The `bonuses` array contains bonus product relationships: ```json theme={null} { "bonuses": [ { "bonus_product_id": 23, "product_id": 3, "brand_id": 1 }, { "bonus_product_id": 24, "product_id": 3, "brand_id": 1 } ] } ``` When a customer purchases a product with bonuses, they automatically receive the bonus products. *** ## Downloadable Files Digital downloads and bonus downloads are stored in `product_files`. Each file row can include: * `purpose` - `digital` or `bonus` * `title` - Optional display title * `description` - Optional description shown in customer-facing download UIs * `image` - Optional thumbnail/cover image URL for this file * `file` - File URL * `kind` - Coarse classification derived from the file extension and `metadata.mime_type`. One of `audio`, `video`, `document`, `image`, `archive`, `file`. Lets clients branch on file type without re-parsing the URL. * `is_primary` - Marks the default file for that purpose * `sort_order` - Controls display order * `metadata` - Public file metadata. Only the following fields are exposed: * `mime_type` - MIME type of the file (e.g. `"audio/mpeg"`, `"application/pdf"`) * `size` - Size in bytes * `checksum` - Optional checksum recorded at upload (may be `null`) * `extension` - Lowercase extension without the dot (e.g. `"pdf"`, `"mp3"`). Derived from the file URL when possible, with a small mime-type fallback for opaque/signed URLs. Empty string when neither source can produce one. Internal storage pointers (`provider`, `storage_path`, `brand_file_id`) are intentionally not returned. ```json theme={null} { "product_files": [ { "purpose": "digital", "title": "Quick Start Guide", "description": "PDF onboarding guide", "image": "https://cdn.example.com/guide-cover.jpg", "file": "https://cdn.example.com/guide.pdf", "kind": "document", "is_primary": true, "sort_order": 0, "metadata": { "mime_type": "application/pdf", "size": 182734, "checksum": null, "extension": "pdf" } }, { "purpose": "bonus", "title": "Bonus Audio", "file": "https://cdn.example.com/bonus.mp3", "kind": "audio", "is_primary": true, "sort_order": 0, "metadata": { "mime_type": "audio/mpeg", "size": 10876045, "checksum": null, "extension": "mp3" } } ] } ``` Use bonus-product relationships for entitlement, and use `product_files` for the actual downloadable assets those products expose. *** ## Custom Attributes Products support custom key-value attributes for flexibility: ```json theme={null} { "custom_attributes": [ { "key": "download", "value": "https://example.com/download" }, { "key": "access_code", "value": "ABC123" } ] } ``` Common uses: * Download links for digital products * Access codes for member areas * Special instructions * Custom tracking parameters *** ## Merchant Product IDs Map your ElasticFunnels products to merchant platform product IDs: ```json theme={null} { "merchant_product_ids": { "buygoods": "1234", "clickbank": "PROD-001", "stripe": "prod_abc123" } } ``` *** ## Subscription Tiers Products can have tiered subscription billing — different prices at different intervals (e.g. monthly then annually). Pass `subscription_tiers` as an array in the Create or Update body. Mark product as a subscription product Default billing interval count (e.g. `1` for every-1-month) `day`, `week`, `month`, or `year` Free trial length in days (min: 0) Waive the first billing charge Array of tier objects. When provided, overrides the flat `subscription_frequency` / `subscription_frequency_unit` values. `day`, `week`, `month`, or `year` How many units of `interval` between charges (min: 1) Billing amount for this tier (min: 0) Optional tier label (max 64 chars) Display label shown to the customer (max 255 chars) Discount shown as "subscribe & save" (0–100%) ```json Subscription tiers example theme={null} { "is_subscription": true, "subscription_trial_days": 14, "subscribe_save_discount_percent": 15, "subscription_tiers": [ { "tier": "monthly", "interval": "month", "interval_count": 1, "price": 29.99, "label": "Monthly — $29.99/mo" }, { "tier": "annual", "interval": "year", "interval_count": 1, "price": 249.00, "label": "Annual — $249/yr (save 30%)" } ] } ``` *** ## Product Variants & Options Products can have variants (e.g. size/colour combinations). Variants are managed inline via the `product_fields` and `variants` arrays on Create/Update. ### Product Fields (variant dimensions) `product_fields` defines the variant dimensions (e.g. "Size", "Colour"): ```json theme={null} { "product_fields": [ { "name": "Size", "type": "select", "options": ["S", "M", "L", "XL"], "position": 0 }, { "name": "Colour", "type": "select", "options": ["Red", "Blue"], "position": 1 } ] } ``` ### Variants Each variant is a combination of field values with an optional per-variant price and SKU: ```json theme={null} { "variants": [ { "option1": "S", "option2": "Red", "code": "SKU-S-RED", "price": 29.99 }, { "option1": "M", "option2": "Red", "code": "SKU-M-RED", "price": 29.99 }, { "option1": "L", "option2": "Blue", "code": "SKU-L-BLUE", "price": 34.99 } ] } ``` Existing variant ID for update (omit to create new) Variant SKU/code (max 128 chars) Value for the first product field (max 128 chars) Value for the second product field (max 128 chars) Value for the third product field (max 128 chars) Override price for this variant (null = use product base price) Variant image URL (max 512 chars) 3-letter ISO currency code (e.g. `USD`) *** ## Variant Options Variant options are brand-level reusable option sets (e.g. "Shoe Sizes", "Colours"). They can be attached to multiple products. ### List Variant Options ``` GET /api/brands/{brand}/variant-options ``` ### Get All Variant Options (unpaginated) ``` GET /api/brands/{brand}/variant-options/all ``` ### Create Variant Option ``` POST /api/brands/{brand}/variant-options ``` Option set name (e.g. `Shirt Sizes`) Array of option value strings (e.g. `["XS", "S", "M", "L", "XL"]`) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/variant-options \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "Shirt Sizes", "values": ["XS", "S", "M", "L", "XL"]}' ``` ### Update Variant Option ``` PUT /api/brands/{brand}/variant-options/{variant_option} ``` ### Delete Variant Option ``` DELETE /api/brands/{brand}/variant-options/{variant_option} ``` *** ## Product Categories Organise products into a two-level category hierarchy (category → subcategory). ### List Categories ``` GET /api/brands/{brand}/product-categories ``` ### Get All Categories (unpaginated) ``` GET /api/brands/{brand}/product-categories/all ``` ### Create Category ``` POST /api/brands/{brand}/product-categories ``` Category name Parent category ID — omit for top-level categories, provide for subcategories. ```bash cURL — create top-level category theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/product-categories \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "Supplements"}' ``` ```bash cURL — create subcategory theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/product-categories \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "Weight Loss", "parent_id": 3}' ``` ```json 201 Created theme={null} { "id": 4, "name": "Weight Loss", "parent_id": 3, "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } ``` ### Update Category ``` PUT /api/brands/{brand}/product-categories/{product_category} ``` ### Delete Category ``` DELETE /api/brands/{brand}/product-categories/{product_category} ``` When creating a product, `product_category_id` must be a **top-level** category (no `parent_id`). To assign a subcategory, also provide `product_subcategory_id` (which must belong to the category). Providing a subcategory without a category returns a 422. *** ## Coupons Create and manage discount codes for products. ### List Coupons ``` GET /api/brands/{brand}/coupons ``` Results per page (max 100, default: 25) ### Get Coupon ``` GET /api/brands/{brand}/coupons/{coupon} ``` ### Create Coupon ``` POST /api/brands/{brand}/coupons ``` Coupon code (max 64 chars). Must be unique per brand. `percent` — percentage off, or `fixed` — fixed amount off Discount value. For `percent`, clamped to 0–100 automatically. Internal label (max 128 chars) `active` (default), `draft`, or `disabled` Limit discount to specific product IDs. Empty/null means all products. Product IDs to exclude from the discount. Whether the coupon applies to order bump products. ISO 8601 date — coupon becomes active from this date. ISO 8601 date — coupon expires after this date. Must be on or after `valid_from`. Maximum number of redemptions (null = unlimited). ```bash cURL — 20% off all products theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/coupons \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "code": "SAVE20", "name": "20% Off Launch Promo", "type": "percent", "value": 20, "status": "active", "valid_from": "2024-12-01", "valid_until": "2024-12-31", "usage_limit": 500 }' ``` ```bash cURL — $10 off specific products theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/coupons \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "code": "BOTTLE10", "type": "fixed", "value": 10, "product_ids": [5, 6] }' ``` ```json 201 Created theme={null} { "id": 12, "code": "SAVE20", "name": "20% Off Launch Promo", "type": "percent", "value": 20, "status": "active", "product_ids": null, "excluded_product_ids": null, "applies_on_bump": false, "valid_from": "2024-12-01", "valid_until": "2024-12-31", "usage_limit": 500, "brand_id": 42, "created_at": "2024-12-11T10:00:00.000000Z" } ``` ```json 422 Duplicate code theme={null} { "errors": { "code": ["The code has already been taken."] } } ``` ### Update Coupon ``` PUT /api/brands/{brand}/coupons/{coupon} ``` All fields optional on update. Use this to disable an expired coupon: ```bash cURL — disable coupon theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/coupons/12 \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"status": "disabled"}' ``` ### Delete Coupon ``` DELETE /api/brands/{brand}/coupons/{coupon} ``` ### Validate Coupon Check if a coupon code is valid and get its details (useful for frontend validation before applying at checkout). ``` GET /api/brands/{brand}/coupons/validate?code=SAVE20 ``` Coupon code to validate ```json 200 Valid theme={null} { "valid": true, "coupon": { "id": 12, "code": "SAVE20", "type": "percent", "value": 20, "product_ids": null } } ``` ```json 200 Invalid theme={null} { "valid": false, "reason": "Coupon has expired." } ``` *** ## Notes * Product `code` must be unique within the brand * `classification` of `bonus` products are automatically included with their parent products * Subscription products require `subscription_frequency` and `subscription_frequency_unit` * All costs (COGS, fulfillment, packaging) are tracked for profit margin calculations * Images are returned as both `image` (relative path) and `image_link` (full URL) * Downloadable assets live in `product_files`, not top-level `bonus_file` / `digital_file` columns * `product_category_id` must be a root-level category; use `product_subcategory_id` for sub-level * Coupon `type: "percent"` values are clamped to 0–100 automatically When updating products that are actively used in funnels, changes may affect existing customer orders and analytics. # Quizzes Source: https://docs.elasticfunnels.io/api-reference/endpoints/quizzes Create and manage quizzes, screens, and flow data Quiz endpoints sit under `/api/brands/{brand}/quizzes/` with `moduleAccess:quizzes` middleware. Authenticate with `EF-Access-Key`. *** ## List Quizzes ``` GET /api/brands/{brand}/quizzes ``` Results per page (1–100, default: 25) Search by name `newest`, `oldest`, `created_at`, `updated_at` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/quizzes \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 4, "code": "health-quiz-xyz", "name": "Health Assessment Quiz", "page_id": 201, "total_entries": 312, "updated_at": "2024-12-10T15:00:00.000000Z" } ], "per_page": 25, "total": 4, "last_page": 1 } ``` *** ## List Quizzes (Unpaginated) ``` GET /api/brands/{brand}/quizzes/all ``` Returns `{ id, name, code }` for all quizzes — useful for dropdowns and mapping. *** ## Get Quiz ``` GET /api/brands/{brand}/quizzes/{quiz} ``` ```json Response theme={null} { "id": 4, "code": "health-quiz-xyz", "name": "Health Assessment Quiz", "page_id": 201, "page": { "id": 201, "title": "Health Quiz Page", "slug": "health-quiz" }, "total_entries": 312, "config": { /* quiz theme/layout config */ }, "fields": [ { "id": 10, "code": "screen-abc", "name": "Question 1", "type": "single_choice", "order": 0 } ], "slug": "health-quiz", "domain": { "domain": "quiz.mybrand.com" } } ``` `flow_data` is **not** included in the `show` response. Use `GET .../flow` to retrieve the flow graph. *** ## Create Quiz ``` POST /api/brands/{brand}/quizzes ``` Quiz name ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/quizzes \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "Health Assessment Quiz"}' ``` ```json 200 Created theme={null} { "quiz": { "id": 4, "code": "health-quiz-xyz", "name": "Health Assessment Quiz", "page_id": 201, "total_entries": 0 } } ``` ```json 403 Plan limit theme={null} { "error": "Plan limit exceeded", "plan_error": { "type": "plan_upgrade_required", "feature": "quizzes", "message": "Your plan does not allow creating more quizzes.", "plan_name": "Starter", "upgrade_info": {} }, "errors": { /* field errors */ } } ``` *** ## Update Quiz ``` PUT /api/brands/{brand}/quizzes/{quiz} ``` Quiz name (max 255) ID of the page to associate with this quiz Quiz display configuration (theme, layout, computed variables, etc.) ```json 200 Updated theme={null} { "id": 4, "code": "health-quiz-xyz", "name": "Health Assessment Quiz", "config": { /* updated config */ } } ``` ```json 422 Validation error theme={null} { "message": "Validation failed.", "errors": { "name": ["The name field is required."] }, "component_errors": [] } ``` *** ## Delete Quiz ``` DELETE /api/brands/{brand}/quizzes/{quiz} ``` ```json Response theme={null} { "success": true } ``` *** ## Get Quiz Flow Returns the visual flow graph (Drawflow format) and all screen definitions. ``` GET /api/brands/{brand}/quizzes/{quiz}/flow ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/quizzes/4/flow \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "flow": { "drawflow": { "Home": { "data": { "1": { "id": 1, "name": "Question 1", "inputs": {}, "outputs": {} }, "2": { "id": 2, "name": "Result Screen", "inputs": {}, "outputs": {} } } } } }, "screens": [ { "id": 10, "code": "screen-abc", "name": "Question 1", "type": "single_choice", "order": 0 } ] } ``` If no flow has been saved yet, `flow` defaults to a minimal empty Drawflow structure (`{"drawflow":{"Home":{"data":[]}}}`). *** ## Save Quiz Flow ``` POST /api/brands/{brand}/quizzes/{quiz}/flow ``` The complete Drawflow graph object. Can be `null` to clear the flow. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/quizzes/4/flow \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"flow": { /* drawflow object */ }}' ``` ```json Response theme={null} { "success": true, "message": "Flow saved successfully" } ``` *** ## Screens Quiz screens are the individual question/result pages within a quiz. They are nested under the quiz resource. ### List Screens ``` GET /api/brands/{brand}/quizzes/{quiz}/screens ``` ```json Response theme={null} { "data": [ { "id": 10, "code": "screen-abc", "name": "Do you experience bloating?", "type": "single_choice", "order": 0, "options": [ { "label": "Yes, often", "value": "often" }, { "label": "Sometimes", "value": "sometimes" }, { "label": "Never", "value": "never" } ], "style": "light" } ] } ``` ### Get Screen ``` GET /api/brands/{brand}/quizzes/{quiz}/screens/{screen} ``` ### Create Screen ``` POST /api/brands/{brand}/quizzes/{quiz}/screens ``` Screen name / question title Screen type: `single_choice`, `multiple_choice`, `text_input`, `rating`, `result`, `info`, `video`, `image` Display title shown to the user Subtitle or question description Answer options (for choice screens) — each with `label` and `value` Additional screen-level configuration HTML content (for `info` and `result` screens) `light` or `dark` Display order. Auto-incremented if omitted. Audio file upload (multipart) Image file upload (multipart) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/quizzes/4/screens \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Bloating Question", "type": "single_choice", "title": "Do you experience bloating after meals?", "style": "light", "options": [ {"label": "Yes, often", "value": "often"}, {"label": "Sometimes", "value": "sometimes"}, {"label": "Never", "value": "never"} ] }' ``` ```json Response theme={null} { "quiz": { "id": 10, "code": "screen-abc", "name": "Bloating Question", "type": "single_choice", "order": 3 } } ``` The response key is `"quiz"` even though the value is a screen object. This is a known naming quirk. ### Update Screen ``` PUT /api/brands/{brand}/quizzes/{quiz}/screens/{screen} ``` Same body as Create. ### Delete Screen ``` DELETE /api/brands/{brand}/quizzes/{quiz}/screens/{screen} ``` ```json Response theme={null} { "success": true } ``` ### Reorder Screens ``` POST /api/brands/{brand}/quizzes/{quiz}/screens/order ``` Map of `{ "": , ... }` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/quizzes/4/screens/order \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"order": {"10": 0, "11": 1, "12": 2}}' ``` ```json Response theme={null} { "success": true } ``` ### Duplicate Screen ``` POST /api/brands/{brand}/quizzes/{quiz}/screens/{screen}/duplicate ``` Creates an exact copy of the screen with a new code and auto-assigned order. ```json Response theme={null} { "success": true } ``` *** ## Notes * `flow_data` is separate from the quiz's `config` and screen definitions — save and retrieve it with the dedicated `/flow` endpoints * Screen `code` values are auto-generated unique identifiers used in the flow graph to connect questions to answer paths * `total_entries` tracks submission count and is incremented automatically when entries are submitted via the public form # Split Tests Source: https://docs.elasticfunnels.io/api-reference/endpoints/split-tests Create and manage A/B split tests, read statistical results, declare winners All split test endpoints authenticate via `EF-Access-Key` with `brandAccess` middleware. Request bodies are nested under a `split_test` key. *** ## List Split Tests ``` GET /api/brands/{brand}/split-tests ``` The brand/project ID Results per page (1–100, default: 25) Filter by status: `running`, `paused`, `finalized`, `deleted` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests?status=running" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "current_page": 1, "data": [ { "id": 7, "name": "Headline Test — Hero Section", "type": "split_test", "status": "running", "page_id": 101, "funnel_id": null, "start_date": "2024-12-01", "end_date": "2024-12-31", "duration": 30, "auto_select_winner": false, "winner_selection_criteria": null, "winner_selection_confidence": 95, "sample_size_limit": null, "baseline": "control", "views": 4820, "time_left": "20 days", "user": { "id": 1, "name": "Sam" }, "page": { "id": 101, "title": "Nutra Landing Page" }, "funnel": null, "created_at": "2024-12-01T10:00:00.000000Z" } ], "per_page": 25, "total": 3, "last_page": 1 } ``` *** ## Get Split Test ``` GET /api/brands/{brand}/split-tests/{split_test} ``` The brand/project ID Split test ID ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7 \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": 7, "name": "Headline Test — Hero Section", "type": "split_test", "status": "running", "page_id": 101, "funnel_id": null, "start_date": "2024-12-01", "end_date": "2024-12-31", "statistical_method": "frequentist", "significance_level": 0.05, "statistical_power": 0.8, "primary_metric": "conversion_rate", "config": { "items": [ { "name": "Control", "node_code": "ctrl-abc123", "weight": 50 }, { "name": "Variant B", "node_code": "var-def456", "weight": 50 } ] }, "winner": null, "goals": [ { "name": "Purchase", "value": "purchase" } ], "created_at": "2024-12-01T10:00:00.000000Z", "updated_at": "2024-12-10T15:30:00.000000Z" } ``` *** ## Create Split Test ``` POST /api/brands/{brand}/split-tests ``` All body fields must be nested under the `split_test` key (e.g. `split_test.name`, `split_test.config`). The brand/project ID Human-readable name for the test `split_test` (funnel/page variants) or `component_split_test` (component-level test) Test configuration including `items` array. Each item has `name`, `node_code`, and `weight`. `running`, `paused`, `completed`, `deleted` (default: `running`) ISO 8601 date (default: today) ISO 8601 date Primary metric to optimise for (e.g. `conversion_rate`, `revenue`) `frequentist` (default) or `bayesian` Alpha level (0.001–0.5, default: 0.05) Beta level (0.1–0.99, default: 0.8) Minimum visitors per variant before declaring a winner (100–1,000,000) Automatically select winner when confidence threshold is reached Confidence % required for auto-winner selection (e.g. `95`) Conversion goal events. Each goal has `name` (string) and `value` (alphanumeric slug). The control variant's name or node code ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "split_test": { "name": "Headline Test — Hero Section", "type": "split_test", "status": "running", "page_id": 101, "primary_metric": "conversion_rate", "significance_level": 0.05, "winner_selection_confidence": 95, "start_date": "2024-12-01", "end_date": "2024-12-31", "goals": [ { "name": "Purchase", "value": "purchase" } ], "config": { "items": [ { "name": "Control", "node_code": "ctrl-abc123", "weight": 50 }, { "name": "Variant B", "node_code": "var-def456", "weight": 50 } ] } } }' ``` ```json 200 Created theme={null} { "id": 7, "name": "Headline Test — Hero Section", "type": "split_test", "status": "running", "page_id": 101, "brand_id": 42, "start_date": "2024-12-01", "end_date": "2024-12-31", "primary_metric": "conversion_rate", "significance_level": 0.05, "statistical_method": "frequentist", "winner_selection_confidence": 95, "config": { "items": [ { "name": "Control", "node_code": "ctrl-abc123", "weight": 50 }, { "name": "Variant B", "node_code": "var-def456", "weight": 50 } ] }, "goals": [{ "name": "Purchase", "value": "purchase" }], "created_at": "2024-12-01T10:00:00.000000Z" } ``` ```json 422 Validation error theme={null} { "errors": { "split_test.name": ["The split test.name field is required."], "split_test.config": ["The split test.config field is required."] } } ``` *** ## Update Split Test ``` PUT /api/brands/{brand}/split-tests/{split_test} ``` Same body structure as Create — all fields nested under `split_test`, all optional on update except as noted. To pause a running test: set `split_test.status` to `paused`. ```bash cURL — pause a running test theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7 \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"split_test": {"status": "paused"}}' ``` ```bash cURL — extend end date theme={null} curl -X PUT https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7 \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"split_test": {"end_date": "2025-01-15"}}' ``` ```json 200 Updated theme={null} { "id": 7, "name": "Headline Test — Hero Section", "status": "paused", "end_date": "2025-01-15", "updated_at": "2024-12-11T09:00:00.000000Z" } ``` *** ## Delete Split Test ``` DELETE /api/brands/{brand}/split-tests/{split_test} ``` ```bash cURL theme={null} curl -X DELETE https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7 \ -H "EF-Access-Key: your_api_key_here" ``` ```json 200 theme={null} { "message": "Split test deleted successfully." } ``` *** ## Get Chart Data Retrieve time-series and comparison data for visualisations. ``` GET /api/brands/{brand}/split-tests/{split_test}/chart-data ``` Split test ID One of: `conversion_trends`, `funnel_analysis`, `statistical_confidence`, `performance_comparison` Start date (ISO 8601) End date (ISO 8601) Timezone (e.g. `America/New_York`) ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7/chart-data?chart_type=conversion_trends&start=2024-12-01&end=2024-12-10" \ -H "EF-Access-Key: your_api_key_here" ``` ```json conversion_trends theme={null} { "chart_type": "conversion_trends", "labels": ["Dec 1", "Dec 2", "Dec 3"], "datasets": [ { "label": "Control", "data": [0.031, 0.033, 0.029], "node_code": "ctrl-abc123" }, { "label": "Variant B", "data": [0.038, 0.041, 0.039], "node_code": "var-def456" } ] } ``` ```json statistical_confidence theme={null} { "chart_type": "statistical_confidence", "variants": [ { "name": "Control", "node_code": "ctrl-abc123", "conversion_rate": 0.031, "conversions": 149, "visitors": 4806, "confidence": null, "is_baseline": true }, { "name": "Variant B", "node_code": "var-def456", "conversion_rate": 0.039, "conversions": 188, "visitors": 4820, "confidence": 87.4, "lift": 25.8, "is_winner": false } ], "required_confidence": 95, "has_winner": false } ``` *** ## Declare Winner Manually select the winning variant and finalize the test. Sets `status` to `finalized`. ``` POST /api/brands/{brand}/split-tests/{split_test}/declare-winner ``` The `node_code` of the winning variant Display name of the winning variant Statistical confidence at time of declaration (0–100) Relative lift over baseline (e.g. `25.8` for 25.8% improvement) Free-text note for the decision (max 500 chars) ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7/declare-winner \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "winner_node_code": "var-def456", "variant_name": "Variant B", "confidence": 87.4, "improvement": 25.8, "reason": "Strong uplift on mobile; acceptable confidence given campaign deadline." }' ``` ```json 200 Winner declared theme={null} { "message": "Winner declared successfully", "split_test": { "id": 7, "status": "finalized", "winner_selected_at": "2024-12-11T09:15:00.000000Z", "winner_selected_by": "manual" }, "winner": { "variant": "Variant B", "node_code": "var-def456", "confidence": 87.4, "improvement": 25.8 } } ``` *** ## Export Results Queue a CSV/Excel export of all split test results. An email is sent when ready. ``` POST /api/brands/{brand}/split-tests/{split_test}/export ``` `csv` (default) or `excel` Include p-values, confidence intervals, lift (default: `true`) Include CI columns (default: `false`) Optional `{start, end}` to restrict export date range ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7/export \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"format": "csv", "include_statistics": true}' ``` ```json 200 theme={null} { "message": "Export has been queued. You will receive an email when it's ready.", "status": "queued", "split_test_name": "Headline Test — Hero Section" } ``` *** ## Get Node Codes Return the variant names and `node_code` identifiers for a split test — useful for linking analytics filter calls. ``` GET /api/brands/{brand}/split-tests/{split_test}/node-codes ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7/node-codes \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} [ { "code": "ctrl-abc123", "name": "Control" }, { "code": "var-def456", "name": "Variant B" } ] ``` *** ## Quick Access Stats Counts by status — useful for dashboard badges. ``` GET /api/brands/{brand}/split-tests/quick-access ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/quick-access \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "all": 12, "running": 3, "finalized": 7, "not_running": 2 } ``` *** ## Notes Add freeform notes to a split test for team collaboration. ### List notes ``` GET /api/brands/{brand}/split-tests/{split_test}/notes ``` ### Add note ``` POST /api/brands/{brand}/split-tests/{split_test}/notes ``` Note text ### Delete note ``` DELETE /api/brands/{brand}/split-tests/{split_test}/notes/{note} ``` ```bash cURL — add note theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/split-tests/7/notes \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"content": "Pausing — holiday traffic skewing mobile numbers."}' ``` ```json Note added theme={null} { "id": 3, "split_test_id": 7, "content": "Pausing — holiday traffic skewing mobile numbers.", "user": { "id": 1, "name": "Sam" }, "created_at": "2024-12-11T09:30:00.000000Z" } ``` *** ## Using Split Test Data with Analytics To filter analytics metrics by split test variant, pass the `split_test_id` and `filter[node_code]` to the analytics endpoints: ```bash theme={null} # Metrics for a specific variant GET /api/brands/{brand}/analytics/metrics/data ?start=2024-12-01T00:00:00Z &end=2024-12-10T23:59:59Z &selected_metrics[]=conversion_rate&selected_metrics[]=revenue &split_test_id=7 &filter[node_code]=var-def456 ``` ```bash theme={null} # Compare metrics across all variants grouped by node_code GET /api/brands/{brand}/analytics/metrics/node_code/data ?start=2024-12-01T00:00:00Z &end=2024-12-10T23:59:59Z &selected_metrics[]=conversion_rate &split_test_id=7 ``` # Subscriptions Source: https://docs.elasticfunnels.io/api-reference/endpoints/subscriptions Manage subscription lifecycle, analytics, and retention ## List Subscriptions Returns a paginated list of subscriptions for the brand. Supports filters for status, date ranges, search, merchant, and product. The brand/project ID Page number for pagination Results per page Filter by subscription status: `active`, `trial`, `paused`, `past_due`, `canceled` (use `all` or omit for no status filter) Search by customer name, email, public order ID, or transaction ID Start of date range (ISO 8601 or date string accepted by the API) End of date range (ISO 8601 or date string accepted by the API) Filter by merchant ID Filter by product code Filter by customer email ```bash Request theme={null} GET /api/brands/{brand}/subscriptions ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions?status=active&page=1" \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Show Subscription Returns full details of a single subscription (identified by **transaction ID**), including enriched subscription flags from the conversion record. The brand/project ID Subscription **transaction ID** (not the public order code) ```bash Request theme={null} GET /api/brands/{brand}/subscriptions/{id} ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/{transaction_id}" \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Update Subscription Flag Updates a keyed flag on the subscription conversion (for example `subscription_status`, `subscription_next_charge_at`). In practice, prefer the **dedicated POST endpoints** below for lifecycle actions; they perform validation and side effects. Use a generic flag update only when you need to align data with an external system and your deployment exposes this route. The brand/project ID Subscription transaction ID Flag key (e.g. `subscription_status`, `subscription_next_charge_at`) New value for the flag ```bash Request theme={null} PATCH /api/brands/{brand}/subscriptions/{id}/flag ``` ```bash cURL theme={null} curl -X PATCH "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/{transaction_id}/flag" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"key":"subscription_status","value":"paused"}' ``` If this path returns 404, use **Pause**, **Resume**, **Reactivate**, **Change Next Bill Date**, and related POST routes in this reference instead. *** ## Skip Next Charge Advances the subscription by one billing cycle without taking payment now (skips the next scheduled charge). The brand/project ID Subscription transaction ID ```bash Request theme={null} POST /api/brands/{brand}/subscriptions/{id}/skip ``` ```bash cURL theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/{transaction_id}/skip" \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Force Bill Triggers an immediate billing attempt for the subscription. The brand/project ID Subscription transaction ID ```bash Request theme={null} POST /api/brands/{brand}/subscriptions/{id}/force-bill ``` ```bash cURL theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/{transaction_id}/force-bill" \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Change Next Charge Date Updates the next billing date for the subscription. The brand/project ID Subscription transaction ID New charge date/time (must be after now; ISO 8601 recommended) ```bash Request theme={null} POST /api/brands/{brand}/subscriptions/{id}/change-next-bill-date ``` ```bash cURL theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/{transaction_id}/change-next-bill-date" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"next_charge_at":"2026-04-15T12:00:00Z"}' ``` *** ## Reactivate Subscription Reactivates a canceled (or otherwise eligible) subscription. The brand/project ID Subscription transaction ID ```bash Request theme={null} POST /api/brands/{brand}/subscriptions/{id}/reactivate ``` ```bash cURL theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/{transaction_id}/reactivate" \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Subscription Emails Returns the email log for a subscription (from brand email logs when available). The brand/project ID Subscription transaction ID ```bash Request theme={null} GET /api/brands/{brand}/subscriptions/{id}/emails ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/{transaction_id}/emails" \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Analytics Endpoints ### Summary Returns key metrics such as active count, MRR, churn, and average LTV (exact fields depend on implementation). ```bash Request theme={null} GET /api/brands/{brand}/subscriptions/analytics/summary ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/analytics/summary" \ -H "EF-Access-Key: your_api_key_here" ``` ### MRR Trend Returns monthly MRR values over time. ```bash Request theme={null} GET /api/brands/{brand}/subscriptions/analytics/mrr-trend ``` ### Churn Trend Returns monthly churn rate values over time. ```bash Request theme={null} GET /api/brands/{brand}/subscriptions/analytics/churn-trend ``` ### Cohort LTV Returns lifetime value analysis grouped by subscription start month. ```bash Request theme={null} GET /api/brands/{brand}/subscriptions/analytics/cohort-ltv ``` ### Dunning Stats Returns dunning recovery metrics: totals in dunning, recovered, lost, and recovery rate. ```bash Request theme={null} GET /api/brands/{brand}/subscriptions/analytics/dunning-stats ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/subscriptions/analytics/dunning-stats" \ -H "EF-Access-Key: your_api_key_here" ``` *** ## Retention Offers Retention configuration is stored **per merchant**. Replace `{merchant}` with the merchant record ID. ### Get Retention Offers Returns the merchant's configured retention offers and exit survey reasons. The brand/project ID Merchant ID ```bash Request theme={null} GET /api/brands/{brand}/merchants/{merchant}/retention-offers ``` ### Save Retention Offers Updates the merchant's retention offer configuration. The brand/project ID Merchant ID Configuration object: `enabled`, `offers` (array with `offer_type`, optional `cancel_reason`, `offer_value`, `offer_label`, `offer_duration_cycles`, `offer_product_code`), and optional `exit_survey_reasons` ```bash Request theme={null} PUT /api/brands/{brand}/merchants/{merchant}/retention-offers ``` ```bash cURL theme={null} curl -X PUT "https://app.elasticfunnels.io/api/brands/{brand_id}/merchants/{merchant_id}/retention-offers" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"retention_offers":{"enabled":true,"offers":[],"exit_survey_reasons":[]}}' ``` ### Apply Retention Offer Applies a retention offer to a specific subscription transaction. The brand/project ID The subscription transaction ID Merchant ID that owns the transaction `discount`, `pause`, or `downgrade` For `discount`, percent off; for `pause`, pause length in **days** (minimum 1) For `discount`, number of billing cycles the discount applies For `downgrade`, target product code when applicable Customer's selected cancellation reason ```bash Request theme={null} POST /api/brands/{brand}/retention-offers/apply ``` ```bash cURL theme={null} curl -X POST "https://app.elasticfunnels.io/api/brands/{brand_id}/retention-offers/apply" \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"transaction_id":"...","merchant_id":1,"offer_type":"discount","offer_value":15,"offer_duration_cycles":3,"cancel_reason":"Too expensive"}' ``` # Tickets Source: https://docs.elasticfunnels.io/api-reference/endpoints/tickets Manage support tickets and customer inquiries ## List Tickets Retrieve all support tickets for a project. The brand/project ID Page number for pagination (default: 1) Number of results per page (default: 30) Filter by status: `open`, `closed`, `waiting`, `resolved`, `spam` Search tickets by subject, email, or content Sort order: `date_asc` or `date_desc` ```bash Request theme={null} GET /api/brands/{brand}/tickets ``` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/tickets?status=open&page=1" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "data": [ { "brand_id": 123, "ticket_code": "TKT-12345", "type": "support", "status": "open", "subject": "Question about my order", "from_name": "John Doe", "from_email": "john@example.com", "customer_email": "john@example.com", "preview_message": "I have a question about...", "read": false, "created_at": "2024-12-10T10:30:00.000000Z", "updated_at": "2024-12-10T10:30:00.000000Z", "created_at_label": "2 hours ago", "status_label": "Open" } ], "meta": { "current_page": 1, "per_page": 30, "total": 45 }, "statuses": { "open": 15, "waiting": 10, "closed": 18, "resolved": 2, "spam": 0 } } ``` *** ## Get Ticket Statuses Get count of tickets by status. The brand/project ID ```bash Request theme={null} GET /api/brands/{brand}/tickets/statuses ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/tickets/statuses \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "open": 15, "waiting": 10, "closed": 18, "resolved": 2, "spam": 0 } ``` *** ## Mark Ticket as Read Mark a ticket as read. The brand/project ID The ticket code (e.g., "TKT-12345") ```bash Request theme={null} POST /api/brands/{brand}/tickets/{ticket_code}/mark-as-read ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/tickets/TKT-12345/mark-as-read \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "message": "Ticket marked as read" } ``` *** ## Mark Ticket as Unread Mark a ticket as unread. The brand/project ID The ticket code (e.g., "TKT-12345") ```bash Request theme={null} POST /api/brands/{brand}/tickets/{ticket_code}/mark-as-unread ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/tickets/TKT-12345/mark-as-unread \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "message": "Ticket marked as unread" } ``` *** ## List Ticket Messages Get all messages in a ticket conversation. The brand/project ID The ticket code (e.g., "TKT-12345") ```bash Request theme={null} GET /api/brands/{brand}/tickets/{ticket_code}/messages ``` ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/tickets/TKT-12345/messages \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "data": [ { "brand_id": 123, "ticket_code": "TKT-12345", "status": "open", "type": "customer_message", "message": "

I have a question about my order...

", "message_type": "text", "from_name": "John Doe", "created_at": "2024-12-10T10:30:00.000000Z", "created_at_label": "2 hours ago", "status_label": "Open" }, { "brand_id": 123, "ticket_code": "TKT-12345", "status": "waiting", "type": "agent_message", "message": "

Hi John, I'd be happy to help...

", "message_type": "text", "from_name": "Support Agent", "created_at": "2024-12-10T10:45:00.000000Z", "created_at_label": "1 hour ago", "status_label": "Waiting" } ] } ```
*** ## Send Ticket Reply Send a reply message to a ticket. The brand/project ID The ticket code (e.g., "TKT-12345") The reply message (HTML supported) Message type: `agent_message` or `system_message` ```bash Request theme={null} POST /api/brands/{brand}/tickets/{ticket_code}/messages ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/tickets/TKT-12345/messages \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "message": "

Thank you for contacting us. Your order is being processed...

", "type": "agent_message" }' ``` ```json Response theme={null} { "brand_id": 123, "ticket_code": "TKT-12345", "status": "waiting", "type": "agent_message", "message": "

Thank you for contacting us. Your order is being processed...

", "from_name": "Support Agent", "created_at": "2024-12-10T12:00:00.000000Z" } ```
*** ## Set Ticket Status Update the status of a ticket. The brand/project ID The ticket code (e.g., "TKT-12345") New status: `open`, `waiting`, `closed`, `resolved`, `spam` ```bash Request theme={null} POST /api/brands/{brand}/tickets/{ticket_code}/set-status/{status} ``` ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/tickets/TKT-12345/set-status/resolved \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "message": "Email sent" } ``` *** ## Ticket Status Values | Status | Description | | - | - | | `open` | New ticket or reopened ticket | | `waiting` | Waiting for customer response | | `closed` | Ticket has been closed | | `resolved` | Issue has been resolved | | `spam` | Marked as spam | *** ## Message Types | Type | Description | | - | - | | `customer_message` | Message from customer | | `agent_message` | Message from support agent | | `system_message` | Automated system message | *** ## Notes * Tickets are automatically sorted by `created_at` for open tickets and `updated_at` for closed/resolved/spam tickets * Sending a reply automatically changes ticket status to "waiting" * The AI reply generation uses customer order history and purchase data when available * All ticket messages support HTML formatting # Tracking Source: https://docs.elasticfunnels.io/api-reference/endpoints/tracking Access click data, session replays, and manage short links Tracking endpoints sit under `/api/brands/{brand}/` with `moduleAccess:tracking` middleware. Authenticate with `EF-Access-Key`. *** ## Clicks The Clicks API exposes click tracking data from visitor sessions. Clicks are stored in Elasticsearch (or ClickHouse when enabled for the account). ### List Clicks ``` GET /api/brands/{brand}/clicks ``` Page size is fixed at **30** for the clicks index and cannot be changed via `per_page`. Page number (default: 1) Filter by URL (partial match) Filter by Sub ID 1 (`sub` tracking parameter) Filter by Sub ID 2 Filter by Sub ID 3 Filter by Sub ID 4 Filter by visit tracking ID Filter by specific click/session ID — collapses results to unique sessions Filter by IP address Filter by page ID Filter by funnel ID Filter by merchant ID Exclude bot traffic from results Date range start (ISO 8601). Used by `DateRangeService`. Column to sort by (limited to sortable fields) Sort direction: `asc` or `desc` ```bash cURL theme={null} curl "https://app.elasticfunnels.io/api/brands/{brand_id}/clicks?funnel_id=5&page=1" \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "total": 1240, "last_page": 42, "current_page": 1, "per_page": 30, "next_page_url": true, "data": [ { "id": "click-es-doc-abc", "session_id": "sess-xyz-123", "ip_address": "203.0.113.50", "url": "/checkout", "funnel_id": 5, "domain": { "id": 7, "domain": "checkout.mybrand.com" }, "affiliate": null, "merchant": { "id": 1, "merchant": "NMI" }, "events": [ /* event objects */ ], "created_at": "2024-12-10T09:00:00Z" } ] } ``` *** ### Get Click Details ``` GET /api/brands/{brand}/clicks/{click_code} ``` Session ID or click code ```json Response theme={null} { "id": "click-es-doc-abc", "session_id": "sess-xyz-123", "ip_address": "203.0.113.50", "url": "/checkout", "funnel_id": 5, "domain": { /* domain */ }, "events": [ /* events */ ] } ``` ```json 404 theme={null} { "message": "Not found" } ``` *** ### Get Session Details Returns geo data and the full sequence of events for a visitor session. ``` GET /api/brands/{brand}/clicks/session/{sessionId} ``` Session ID ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/clicks/session/sess-xyz-123 \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "geoData": { "lat": 34.052, "lon": -118.244, "city": "Los Angeles", "region": "CA", "country": "US", "zip": "90001", "ip_address": "203.0.113.50" }, "events": [ { "type": "page_view", "url": "/checkout", "timestamp": "2024-12-10T09:00:00Z", "data": {} }, { "type": "add_to_cart", "timestamp": "2024-12-10T09:01:30Z", "data": { "product_id": 3 } } ], "domains": { "7": "https://checkout.mybrand.com" } } ``` ```json No data found theme={null} { "geoData": [], "events": [] } ``` *** ### Export Clicks Queue a CSV export of click records. ``` POST /api/brands/{brand}/clicks/export ``` Filter conditions (same as list query params) Limit export to specific click codes Columns to include in the export ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/clicks/export \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"columns": ["session_id", "ip_address", "url", "created_at"]}' ``` ```json Response theme={null} { "success": true, "message": "Export started. You will receive an email when it is ready." } ``` *** ## Session Replays Session replays capture mouse movements, clicks, and scroll events for visitor sessions. ### List Session Replays ``` GET /api/brands/{brand}/replays ``` Session ID to retrieve replays for ```json Response theme={null} [ { "id": "replay-doc-abc", "session_id": "sess-xyz-123", "page_session_id": "page-sess-456" } ] ``` *** ### Get Session Replay Retrieve the full recorded event stream for a session. ``` GET /api/brands/{brand}/replays/{sessionId} ``` Session replay document ID (from the list endpoint) ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id}/replays/replay-doc-abc \ -H "EF-Access-Key: your_api_key_here" ``` ```json Response theme={null} { "id": "replay-doc-abc", "session_id": "sess-xyz-123", "page_session_id": "page-sess-456", "url": "/checkout", "duration_ms": 45000, "viewport_width": 1440, "viewport_height": 900, "events": [ { "type": "mousemove", "x": 100, "y": 200, "ts": 1500 }, { "type": "click", "x": 300, "y": 400, "ts": 2300, "target": "button#buy-now" }, { "type": "scroll", "scrollY": 800, "ts": 5000 } ] } ``` If the session replay document does not exist in Elasticsearch, the response may produce an error. Always use `session_id` values returned from the list endpoint. *** ## Short Links Short links create trackable redirects for marketing campaigns, affiliate links, and social sharing. ### Generate Short Link ``` POST /api/brands/{brand}/short-links/generate ``` The destination URL the short link will redirect to. Must be a valid URL. ```bash cURL theme={null} curl -X POST https://app.elasticfunnels.io/api/brands/{brand_id}/short-links/generate \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"destination_url": "https://checkout.mybrand.com/offer?utm_source=email"}' ``` ```python Python theme={null} import requests response = requests.post( f'https://app.elasticfunnels.io/api/brands/{brand_id}/short-links/generate', headers={'EF-Access-Key': 'your_api_key_here'}, json={'destination_url': 'https://checkout.mybrand.com/offer?utm_source=email'} ) data = response.json() print(f"Short URL: {data['url']}") # https://go.mybrand.com/g/abc123 ``` ```json 201 Created theme={null} { "code": "abc123", "url": "https://go.mybrand.com/g/abc123" } ``` ```json 422 Invalid URL theme={null} { "errors": { "destination_url": ["Invalid URL"] } } ``` *** ## Notes * Click data is stored in **Elasticsearch** by default, but accounts with ClickHouse enabled use a separate engine — both are queried transparently via the same API * The session replay `events` array is stored as a compressed/encoded string in Elasticsearch and decoded automatically before being returned * Short links are served from the `SHORT_URL_DOMAIN` configured for your account (e.g. `go.yourdomain.com`) * The clicks `index` endpoint always returns **30 results per page** regardless of `per_page` parameter # Errors & Status Codes Source: https://docs.elasticfunnels.io/api-reference/errors Understanding API error responses ## Error Response Format Most failed requests return a JSON body. The **most common** validation/auth shape is: ```json theme={null} { "message": "Error message describing what went wrong", "errors": { "field_name": [ "Specific validation error for this field" ] } } ``` ### Alternate shapes (also used in production) Endpoints are not fully normalized to one schema. Also handle these when present: | Pattern | Typical status | Example | | - | - | - | | `message` only | 401, 404 | `{ "message": "Invalid API key or brand access denied" }` | | `error` string | 403, 404, 409 | `{ "error": "component_in_use", "message": "...", "usage_count": 3 }` | | `success: false` | 409 | `{ "success": false, "message": "...", "code": "revision_conflict" }` | | `plan_error` | 403, 422 | `{ "error": "Upgrade required...", "plan_error": { ... } }` | | Laravel `errors` only | 422 | `{ "errors": { "domain": ["The domain has already been taken."] } }` | | Non-JSON / HTML | 302, 500 | Session redirect to login, or proxy error pages — check `Content-Type` | Always branch on **HTTP status code** first, then parse JSON if `Content-Type` includes `application/json`. Log the raw response body when debugging unknown failures. ## HTTP Status Codes ElasticFunnels uses conventional HTTP response codes to indicate the success or failure of an API request. ### Success Codes The request was successful. The resource was successfully created. The request was successful but there's no content to return (typically for DELETE requests). ### Client Error Codes The request was malformed or contains invalid parameters. Authentication failed. Check your API key. You don't have permission to access this resource. The requested resource doesn't exist. Validation failed. Check the `errors` field in the response. Rate limit exceeded. Slow down your requests. ### Server Error Codes Something went wrong on our end. Please contact support if this persists. The service is temporarily unavailable. Please try again later. ## Common Error Scenarios ### Authentication Errors #### Invalid API Key ```json theme={null} { "message": "Invalid API key" } ``` **Status Code:** `401` **Solution:** Check that your API key is correct and hasn't been regenerated. #### No Brand Access ```json theme={null} { "message": "Invalid API key or brand access denied" } ``` **Status Code:** `401` **Solution:** Ensure your API key has access to the specified brand/project. ### Validation Errors #### Missing Required Fields ```json theme={null} { "message": "The given data was invalid.", "errors": { "name": [ "The name field is required." ], "price": [ "The price field is required." ] } } ``` **Status Code:** `422` **Solution:** Include all required fields in your request. #### Invalid Field Values ```json theme={null} { "message": "The given data was invalid.", "errors": { "price": [ "The price must be a number.", "The price must be at least 0." ], "status": [ "The selected status is invalid." ] } } ``` **Status Code:** `422` **Solution:** Ensure field values meet the requirements (correct type, valid options, etc.). ### Permission Errors #### Insufficient Permissions ```json theme={null} { "message": "You don't have permission to perform this action." } ``` **Status Code:** `403` **Solution:** This action requires higher permissions (e.g., Admin or Owner role). ### Resource Errors #### Resource Not Found ```json theme={null} { "message": "Resource not found" } ``` **Status Code:** `404` **Solution:** Check that the resource ID is correct and the resource exists. ### Rate Limiting #### Too Many Requests ```json theme={null} { "message": "Too many requests. Please slow down." } ``` **Status Code:** `429` **Solution:** Implement exponential backoff or reduce request frequency. ## Best Practices for Error Handling Always check the HTTP status code before parsing the response Display validation errors to users in a friendly format Retry failed requests with exponential backoff for 5xx errors Log error responses for debugging and monitoring ## Example Error Handling Here are examples of proper error handling in different languages: ### JavaScript ```javascript theme={null} try { const response = await fetch( 'https://app.elasticfunnels.io/api/brands/123/pages', { method: 'POST', headers: { 'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'New Page' }) } ); if (!response.ok) { const error = await response.json(); if (response.status === 401) { console.error('Authentication failed:', error.message); } else if (response.status === 422) { console.error('Validation errors:', error.errors); } else { console.error('API error:', error.message); } throw new Error(error.message); } const data = await response.json(); console.log('Success:', data); } catch (error) { console.error('Request failed:', error); } ``` ### Python ```python theme={null} import requests try: response = requests.post( 'https://app.elasticfunnels.io/api/brands/123/pages', headers={ 'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json' }, json={'name': 'New Page'} ) response.raise_for_status() data = response.json() print('Success:', data) except requests.exceptions.HTTPError as e: error_data = e.response.json() if e.response.status_code == 401: print('Authentication failed:', error_data['message']) elif e.response.status_code == 422: print('Validation errors:', error_data['errors']) else: print('API error:', error_data['message']) except requests.exceptions.RequestException as e: print('Request failed:', str(e)) ``` ### PHP ```php theme={null} 'New Page'])); $response = curl_exec($ch); $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = json_decode($response, true); if ($statusCode >= 200 && $statusCode < 300) { echo 'Success: ' . print_r($data, true); } else { if ($statusCode === 401) { echo 'Authentication failed: ' . $data['message']; } elseif ($statusCode === 422) { echo 'Validation errors: ' . print_r($data['errors'], true); } else { echo 'API error: ' . $data['message']; } } ``` ## Need Help? If you encounter persistent errors or need assistance: [support@elasticfunnels.io](mailto:support@elasticfunnels.io) # API Introduction Source: https://docs.elasticfunnels.io/api-reference/introduction Getting started with the ElasticFunnels API ## Welcome to ElasticFunnels API The ElasticFunnels API allows you to programmatically manage your projects, pages, products, conversions, and analytics. Our API is organized around REST principles with predictable resource-oriented URLs, returns JSON-encoded responses, and uses standard HTTP response codes. All API requests must be made over HTTPS. Calls made over plain HTTP will fail. ## Base URL All API endpoints are relative to the base URL: ``` https://app.elasticfunnels.io/api ``` ## Authentication The ElasticFunnels API uses API keys for authentication. You can generate and manage your API keys from your project settings under the API tab. Learn how to authenticate your API requests ## Making Your First Request Here's a simple example to get your project details: ```bash cURL theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id} \ -H "EF-Access-Key: your_api_key_here" ``` ```javascript JavaScript theme={null} const response = await fetch('https://app.elasticfunnels.io/api/brands/{brand_id}', { headers: { 'EF-Access-Key': 'your_api_key_here' } }); const brand = await response.json(); ``` ```python Python theme={null} import requests response = requests.get( 'https://app.elasticfunnels.io/api/brands/{brand_id}', headers={'EF-Access-Key': 'your_api_key_here'} ) brand = response.json() ``` ## Rate Limits Rate limiting information will be added in a future update. Currently, reasonable use is expected. ## Support Need help with the API? Contact our support team: [support@elasticfunnels.io](mailto:support@elasticfunnels.io) # Quick Start Guide Source: https://docs.elasticfunnels.io/api-reference/quick-start Get up and running with the ElasticFunnels API in minutes ## 5-Minute Setup Follow these steps to make your first API request: 1. Log in to your ElasticFunnels account 2. Navigate to **Settings** → **API** 3. Click **Generate API Key** 4. Copy and securely store your key Your brand (project) ID is in the URL when you're viewing your project: ``` https://app.elasticfunnels.io/123/analytics ^^^ Brand ID ``` Test your authentication by fetching your project details: ```bash theme={null} curl https://app.elasticfunnels.io/api/brands/{brand_id} \ -H "EF-Access-Key: your_api_key_here" ``` You're ready! Explore the endpoints documentation to build your integration. ## Common Use Cases ### Sync Conversions to Your CRM Automatically sync new orders to your CRM or analytics platform: ```javascript theme={null} // Fetch recent conversions const response = await fetch( 'https://app.elasticfunnels.io/api/brands/123/conversions?page=1&per_page=50', { headers: { 'EF-Access-Key': 'your_api_key_here' } } ); const { data } = await response.json(); // Process each conversion for (const order of data) { await syncToCRM({ email: order.customer_email, name: order.customer_name, revenue: order.total, order_id: order.code }); } ``` ### Monitor Split Test Performance Check split test results programmatically: ```python theme={null} import requests # Get split test results response = requests.get( 'https://app.elasticfunnels.io/api/brands/123/split-tests/456', headers={'EF-Access-Key': 'your_api_key_here'} ) split_test = response.json() # Find the winning variant winner = next(v for v in split_test['variants'] if v['is_winner']) print(f"Winner: {winner['name']} with {winner['conversion_rate']}% CR") # Alert if improvement is significant if winner['conversion_rate'] > 5.0: send_slack_notification(f"🎉 Split test winner: {winner['conversion_rate']}% CR!") ``` ### Bulk Create Pages from Templates Automate page creation for new campaigns: ```javascript theme={null} const campaigns = [ { name: 'Summer Sale', slug: 'summer-sale' }, { name: 'Black Friday', slug: 'black-friday' }, { name: 'New Year', slug: 'new-year' } ]; for (const campaign of campaigns) { const response = await fetch( 'https://app.elasticfunnels.io/api/brands/123/pages', { method: 'POST', headers: { 'EF-Access-Key': 'your_api_key_here', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: campaign.name, slug: campaign.slug, type: 'landing', status: 'inactive' }) } ); const page = await response.json(); console.log(`Created page: ${page.url}`); } ``` ### Daily Revenue Report Generate and email daily performance reports: ```python theme={null} from datetime import date, timedelta import requests # Get yesterday's metrics yesterday = date.today() - timedelta(days=1) response = requests.get( f'https://app.elasticfunnels.io/api/brands/123/analytics/metrics', params={ 'start_date': yesterday.isoformat(), 'end_date': yesterday.isoformat() }, headers={'EF-Access-Key': 'your_api_key_here'} ) metrics = response.json()['metrics'] # Format report report = f""" Daily Performance Report - {yesterday} 📊 Visitors: {metrics['visitors']:,} 📧 Leads: {metrics['leads']:,} 🛒 Orders: {metrics['orders']:,} 💰 Revenue: ${metrics['revenue']:,.2f} 📈 AOV: ${metrics['aov']:.2f} 🎯 Conversion Rate: {metrics['conversion_rate']:.2f}% """ send_email(to='team@example.com', subject='Daily Report', body=report) ``` ### Export Conversion Data Export conversion data for analysis in your BI tool: ```bash theme={null} # Request export curl -X POST https://app.elasticfunnels.io/api/brands/123/conversions/export \ -H "EF-Access-Key: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "start_date": "2024-12-01", "end_date": "2024-12-31" }' # Response includes download URL { "message": "Export is being processed", "export_id": "exp_abc123", "download_url": "https://app.elasticfunnels.io/api/brands/123/exports/exp_abc123/download/token" } # Download the file curl -O "https://app.elasticfunnels.io/api/brands/123/exports/exp_abc123/download/token" ``` ## SDKs and Libraries While ElasticFunnels doesn't provide official SDKs yet, the API works with any HTTP client library: * `fetch` (native) * `axios` * `node-fetch` * `requests` * `httpx` * `aiohttp` * `cURL` * `Guzzle` * `HTTP Client` ## Rate Limiting Best Practices While rate limits aren't strictly enforced currently, follow these best practices for optimal performance: 1. **Batch Requests**: Fetch multiple items in one request when possible 2. **Cache Responses**: Cache data that doesn't change frequently 3. **Use Pagination**: Don't try to fetch all records at once 4. **Implement Backoff**: Retry failed requests with exponential backoff Example retry logic: ```javascript theme={null} async function fetchWithRetry(url, options, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await fetch(url, options); if (response.ok) { return await response.json(); } // If rate limited, wait before retry if (response.status === 429) { const waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff await new Promise(resolve => setTimeout(resolve, waitTime)); continue; } throw new Error(`HTTP ${response.status}`); } catch (error) { if (attempt === maxRetries - 1) throw error; } } } ``` ## Next Steps Explore all available API endpoints Learn how to handle errors properly Deep dive into API authentication Contact our support team # Actions Source: https://docs.elasticfunnels.io/backend-scripts/actions Side-effect functions: session, cookies, redirects, response control, headers, and template variables. Action functions let your backend script perform side-effects — set session data, cookies, redirect the visitor, inject template variables, or send custom responses. ## Session Read and write values in the visitor's server-side session. ```javascript theme={null} session.set('my_key', 'my_value'); session.set('visit_count', 5); var count = session.get('visit_count'); // 5 var missing = session.get('nonexistent'); // null ``` Session values set here are immediately available to funnel script rules that run after the backend script. Session keys and values should be simple types (strings, numbers, booleans). Complex objects are serialized as JSON internally. For security, some session keys are reserved and cannot be read or written via `session.get()` / `session.set()`. Use your own key names (e.g. `my_key`, `visit_count`) for custom data. ### `session.setCustomer(fields)` Establish an authenticated customer session from a custom login flow — magic link, external OTP, SSO callback, or any mechanism outside the built-in purchase pipeline. Writes the runtime's canonical customer fields (`is_customer`, `email`, `customer_id`, `customer`) so downstream pages, template variables, and access rules see the visitor as logged in. ```javascript theme={null} // Verify a one-time code your app generated... var code = getVariable('submitted_code'); var email = getVariable('submitted_email'); var ok = verifyMyOtp(email, code); if (ok) { session.setCustomer({ email: email, name: 'Jane Doe' }); redirect('/dashboard'); } ``` | Field | Type | Required | Description | | - | - | - | - | | `email` | string | yes | The customer's email (must contain `@`). | | `name` | string | no | Full display name. If omitted, derived from `first_name` + `last_name`. | | `first_name` | string | no | First name. Merged into `session.customer.first_name`. | | `last_name` | string | no | Last name. Merged into `session.customer.last_name`. | | `id` | any | no | Internal customer id. When set, also writes `session.customer_id`. | Returns `true` on success, `false` on invalid input or quota exhaustion. `setCustomer` merges over any existing `session.customer` object, so tracking fields such as `total`, `currency`, or `order_ids` set by earlier checkout flows are preserved. ### `session.getToken(ttlSeconds?)` / `session.setFromToken(token)` Mint and consume short-lived encrypted tokens that carry the current customer identity across brand-owned domains. Used for cross-domain session continuity — see [Cross-domain session handoff](/guides/cross-domain-session-handoff) for the full protocol and a worked example. ```javascript theme={null} // On the source domain: mint a 60-second token var token = await session.getToken(); // null if no customer var url = 'https://app.example.com/welcome?ef_session=' + encodeURIComponent(token); redirect(url); // On the destination domain: most of the time you don't need this — the // EF runtime auto-consumes `?ef_session=` on every request. Call // setFromToken() manually only if you need to consume a token from a // header, body, or non-query-string location. var ok = await session.setFromToken(headerToken); ``` `session.getToken(ttlSeconds?)` returns the token string (or `null` if the session has no customer). `ttlSeconds` defaults to `60` and is capped at `600`. `session.setFromToken(token)` returns `true` on success, `false` on any failure (tamper, expiry, replay, wrong brand). It never throws. ## Cookies Set or delete cookies on the visitor's browser. ```javascript theme={null} cookie.set('promo_seen', 'true', { maxAge: 3600, path: '/' }); cookie.delete('old_cookie'); ``` ### `cookie.set(name, value, options?)` | Option | Type | Description | | - | - | - | | `maxAge` | number | Cookie lifetime in **seconds** | | `path` | string | Cookie path (default: `'/'`) | ### `cookie.delete(name)` Clears the cookie by name. Some cookie names are reserved and cannot be set or deleted. Use your own cookie names for custom data. Visitor **tags** are cookies too, but do not set them with `cookie.set()` — use [`addTag()` / `hasTag()` / `removeTag()`](/backend-scripts/tags), which write the naming both the funnel nodes and the browser expect. ## Redirect Redirect the visitor to a different URL. This **stops** page rendering — the visitor is immediately sent to the new URL. ```javascript theme={null} redirect('/login'); // 302 temporary redirect redirect('/new-page', 301); // 301 permanent redirect redirect('https://example.com/page'); // external redirect ``` When `redirect()` is called, the entire funnel flow and page rendering are skipped. The visitor receives only the redirect response. Only relative paths (`/path`) and `http://` / `https://` URLs are allowed. Other schemes (e.g., `javascript:`, `data:`) are blocked for security. ## Template Variables Inject values into the template engine. These become available as `{{ var.key }}` in page HTML. ```javascript theme={null} setVariable('discount', '50%'); setVariable('show_banner', true); setVariable('user_tier', 'premium'); setVariable('user', { name: 'Sam', email: 'sam@example.com' }); setVariable('categories', [ { slug: 'marketing', name: 'Marketing', courses: [...] }, { slug: 'design', name: 'Design', courses: [...] }, ]); ``` In your page HTML: ```html theme={null}

Get {{ var.discount }} off!

@if(var.show_banner) @endif

Welcome, {{ var.user.name }}

{{ dump(var.user) }}
``` `setVariable('name', value)` stores the value under the `var` namespace. Always access it as **`var.name`** in templates — e.g. `{{ var.name }}`, `{{ dump(var.name) }}`, `@if(var.name)`. Using `{{ name }}` or `{{ dump(name) }}` without the `var.` prefix will **not** find the variable. ### Scoping inside `@foreach` When you iterate over a variable with `@foreach`, the **loop variable** is its own scope — it does **not** use the `var.` prefix: ```html theme={null} @foreach(cat in var.categories)

{{ cat.name }}

{{ cat.slug }}

@foreach(course in cat.courses)
{{ course.title }}
@endforeach @endforeach ``` In short: `var.` is only for the **top-level** reference to variables set by `setVariable`. Once a value is assigned to a loop variable (`cat`, `course`, etc.), you access its properties directly. Variables set with `setVariable` take precedence over brand variables with the same key. ## Response Headers Set custom HTTP response headers. ```javascript theme={null} setHeader('X-Custom-Header', 'my-value'); setHeader('Cache-Control', 'no-store'); ``` For security, certain response headers cannot be set (e.g. those that control cookies, CORS, or connection). Header values are sanitized. ## Custom Response Send a custom response instead of the rendered page. These **stop** page rendering. ### `response.status(code)` Set the HTTP status code (used with `response.send()` or `response.json()`, or with the normal page render). ```javascript theme={null} response.status(403); response.send('

Access Denied

'); ``` ### `response.json(data)` Send a JSON response. Skips page rendering. ```javascript theme={null} response.json({ error: false, user: customer.email }); ``` ### `response.send(html)` Send raw HTML. Skips page rendering. ```javascript theme={null} response.send('

Maintenance

'); ``` ## Logging and errors `console.log`, `console.info`, `console.warn`, and `console.error` are captured and stored in execution logs. Use them for debugging. ```javascript theme={null} console.log('Visitor IP:', request.ip); console.log('Customer:', customer); ``` Logs and errors are visible in the [Debug Window](/debugging/debug-window) (Page Events tab) for the page request. If your script throws an error or times out, the visitor still receives the normal page (no error message is shown); the error is logged there and may be reported to admins. # Cache Source: https://docs.elasticfunnels.io/backend-scripts/cache Brand-scoped key-value cache for backend scripts. Backend scripts have access to a Redis-backed key-value cache via the `cache` global. Every key is automatically scoped to the current brand — scripts can never read or write keys belonging to another brand. Values are JSON-serialised, so you can store strings, numbers, booleans, arrays, and plain objects. ## `cache.get(key)` Retrieve a value from the cache. ```javascript theme={null} var count = cache.get('page_views'); if (count !== null) { setVariable('views', count); } ``` | Parameter | Type | Description | | - | - | - | | `key` | string | Cache key | **Returns:** The stored value, or `null` if the key does not exist or has expired. ## `cache.set(key, value, ttlSeconds?)` Store a value in the cache with an optional TTL. ```javascript theme={null} cache.set('last_visit', new Date().toISOString(), 3600); ``` | Parameter | Type | Default | Description | | - | - | - | - | | `key` | string | — | Cache key | | `value` | any | — | Value to store (JSON-serialised) | | `ttlSeconds` | number | 3600 | Time-to-live in seconds | **Returns:** `true` on success, `false` if the brand key limit was reached or an error occurred. ## `cache.del(key)` Delete a key from the cache. ```javascript theme={null} cache.del('last_visit'); ``` | Parameter | Type | Description | | - | - | - | | `key` | string | Cache key | **Returns:** `true` on success. ## `cache.has(key)` Check whether a key exists in the cache. ```javascript theme={null} if (cache.has('onboarding_complete')) { redirect('/dashboard'); } ``` | Parameter | Type | Description | | - | - | - | | `key` | string | Cache key | **Returns:** `true` if the key exists, `false` otherwise. ## `cache.setIfAbsent(key, value, ttlSeconds?)` Write a value only if the key does **not** already exist. Useful for debouncing and deduplication. ```javascript theme={null} var isNew = cache.setIfAbsent('email_sent:' + email, true, 86400); if (isNew) { // First time — send the email } ``` | Parameter | Type | Default | Description | | - | - | - | - | | `key` | string | — | Cache key | | `value` | any | — | Value to store | | `ttlSeconds` | number | 3600 | Time-to-live in seconds | **Returns:** `true` if the value was written (key was absent), `false` if the key already existed. ## `cache.increment(key, amount?, ttlSeconds?)` Atomically increment a numeric value. Creates the key with the given amount if it doesn't exist. ```javascript theme={null} var views = cache.increment('page:home:views', 1, 86400); setVariable('view_count', views); ``` | Parameter | Type | Default | Description | | - | - | - | - | | `key` | string | — | Cache key | | `amount` | number | 1 | Value to add (can be negative or float) | | `ttlSeconds` | number | — | Optional TTL to set/refresh | **Returns:** The new numeric value after incrementing. **Limits per brand:** * **50,000 keys** maximum per brand * **512 bytes** maximum value size * **1-hour** maximum TTL * **20 cache operations** per script execution Cache keys must match the pattern `[a-zA-Z0-9_.:-]` and be at most 200 characters long. Keys that don't match will throw an error. # Request Context Source: https://docs.elasticfunnels.io/backend-scripts/context Read-only globals available inside backend scripts: request data, customer info, and visitor details. These globals are injected automatically into every backend script execution. They are **read-only** — you cannot modify them directly. ## `request` Information about the current HTTP request. ```javascript theme={null} request.query // { foo: 'bar', utm_source: 'google' } request.headers // see below request.method // 'GET' or 'POST' request.body // parsed body, JSON or form-urlencoded (POST only, null for GET, max 64 KB) request.ip // IPv4 address, e.g. '1.2.3.4' request.ip6 // IPv6 address (if available) request.path // '/my-page' request.cookies // { cookie_name: 'value' } request.is_mobile // true/false request.is_tablet // true/false ``` ### `request.body` When a page receives a `POST` request, the body is parsed and available as `request.body`. Pages accept both GET and POST requests, and the same backend script runs for both, so use `request.method` to distinguish. ```javascript theme={null} if (request.method === 'POST' && request.body) { var data = request.body; console.log('Received:', data.name); } ``` Both JSON (`application/json`) and ordinary form posts (`application/x-www-form-urlencoded`) are parsed, so a plain HTML `
` can post straight to the page that renders it: ```html theme={null}
``` `multipart/form-data` is **not** parsed on page routes, so a form with that `enctype` (including file uploads) arrives with `request.body` as `null`. The body handed to the script is capped at **64 KB**, well below the request size the server itself accepts. A larger payload is dropped silently: `request.body` is `null` rather than truncated, and nothing is logged. Guard with `if (request.method === 'POST' && request.body)` so a page never assumes a body it did not get. ### `request.headers` A subset of request headers is exposed: | Key | Description | | - | - | | `user-agent` | Browser/device user-agent string | | `referer` | Referring URL | | `accept-language` | Visitor's language preferences | | `host` | Domain the request was made to | Full request headers are not exposed to prevent leaking internal infrastructure details (e.g. proxy headers, auth tokens). ## `customer` The current customer object from the session, or `null` if no customer is set. Populated after checkout or check-order login. ```javascript theme={null} customer.name customer.email customer.first_name customer.last_name customer.phone customer.order_id customer.order_ids // array of order objects customer.order_item customer.order_item_name customer.conversion_code customer.payment_provider customer.billing_first_name customer.billing_last_name customer.billing_address customer.billing_city customer.billing_state customer.billing_zip customer.billing_country customer.shipping_first_name customer.shipping_last_name customer.shipping_address customer.shipping_city customer.shipping_state customer.shipping_zip customer.shipping_country ``` ## `is_customer` Boolean. `true` if the visitor has an active customer session (completed a purchase or logged in via check-order). ```javascript theme={null} if (!is_customer) { redirect('/sales-page'); } ``` # Data Functions Source: https://docs.elasticfunnels.io/backend-scripts/data-functions Query customers, orders, products, courses, blog, and more from your backend scripts. Backend scripts expose the same data functions available in the template engine, so you can query your brand data and use the results for logic, not just display. All data functions that hit the database are **asynchronous** under the hood, but they look synchronous in your code — just call them and use the return value directly. Pure-computation helpers (like `formatPrice`, `productSavings`) are truly synchronous. ## Customer ### `getCustomer()` Returns the full current customer object from the session (same as the `customer` global). ```javascript theme={null} var c = getCustomer(); if (c && c.email) { console.log('Customer:', c.email); } ``` ### `getUser()` Returns a lightweight `{ name, email }` object for the current session customer. ```javascript theme={null} var user = getUser(); if (user.email) { setVariable('greeting', 'Welcome, ' + user.name); } ``` ## Translations Backend scripts use the same **resolved locale** as page templates (`req.locale` after host resolution). That value comes from route/query/session/domain/brand/`Accept-Language` and is then **snapped** to a row in Advanced → Translations when the brand has languages configured. See [Internationalization](/pages/internationalization) for precedence and template helpers (`t()`, `| t`, and related). Most translation helpers are **async** in the sandbox — use `await`. ### `getLocale()` Returns the active resolved locale code for this request (same as `req.locale`), e.g. `'en'` or `'ro'`. ```javascript theme={null} if ((await getLocale()) === 'ro') { setVariable('currency_symbol', 'lei'); } ``` ### `getLocales()` Returns the list of locale **codes** configured for the brand (admin sort order). Empty array when the brand has no translation languages. ```javascript theme={null} var available = await getLocales(); // ['en', 'ro'] ``` ### `getDefaultLocale()` Returns the brand **default** language code, or the first language by sort order, or `''` when none exist. ```javascript theme={null} if (await getDefaultLocale() === 'en') { setVariable('show_usd', true); } ``` ### `t(key, params?)` **Async.** Tries the **brand** dictionary first for `key`, then built-in locale JSON. Optional `params` is a plain object for `{{ name }}`-style interpolation in **built-in** strings — not a fallback text argument. This is the default choice in backend scripts for the same keys you use with **`t()`** or **`| t`** in templates. ```javascript theme={null} var label = await t('title.greeting'); var hero = await t('hero.title'); var cta = await t('checkout.cta'); setVariable('hero_title', hero); setVariable('checkout_cta', cta); var pct = await t('product.savings_percent', { percent: 20 }); setVariable('savings_label', pct); ``` ### `getTranslation(key)` **Async.** Reads **only** the brand dictionary (no built-in fallback); returns `''` if the key is missing. Prefer **`t()`** unless you explicitly need that behavior. ### `getTranslations()` Returns a **plain object** copy of every `key → value` for the active resolved language (empty object when the brand has no entries). ```javascript theme={null} var all = await getTranslations(); console.log(Object.keys(all).length); ``` ### `getResolvedLanguage()` Returns the **snapped** language code used for brand translations (e.g. `'ro'`), or the raw locale hint when the brand has no translation languages configured. ```javascript theme={null} var code = await getResolvedLanguage(); if (code === 'de') { setVariable('region', 'EU'); } ``` ## Orders ### `getOrders(sortBy?, limit?)` Fetch the current customer's orders from the database. | Parameter | Type | Default | Description | | - | - | - | - | | `sortBy` | string | `'newest'` | `'newest'` or `'oldest'` | | `limit` | number | `50` | Max orders to return (max 1000) | ```javascript theme={null} var orders = getOrders('newest', 10); if (orders.length > 0) { setVariable('has_orders', true); setVariable('latest_order', orders[0].order_number); console.log('Found', orders.length, 'orders'); } ``` Each order object contains: ```javascript theme={null} { order_number: 'ORD-12345', created_at: '2025-01-15T10:30:00Z', billing_address: '...', billing_city: '...', billing_state: '...', billing_zip: '...', billing_country: 'US', shipping_address: '...', shipping_city: '...', shipping_state: '...', shipping_zip: '...', shipping_country: 'US', currency: 'USD', customer_name: 'John Doe', customer_email: 'john@example.com', products: [ { name: 'Premium Plan', price: 49.99, quantity: 1, image_url: '...', currency_code: 'USD' } ], tracking: [ { tracking_number: '1Z999AA10123456784', tracking_url: '...', carrier: 'UPS' } ] } ``` ### `getOrder(code)` Fetch **one** order for the current customer. Same scope as `getOrders`. The `code` argument is required: public order ID (`order_number`), internal `order_id`, or conversion `code`. Returns `null` if not found. ```javascript theme={null} var order = getOrder(request.query.order); if (order) { setVariable('order', order); } ``` See [Showing order details](/members-area/order-details) for template examples. ### `getOrderFulfillment(code)` Returns an **allowlisted** fulfillment **summary** for a single order. Same `code` rules as `getOrder`. Use this for status labels and timestamps shown to customers. Returns `null` when the order exists but has **no** fulfillment record yet (no integration / not scheduled), or when the order is not found. **Returned fields** (only these; raw `fulfillment_provider_response` and `fulfillment_order_data` are never exposed): | Field | Type | Description | | - | - | - | | `status` | string \| null | Normalized: `pending`, `scheduled`, `sent`, `failed`, `skipped`, `cancelled`, or `unknown` | | `provider_order_id` | string \| null | External reference from the fulfillment provider when stored on the conversion | | `integration_name` | string \| null | Display name of the fulfillment integration | | `scheduled_at` | string \| null | ISO timestamp when processing was scheduled | | `sent_at` | string \| null | ISO timestamp when the order was sent to the provider | | `failed_at` | string \| null | ISO timestamp on failure | | `cancelled_at` | string \| null | ISO timestamp if cancelled | | `error_message` | string \| null | Truncated message safe for display (max 200 chars) | ```javascript theme={null} var f = getOrderFulfillment(request.query.order); if (f) { setVariable('fulfillment', f); } ``` ### `getOrderFulfillments()` Takes **no arguments**. Returns **all** tracking-style rows for the **current customer** across their orders (up to the same order cap as `getOrders` with limit 1000, newest first). Each row includes `order_number` so you can link to an order detail page. Duplicate `(order_number, tracking_number)` pairs are removed. **Returned fields per row:** | Field | Type | Description | | - | - | - | | `order_number` | string | Public order id for this shipment | | `order_date` | string \| null | Order `created_at` (ISO) | | `tracking_number` | string | Tracking number | | `tracking_url` | string | Tracking link (e.g. 17track when generated) | | `carrier` | string | Carrier name | ```javascript theme={null} var shipments = getOrderFulfillments(); setVariable('shipments', shipments); ``` These APIs return **filtered** data only. Full provider API responses and internal fulfillment payloads are not available in backend scripts. ## Bonus Products ### `getBonusProducts()` Fetch the current customer's bonus products — digital downloads and extras they received as part of a purchase. Returns a deduplicated list of bonus items from all the customer's orders. ```javascript theme={null} var bonuses = getBonusProducts(); if (bonuses.length > 0) { setVariable('bonuses', bonuses); setVariable('has_bonuses', true); } ``` Each bonus object contains: ```javascript theme={null} { id: 123, // product ID (from brand_products) code: 'bonus-ebook', // product code title: 'Free Bonus eBook', // product title description: 'A bonus guide…', // product description image_url: 'https://…/img.jpg', // product image bonus_url: 'https://…/file', // download URL price: 0, // price (typically 0 for bonuses) type: 'digital', // product type classification: 'bonus' // product classification } ``` `bonus_url` is the public download URL exposed to backend scripts. Internally, it comes from the saved conversion line when available, and otherwise falls back to the current bonus product download URL from the catalog. Bonus products are identified by conversion line items where `is_bonus = true`. The function enriches each item with full product details from the catalog. If the same bonus was granted across multiple purchases, it appears only once. Scoped to the **current session** (email and/or session id): if the visitor is not identified, the list is empty. ### `getAllBonusProducts()` Returns **all** bonus-classified products in the brand **catalog** (`classification: bonus`). This is **not** limited to what the current customer earned; it does not check orders or conversions. Object shape matches `getBonusProducts()` (including `title`, `bonus_url`, etc.). For catalog-only rows, `bonus_url` is the product's stored download URL. ```javascript theme={null} var catalogBonuses = getAllBonusProducts(); setVariable('all_bonus_skus', catalogBonuses); ``` For the same filter but the **full product** shape (price, currency, `merchant_product_ids`, the normalized `product_files` array, etc.), use `getProducts({ classification: 'bonus' })` instead. ### `getPurchasedProducts(sortBy?, limit?)` Customer-scoped helper for **member-area "My Library" pages**. Returns every product the current customer has purchased, fully catalog-enriched, with **same-order bonuses already attached** under `included_bonuses[]` and a pre-rolled-up `downloads[]` array (each entry tagged with `kind`). This replaces the manual recipe of: * calling `getSessionOrders` + `getAllProducts` + `getBonusProducts` separately, * joining order lines back to the catalog by code, * guessing which bonuses go with which product by matching strings in titles. | Parameter | Type | Default | Description | | - | - | - | - | | `sortBy` | string | `'newest'` | `'newest'` or `'oldest'` — controls which purchase wins when the same product appears in multiple orders. | | `limit` | number | `50` | Max orders considered when scanning the customer's history (max 1000). | ```javascript theme={null} var purchasedProducts = getPurchasedProducts('newest'); setVariable('purchased_products', purchasedProducts); ``` Each purchased product contains: ```javascript theme={null} { id: 100, code: 'theta-clear', title: 'Theta Clear', // checkout_title preferred when present checkout_title: '', description: '...', image: 'https://cdn/theta.png', type: 'digital', classification: 'main', price: 39, // catalog price retail_price: 99, currency: 'USD', // Decorated catalog fields (same shape as getProducts) product_files: [ /* normalized file rows with `kind` */ ], digital_files: [ /* product_files filtered by type='digital' */ ], bonus_files: [ /* product_files filtered by type='bonus' */ ], bonus_file: 'https://cdn/...', digital_file: 'https://cdn/...', primary_download_url: 'https://cdn/...', // Pre-rolled-up downloads — one flat list, deduped by URL, each tagged // with kind ('audio' | 'video' | 'document' | 'image' | 'archive' | 'file'). // Use this instead of walking product_files / digital_files / bonus_files // by hand. See the productDownloads helper section above. downloads: [ { url: '...', title: 'Theta Clear Vocal Guidance', kind: 'audio', purpose: 'digital', extension: 'mp3', mime_type: 'audio/mpeg', size: null, is_primary: true, sort_order: 0 }, ], // Purchase context (flat — matches the fields used in member-area templates) order_number: 'A0FZ2V0A', conversion_code: 'CONV-1', created_at: '2026-04-28T10:00:00Z', line_price: 39, // amount paid for this line line_currency: 'USD', quantity: 1, has_shipping: false, // true if the order has a shipping address // Bonuses received in the SAME order — every is_bonus line, no string // filtering. Each entry is catalog-enriched and carries its own // `downloads[]` rollup plus a direct `bonus_url`. included_bonuses: [ { id: 200, code: 'integration-journal', title: 'The Theta Clear Integration Journal', classification: 'bonus', bonus_url: 'https://cdn/journal.pdf', downloads: [ /* same shape as above */ ], // ...full catalog fields }, // ... all other bonus lines from the same order ] } ``` **Bonus attribution rule:** every `is_bonus` line in an order is attached to every main line of that same order. This matches how funnels actually deliver bonuses (the bonuses come with the offer, not with one specific catalog item). It also avoids the silent-drop bug of older recipes that filtered bonuses by title keywords. **Dedup rule:** one entry per product (keyed by id, then code). When the same product appears in multiple orders, the order matching the `sortBy` direction wins (newest purchase for `'newest'`). Bonuses come from the kept order. ```javascript theme={null} // Member-area library — render every purchased product with its bonuses // and downloads already prepared. No string heuristics, no manual joins. var library = getPurchasedProducts('newest'); setVariable('library', library); setVariable('library_count', library.length); setVariable('has_library', library.length > 0); ``` Scoped to the **current session**: returns `[]` when the visitor is not identified. ## Subscriptions Customer-scoped recurring billing functions. All operations are restricted to subscriptions belonging to the **current session customer** (matched by email) — a script can never read or modify another customer's subscription. Write functions return `{ ok: boolean, error?: string, ...rest }` and **never throw** on validation failure. They enforce status transitions automatically (e.g. only `active` can be paused or skipped, only `paused` can be resumed, already-canceled cannot be canceled again). For full request/response shapes, content-gating examples, and ready-to-use page-based API examples, see [Subscriptions (backend template engine)](/backend-template-engine/subscriptions). **Per-request cache:** All read helpers (`getSubscriptions`, `getSubscription` with an object arg, `hasSubscription`, `countSubscriptions`) share a per-request cache. The database is queried only once per request — subsequent calls with different filters read from memory. ### `getSubscriptions(status?, limit?)` List the current customer's subscriptions, newest first. | Parameter | Type | Default | Description | | - | - | - | - | | `status` | string | — | Optional filter: `active`, `paused`, `canceled`, `past_due`, or `trial`. Empty / unknown values return all. | | `limit` | number | `50` | Max subscriptions to return (1–200). | ```javascript theme={null} var all = getSubscriptions(); var active = getSubscriptions('active', 25); setVariable('subscriptions', all); setVariable('has_active_subscription', active.length > 0); ``` Each subscription object contains: ```javascript theme={null} { id: 'sub_abc123', status: 'active', // active | paused | canceled | past_due | trial customer_email: 'jane@example.com', customer_name: 'Jane Doe', products: [ { code: 'monthly-plan', name: 'Monthly Plan', price: 29.99, quantity: 1, type: 'main' } ], amount: 29.99, currency_code: 'USD', tax_amount: 0, shipping_amount: 0, discount_amount: 0, frequency: 1, frequency_unit: 'month', // day | week | month | year next_charge_at: '2026-05-12T00:00:00Z', started_at: '2026-04-12T00:00:00Z', paused_at: null, resume_at: null, canceled_at: null, cancel_reason: null, cycle_number: 2, trial_days: 0, first_charge_free: false, original_transaction_id: 'txn_xyz789', gateway: 'stripe', card_last4: '4242', card_brand: 'visa', created_at: '2026-04-12T00:00:00Z', updated_at: '2026-05-01T00:00:00Z' } ``` ### `getSubscription(arg)` Fetch a single subscription. Accepts either a **transaction ID string** or a **filter object**. **String form** — fetch by original transaction ID (existing behavior): ```javascript theme={null} var sub = getSubscription(request.query.id); if (sub) { setVariable('subscription', sub); } ``` **Object form** — fetch the first matching subscription from the per-request cache: | Property | Type | Default | Description | | - | - | - | - | | `code` | string | — | Filter by product code in the subscription's `products` array. | | `status` | string | `'active'` | Filter by status. Use `'any'` to skip status filtering. | ```javascript theme={null} // First active subscription for a specific product var sub = getSubscription({ code: 'monthly-plan' }); // Most recent canceled subscription (win-back flow) var canceled = getSubscription({ code: 'monthly-plan', status: 'canceled' }); if (canceled) { setVariable('canceled_at', canceled.canceled_at); setVariable('show_winback', true); } // First subscription regardless of code, any status var any = getSubscription({ status: 'any' }); ``` ### `hasSubscription(filter?)` Returns `true` if the customer has at least one subscription matching the filter, `false` otherwise. All fields are optional. | Property | Type | Default | Description | | - | - | - | - | | `code` | string | — | Match by product code. | | `status` | string | `'active'` | Match by status. Use `'any'` to skip. | | `date` | string | — | ISO date string. When set, ignores `status` and instead checks whether the subscription was active on that date (`started_at ≤ date` and `canceled_at` is null or after the date). | ```javascript theme={null} // Does the customer have any active subscription? var isSubscribed = hasSubscription(); // Active subscription for a specific product (content gate) if (hasSubscription({ code: 'premium-plan' })) { setVariable('show_premium', true); } // Was the customer subscribed on a specific date? (date-based gate) if (hasSubscription({ code: 'premium-plan', date: '2026-01-01' })) { setVariable('can_view_jan_content', true); } // Does the customer have a past-due subscription? (dunning banner) if (hasSubscription({ status: 'past_due' })) { setVariable('show_billing_warning', true); } ``` ### `countSubscriptions(filter?)` Returns the number of subscriptions matching the filter. Same filter shape as `hasSubscription`, excluding `date`. | Property | Type | Default | Description | | - | - | - | - | | `code` | string | — | Filter by product code. | | `status` | string | `'active'` | Filter by status. Use `'any'` for all statuses. | ```javascript theme={null} var activeCount = countSubscriptions(); setVariable('active_sub_count', activeCount); var totalCount = countSubscriptions({ status: 'any' }); var canceledCount = countSubscriptions({ status: 'canceled' }); ``` ### `cancelSubscription(transactionId, reason?)` Cancel an active or paused subscription. Fires the `subscription_cancel` event. ```javascript theme={null} var result = cancelSubscription('txn_xyz789', 'Too expensive'); if (!result.ok) { setVariable('error_message', result.error); } ``` ### `pauseSubscription(transactionId, resumeAt?)` Pause an `active` subscription. Optional `resumeAt` is an ISO date string for an automatic resume — invalid dates return `{ ok: false, error: 'Invalid resume_at date' }`. ```javascript theme={null} pauseSubscription('txn_xyz789'); pauseSubscription('txn_xyz789', '2026-08-01T00:00:00Z'); ``` ### `resumeSubscription(transactionId)` Resume a `paused` subscription. If the stored `next_charge_at` is in the past, it is automatically advanced based on the subscription's frequency. ```javascript theme={null} resumeSubscription('txn_xyz789'); ``` ### `skipSubscription(transactionId)` Skip the next billing cycle on an `active` subscription. Returns the new next charge date on success. ```javascript theme={null} var result = skipSubscription('txn_xyz789'); if (result.ok) { setVariable('next_charge', result.next_charge_at); } ``` ### `changeSubscriptionFrequency(transactionId, interval, intervalCount)` Change the billing cadence on an `active` or `paused` subscription. `interval` must be one of `day`, `week`, `month`, `year`. `intervalCount` must be a positive integer. ```javascript theme={null} var result = changeSubscriptionFrequency('txn_xyz789', 'month', 2); // → { ok: true, frequency_unit: 'month', frequency: 2, next_charge_at: '2026-06-12T...' } ``` ## Conversions ### `getConversions(email)` Look up conversions (purchases) by email address. ```javascript theme={null} var conversions = getConversions('john@example.com'); if (conversions.length > 0) { setVariable('returning_customer', true); } ``` Returns an array of conversion records with fields like `public_order_id`, `created_at`, `customer_email`, `product_name`, `total`, `currency_code`, `status`. ## Products ### `getProduct(code)` Fetch a single product by its product code. ```javascript theme={null} var product = getProduct('premium-plan'); if (product) { setVariable('product_name', product.name); setVariable('product_price', product.price); } ``` ### `resolveVariant(product, code?)` Pick which variant a page should start on. Returns `null` for a product with no variants, so you can branch on it. ```javascript theme={null} var product = getProduct('menosoothe'); var picked = resolveVariant(product, request.route_params.code); if (picked) { setVariable('price', picked.price); setVariable('buy_code', picked.code); // what add-to-cart submits } ``` `code` may be a **variant code** or the **linked product's code**, so a URL like `/shop/product/menosoothe-3` resolves without you mapping it yourself. With no match, or no code, the product's default variant is returned. ### Variants on a product Every product returned by `getProduct`/`getProducts` carries a normalised `variants` array, ordered by position, plus `product_fields` (the option axes) and `has_variants`. ```javascript theme={null} var product = getProduct('menosoothe'); @foreach(v in product.variants) {{ v.title }} // "3 Bottles" - joined option values {{ v.code }} // "menosoothe-3" - resolved through the linked product {{ v.price }} // 177 {{ v.retail_price }} // 297 {{ v.per_unit_price }} // 59 - price / units, precomputed {{ v.on_sale }} // true when retail_price > price {{ v.is_default }} // exactly one variant is true @endforeach ``` | Field | Notes | | - | - | | `code` | Variant code, else the linked product's code, else the variant id. This is what you submit to the cart. | | `linked_product_id` / `linked_product_code` | The product this variant resolves to. `null` for a standalone variant. | | `title` | Option values joined with `/`, or `Default` when the product has no option axes. | | `option_values` | Object keyed by option name, e.g. `{ "Pack size": "3 Bottles" }`. | | `per_unit_price` | `price / units`, or `null` when `units` is unset. | | `on_sale` | `true` when `retail_price` is greater than `price`. | | `position` | Display order. The array is already sorted by it. | | `is_default` | Exactly one variant per product is `true`. | Commercial fields resolve through the linked product (see [Product Variants](/products/variants)), so `price`, `sku` and `image` are already the values you should show — no second lookup needed. ### `getProducts(filters?)` Fetch products with optional filters. ```javascript theme={null} // All products var all = getProducts(); // Filtered var physical = getProducts({ type: 'physical' }); var subscriptions = getProducts({ classification: 'subscription' }); var specific = getProducts({ codes: 'plan-a,plan-b,plan-c' }); ``` | Filter | Type | Description | | - | - | - | | `type` | string | `'physical'`, `'digital'`, or `'any'` | | `classification` | string | `'subscription'`, `'one-time'`, or `'any'` | | `codes` | string | Comma-separated product codes | ### `getAllProducts()` Fetch all products for the brand (no filtering). ```javascript theme={null} var products = getAllProducts(); console.log('Total products:', products.length); ``` ### `getProductByCode(merchantCode, productCode)` Look up a product by merchant gateway code and the merchant-specific product code. ```javascript theme={null} var product = getProductByCode('stripe', 'price_1234'); ``` ### Product files and download URLs Catalog products can include attached downloads. **`getProduct`**, **`getAllProducts`**, **`getProductByCode`**, and **`getProducts`** (with **or without** filters) all return the same normalized shape, so you can rely on the same fields in every code path. Use these fields: | Field | Type | Description | | - | - | - | | **`product_files`** | array | All file rows attached to the product (always an array — never a JSON string). Each item: `{ id, purpose, title, description, image, file, sort_order, is_primary, metadata }`. `purpose` is `'digital'` or `'bonus'` (defaults to `'digital'` when missing). Rows with no `file` URL are filtered out. | | **`digital_files`** | array | Subset of `product_files` where `purpose === 'digital'`. Same row shape. | | **`bonus_files`** | array | Subset of `product_files` where `purpose === 'bonus'`. Same row shape. | | **`digital_file`** | string | URL of the **primary** digital file (the `is_primary` row with the lowest `sort_order` whose `purpose` is `'digital'`). Falls back to the legacy `digital_file` column on the product if set. Empty string when none. | | **`bonus_file`** | string | URL of the **primary** bonus file (the `is_primary` row with the lowest `sort_order` whose `purpose` is `'bonus'`). Falls back to the legacy `bonus_file` column, then to any primary file. Empty string when none. | | **`primary_download_url`** | string | URL of the overall primary file across all purposes (`is_primary DESC`, then `sort_order ASC`). Empty string when none. | Inside each `product_files` row: | Field | Type | Description | | - | - | - | | `id` | number \| null | Internal file id. | | `purpose` | string | `'digital'` or `'bonus'`. | | `title` | string | Display title. | | `description` | string | Optional description. | | `image` | string | Optional preview image URL. | | `file` | string | Download URL. Always present (rows without a URL are dropped). | | `kind` | string | Coarse classification derived from `metadata.extension` and `metadata.mime_type`. One of `'audio'`, `'video'`, `'document'`, `'image'`, `'archive'`, `'file'`. Use this to branch on file type without re-parsing the URL. | | `sort_order` | number | Display / primary-selection order (defaults to position in the array if not stored). | | `is_primary` | boolean | Whether this row is the primary file for its purpose. | | `metadata` | object | Public metadata object (always an object, parsed from JSON when stored that way). See **Metadata fields** below. Internal storage pointers are intentionally not exposed. | #### Metadata fields The `metadata` object on each `product_files` row only exposes the following fields: | Field | Type | Description | | - | - | - | | `mime_type` | string \| null | MIME type of the file (e.g. `'audio/mpeg'`, `'application/pdf'`). | | `size` | number \| null | Size in bytes. | | `checksum` | string \| null | Optional checksum, when one was recorded at upload. | | `extension` | string | Lowercase extension without the dot (e.g. `'mp3'`, `'pdf'`, `'docx'`). Derived from the `file` URL when possible, with a small mime-type fallback for opaque/signed URLs. Empty string when neither source can produce one. | Internal storage pointers (`provider`, `storage_path`, `brand_file_id`) used to be present on this object and are now intentionally hidden — they were only useful to the platform itself and should not be relied on by scripts or templates. **Example** — pick the primary bonus URL with a safe fallback, then list all digital files with their type: ```javascript theme={null} var p = getProduct('my-sku'); if (p && (p.bonus_file || p.primary_download_url)) { setVariable('bonus_href', p.bonus_file || p.primary_download_url); } if (p && p.digital_files && p.digital_files.length) { for (var i = 0; i < p.digital_files.length; i++) { var row = p.digital_files[i]; console.log( row.title, row.file, 'kind:', row.kind, 'ext:', row.metadata && row.metadata.extension, 'mime:', row.metadata && row.metadata.mime_type ); } } ``` **Example** — list every bonus across a filtered product set: ```javascript theme={null} var bonuses = getProducts({ classification: 'bonus' }); for (var i = 0; i < bonuses.length; i++) { var b = bonuses[i]; console.log(b.title, '→', b.bonus_file || b.primary_download_url); } ``` The same functions are available in **backend** page templates under the same names (`getProduct` / `getProducts` / `getAllProducts`), see [Products, categories & shipping (backend)](/backend-template-engine/products-and-shipping). ### `productDownloads(product)` Roll up everything a member-area UI normally needs to render "what can the customer actually download for this product" into a single flat array. Replaces the recurring `extractFiles` / "classify by extension" gymnastics — every entry already carries `kind`, so you can `switch` on it without parsing URLs. Source order, deduplicated by URL (first occurrence wins): 1. `digital_files` (purpose=`'digital'`) 2. `bonus_files` (purpose=`'bonus'`) 3. anything left in `product_files` not already picked up 4. legacy scalar `digital_file` / `bonus_file` columns — surfaced **only when no file rows exist**, so you never get duplicates of a row that's already in `product_files` Each entry: | Field | Type | Description | | - | - | - | | `url` | string | Download URL (always present). | | `title` | string | Display title. Falls back to `'Download'`. | | `kind` | string | One of `'audio'`, `'video'`, `'document'`, `'image'`, `'archive'`, `'file'`. | | `purpose` | string | `'digital'` or `'bonus'`. | | `extension` | string | Lowercase extension without the dot (e.g. `'mp3'`). | | `mime_type` | string \| null | MIME type when known. | | `size` | number \| null | Size in bytes when known. | | `is_primary` | boolean | Whether this row is the primary file for its purpose. | | `sort_order` | number | Display order. | **Example** — render a member's library entry without any classify-by-URL logic: ```javascript theme={null} var p = getProduct('my-sku'); var files = productDownloads(p); for (var i = 0; i < files.length; i++) { var f = files[i]; switch (f.kind) { case 'audio': console.log('🎧', f.title, f.url); break; case 'video': console.log('🎬', f.title, f.url); break; case 'document': console.log('📄', f.title, f.url); break; case 'image': console.log('🖼️', f.title, f.url); break; case 'archive': console.log('📦', f.title, f.url); break; default: console.log('📁', f.title, f.url); } } ``` The same helper is available in backend page templates under the same name, see [Products, categories & shipping (backend)](/backend-template-engine/products-and-shipping). ### `productSavings(product)` Compute savings info from a product's `price` and `retail_price`. Returns an object: ```javascript theme={null} var product = getProduct('premium-plan'); var savings = productSavings(product); // savings = { // amount: 20, // percent: 25, // amountFormatted: '$20.00', // percentFormatted: '25%' // } if (savings.percent > 0) { setVariable('discount_badge', 'Save ' + savings.percentFormatted); } ``` Returns `{ amount: 0, percent: 0, amountFormatted: '', percentFormatted: '' }` when there is no discount. ### `subscriptionSummary(product)` Returns a human-readable subscription description string. ```javascript theme={null} var product = getProduct('monthly-plan'); var label = subscriptionSummary(product); // e.g. "7-day free trial · Every 1 month" setVariable('subscription_label', label); ``` Returns an empty string for non-subscription products. ### `formatPrice(value, currency?)` Format a numeric price with the given currency code. ```javascript theme={null} var formatted = formatPrice(49.99, 'USD'); // "$49.99" setVariable('display_price', formatted); ``` ## Courses ### `getCourse(courseId)` Fetch a course by ID, including modules and contents. ```javascript theme={null} var course = getCourse(42); if (course) { setVariable('course_title', course.title); setVariable('lesson_count', course.total_lesson_count); } ``` ### `getCourseBySlug(slug)` Fetch a course by its URL slug. ```javascript theme={null} var course = getCourseBySlug('javascript-basics'); ``` ### `getModuleBySlug(courseSlug, moduleSlug)` Fetch a single module with its contents. ```javascript theme={null} var module = getModuleBySlug('javascript-basics', 'variables'); if (module) { setVariable('module_title', module.title); } ``` ### `getCourses()` Fetch all courses for the brand with lesson and module counts. ```javascript theme={null} var courses = getCourses(); setVariable('course_count', courses.length); ``` ### `getCoursesByCategorySlug(categorySlug)` Filter courses by category slug. ```javascript theme={null} var marketingCourses = getCoursesByCategorySlug('marketing'); setVariable('marketing_course_count', marketingCourses.length); ``` ### `getCategoriesWithCourses()` Get all course categories with their courses grouped together. ```javascript theme={null} var categories = getCategoriesWithCourses(); // [ // { category_slug: 'marketing', category_name: 'Marketing', courses: [...] }, // { category_slug: 'design', category_name: 'Design', courses: [...] }, // ] setVariable('category_count', categories.length); ``` ### `getCourseForCustomer(slug)` Fetch a single course with enrollment data for the current customer. Returns the full module/lesson tree plus `completed_content_ids`, `progress_percent`, `is_assigned`, and `started`. ```javascript theme={null} var course = getCourseForCustomer('javascript-basics'); if (course && course.is_assigned) { setVariable('progress', course.progress_percent); } ``` ### `getCoursesForCustomer()` Fetch all courses the current customer is enrolled in. Same as `getCourses(true)`. ```javascript theme={null} var myCourses = getCoursesForCustomer(); ``` Each course includes `module_count`, `lesson_count`, `progress_percent`, `completed_content_ids`, and `is_assigned`. ### `getCoursesForCustomerByCategorySlug(categorySlug)` Fetch the current customer's enrolled courses, filtered by category slug. Returns an empty array if the category has no enrolled courses. ```javascript theme={null} var marketing = getCoursesForCustomerByCategorySlug('marketing'); setVariable('marketing_courses', marketing); ``` ### `getCategoriesWithCoursesForCustomer()` Get all course categories that contain at least one course the current customer is enrolled in, with the enrolled courses grouped per category. Useful for category-tab navigation on a member-facing courses page. ```javascript theme={null} var categories = getCategoriesWithCoursesForCustomer(); // [ // { category_slug: 'marketing', category_name: 'Marketing', courses: [...] }, // { category_slug: 'design', category_name: 'Design', courses: [...] } // ] ``` ### `getCompletedLessonsCount(opts?)` Count completed lessons across all enrolled courses, or for a specific course. ```javascript theme={null} var totalDone = getCompletedLessonsCount(); var courseDone = getCompletedLessonsCount({ course: 'javascript-basics' }); ``` ### `getCourseProgress(opts?)` Get a structured progress summary with per-course and overall completed/total lesson counts. ```javascript theme={null} var progress = getCourseProgress(); // progress.completedLessons → 12 // progress.totalLessons → 48 // progress.courses → [{ slug, title, completedLessons, totalLessons, progressPercent }] ``` ## Blog ### `getBlog(blogId?)` Fetch a blog by ID. If no ID is provided, returns the default blog for the current domain. ```javascript theme={null} var blog = getBlog(); if (blog) { setVariable('blog_name', blog.name); } // Or by specific ID var specific = getBlog(5); ``` ### `getBlogArticles(blogId, page?, perPage?)` Fetch published blog articles with pagination. | Parameter | Type | Default | Description | | - | - | - | - | | `blogId` | number | — | Blog ID (required) | | `page` | number | `1` | Page number | | `perPage` | number | `12` | Articles per page (max 100) | ```javascript theme={null} var blog = getBlog(); if (blog) { var articles = getBlogArticles(blog.id, 1, 10); setVariable('article_count', articles.length); setVariable('articles', articles); } ``` Each article includes a `body` field with the article content. ### `getBlogArticle(blogId, slug)` Fetch a single published article by blog ID and slug. ```javascript theme={null} var article = getBlogArticle(1, 'getting-started'); if (article) { setVariable('article_title', article.title); setVariable('article_body', article.body); } ``` ### `getBlogCategories(blogId)` Get category summaries for a blog's published articles. ```javascript theme={null} var categories = getBlogCategories(1); setVariable('blog_categories', categories); ``` ### `getRelatedArticles(blogId, articleId, limit?)` Fetch related articles for a given article. | Parameter | Type | Default | Description | | - | - | - | - | | `blogId` | number | — | Blog ID | | `articleId` | number | — | Article to find related content for | | `limit` | number | `4` | Max related articles (1–24) | ```javascript theme={null} var related = getRelatedArticles(1, 42, 3); setVariable('related_articles', related); ``` ## Purchase Links These functions generate URLs for the payment flow. They are synchronous. ### `buy(productCode)` Generate a purchase link for a product. ```javascript theme={null} var link = buy('premium-plan'); setVariable('buy_url', link); ``` ### `upsell(productCode)` Generate an upsell link (one-click purchase for existing customers). ```javascript theme={null} var link = upsell('vip-addon'); setVariable('upsell_url', link); ``` ### `downsell(productCode)` Generate a downsell link (same mechanics as upsell). ```javascript theme={null} var link = downsell('basic-addon'); setVariable('downsell_url', link); ``` ### `decline(productCode)` Generate a decline link (skip the upsell offer and proceed). ```javascript theme={null} var link = decline('vip-addon'); setVariable('decline_url', link); ``` ### `upsellUpgrade(oldSku, newSku)` Generate a subscription upgrade link. Produces a JavaScript action URL that calls the server API to change the customer's subscription product. See [Subscription Upsells](/products/subscription-upsells). ```javascript theme={null} var link = upsellUpgrade('monthly_basic', 'monthly_premium'); setVariable('upgrade_url', link); ``` ### `upsellCancel()` Generate a subscription cancellation link. Produces a JavaScript action URL that calls the server API to cancel the customer's subscription. ```javascript theme={null} var link = upsellCancel(); setVariable('cancel_url', link); ``` ## Assets ### `asset(path)` Generate a full CDN URL for a brand asset. ```javascript theme={null} var cssUrl = asset('/main.css'); var imgUrl = asset('/images/hero.jpg'); setVariable('css_url', cssUrl); ``` Automatically appends a cache-busting parameter when `?nc`, `?no_cache`, or `?preview_key` is present in the request. ## Visitor Control ### `whitelistVisitor()` Whitelist the current visitor by setting an encrypted cookie. Whitelisted visitors bypass access restrictions. ```javascript theme={null} var customer = getCustomer(); if (customer.email === 'admin@example.com') { whitelistVisitor(); } ``` ### `blacklistVisitor()` Blacklist the current visitor by setting an encrypted cookie. ```javascript theme={null} if (request.query.block === 'true') { blacklistVisitor(); redirect('/blocked'); } ``` ## Session Items These functions read and write custom session items that are **compatible with the template engine's `setSessionItem` / `getSessionItem`** functions. Values are stored with a `custom_` prefix and base64-encoded. Use these when you need to share data between backend scripts and template-engine expressions on the same page or across pages within the same session. ### `setSessionItem(key, value)` Store a custom value in the session. Keys are sanitized to `[a-zA-Z0-9_]`. Values over 64KB are silently dropped. Pass `null` to delete. ```javascript theme={null} setSessionItem('preferred_plan', 'premium'); setSessionItem('quiz_score', '85'); ``` ### `getSessionItem(key)` Retrieve a custom session item. Returns an empty string if not found. ```javascript theme={null} var plan = getSessionItem('preferred_plan'); if (plan === 'premium') { setVariable('show_premium_banner', true); } ``` ### `clearSessionItem(key)` Remove a custom session item. ```javascript theme={null} clearSessionItem('preferred_plan'); ``` For raw session access (without the `custom_` prefix and base64 encoding), use `session.get(key)` and `session.set(key, value)` from the [Actions](/backend-scripts/actions) page. ## CRM Data **Entries vs. Fields**: An **entry** is a single record or document stored in the CRM (like a row in a database, representing e.g. a specific check-in for a customer). A **field** (`fieldKey`) is a specific piece of data that lives inside an entry (like a column, e.g. "current\_weight"). CRM entries are stored per brand. Every read/write is scoped by: * **`brand_id`** — from the session (always applied server-side) * **`slug`** — the CRM **entity** machine name from entity settings (e.g. `weight-tracker`), **required** on every call. This is **not** the same as `referenceType` (`customer`, `order`, …). * **`reference_type` + `reference_id`** — which record the entry is attached to. Defaults: `referenceType` → `'customer'`, `referenceId` → current session **`customer_id`** when omitted or `null`. Pass a **single options object** only (positional arguments are not supported). Missing `slug` or other required keys throws `TypeError`. Unknown `slug` for the brand also throws. Invalid field values still return `false` / `null` / `[]` after sanitization where applicable. Slug aliases: `crmEntitySlug`, `crm_entity_slug`. Reference aliases: `reference_type`, `reference_id`. Field key: `fieldKey` or `field_key`. Write functions (`setCrmField`, `setCrmFields`, `addCrmFieldValue`, `clearCrmEntries`) accept an optional **`async`** parameter (default `false`). When `true`, the write runs in the background — faster but with eventual consistency. Read functions are always synchronous. ```javascript theme={null} getCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight' }); getCrmFields({ slug: 'weight-tracker', fieldKeys: ['current_weight', 'goal_weight'] }); setCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight', value: 182, force: true }); setCrmFields({ slug: 'weight-tracker', fields: [ { fieldKey: 'current_weight', value: 182 }, { fieldKey: 'goal_weight', value: 165 } ] }); // Another record type / id setCrmField({ slug: 'weight-tracker', referenceType: 'order', referenceId: '12345', fieldKey: 'status', value: 'shipped' }); ``` ### `ensureCrmEntity({ slug, name?, fields?, ... })` Idempotently provisions a CRM entity (and optionally seeds its field schema). Safe to call on every page render — existing entities and fields are never overwritten, only missing keys are inserted. Use this at the top of a backend script in templates that ship to many brands so the CRM entity is auto-created on first use, without requiring an admin to pre-configure it. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | Machine name for the entity (e.g. `member-settings`). Must match `^[a-z0-9][a-z0-9_-]*$`. | | `name` | string | slug | Human-readable entity name shown in the admin UI. | | `singularName` | string | `null` | Singular label (e.g. "Member Setting"). Alias: `singular_name`. | | `pluralName` | string | `null` | Plural label (e.g. "Member Settings"). Alias: `plural_name`. | | `icon` | string | `null` | Icon name (Lucide icon, admin UI). | | `color` | string | `null` | Accent color (CSS color string). | | `fields` | array | `[]` | Optional field schema. Each item: `{ key, label?, type?, options?, validation_rules?, visible_to_team_ids? }`. | Field `type` values: `text`, `textarea`, `email`, `tel`, `url`, `number`, `integer`, `decimal`, `currency`, `date`, `datetime`, `time`, `select`, `multiselect`, `checkbox`, `boolean`, `radio`, `json`, `rich_text`, `wysiwyg` (rich text fields: values are HTML strings). For `select`/`multiselect`/`radio` pass `options: ['a','b']` or `[{ value: 'a', label: 'A' }]`. Returns: ```javascript theme={null} { id: 42, slug: 'member-settings', created: true, // false if entity already existed fields_added: 3, // newly inserted fields fields_skipped: 2 // already existed (left untouched) } ``` Example: ```javascript theme={null} ensureCrmEntity({ slug: 'member-settings', name: 'Member Settings', icon: 'settings', fields: [ { key: 'preferred_name', label: 'Preferred name', type: 'text' }, { key: 'time_zone', label: 'Time zone', type: 'select', options: ['UTC', 'Europe/London', 'America/New_York'] }, { key: 'marketing_opt_in', label: 'Marketing opt-in', type: 'checkbox' }, ], }); // Now safe to read/write — the entity definitely exists. var name = getCrmField({ slug: 'member-settings', fieldKey: 'preferred_name' }); ``` **Limits**: max 5 `ensureCrmEntity` calls per page execution. Exceeding the limit returns `{ id: null, created: false, error: 'rate_limited' }`. ### `getCrmEntries({ slug, referenceType?, referenceId?, limit? })` Fetch CRM entries for one entity slug and one reference. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug from entity settings | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | | `limit` | number | `50` | Max entries (max 200) | ```javascript theme={null} var entries = getCrmEntries({ slug: 'weight-tracker', limit: 50 }); for (var i = 0; i < entries.length; i++) { console.log(entries[i].title, entries[i].values_flat); } ``` Each entry object contains: ```javascript theme={null} { id: 'abc123', reference_type: 'customer', reference_id: 'xyz789', values_flat: { current_weight: 182, goal_weight: 165, last_checkin: '2026-03-15' }, created_at: '2026-03-15T10:00:00Z', updated_at: '2026-03-20T14:30:00Z' } ``` ### `getCrmField({ slug, fieldKey, referenceType?, referenceId? })` Reads one field from the first matching entry for that **slug** + reference. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug | | `fieldKey` | string | **required** | Field key (e.g. `current_weight`, `goal_weight`) | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | ```javascript theme={null} var weight = getCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight' }); if (weight && Number(weight) <= 165) { setVariable('show_goal_reached', true); } ``` ### `getCrmFields({ slug, fieldKeys, referenceType?, referenceId? })` Reads multiple fields from the first matching entries for that **slug** + reference. Returns an object mapping `fieldKey` -> `value`. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug | | `fieldKeys` | array | **required** | Array of field keys (e.g. `['current_weight', 'goal_weight']`) | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | ```javascript theme={null} var fields = getCrmFields({ slug: 'weight-tracker', fieldKeys: ['current_weight', 'goal_weight'] }); if (fields.current_weight && Number(fields.current_weight) <= 165) { setVariable('show_goal_reached', true); } ``` ### `setCrmField({ slug, fieldKey, value, force?, referenceType?, referenceId?, async? })` Updates an existing entry's field or creates a new entry under that entity slug and reference. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug | | `fieldKey` | string | **required** | Field key to set | | `value` | any | **required** | Must be present (`value: 0` / `false` allowed) | | `force` | boolean | `false` | Skip per-key entry scan — merge into the newest entry directly | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | | `async` | boolean | `false` | When `true`, write runs in background (faster, eventual consistency) | ```javascript theme={null} setCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight', value: 182 }); setCrmField({ slug: 'weight-tracker', fieldKey: 'last_checkin', value: new Date().toISOString() }); ``` **Performance tip:** When setting multiple fields in one script, pass `force: true`. Without it, each call searches all entries (up to 100) to find the document containing that key. With `force`, it fetches only the newest entry (limit 1) and merges the field into it — much faster for sequential writes: ```javascript theme={null} setCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight', value: 182, force: true }); setCrmField({ slug: 'weight-tracker', fieldKey: 'goal_weight', value: 165, force: true }); setCrmField({ slug: 'weight-tracker', fieldKey: 'last_checkin', value: today, force: true }); ``` ### `setCrmFields({ slug, fields, force?, referenceType?, referenceId?, async? })` Sets multiple field values for the same CRM entity + reference in one read/write cycle. This is the preferred API when one script needs to write several fields at once. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug | | `fields` | array | **required** | Non-empty array of `{ fieldKey, value }` objects | | `force` | boolean | `false` | Skip per-key scan and merge into the newest entry directly | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | | `async` | boolean | `false` | When `true`, write runs in background | ```javascript theme={null} setCrmFields({ slug: 'weight-tracker', fields: [ { fieldKey: 'current_weight', value: 182 }, { fieldKey: 'goal_weight', value: 165 }, { fieldKey: 'last_checkin', value: today } ] }); ``` Snake\_case keys are also accepted inside `fields`: ```javascript theme={null} setCrmFields({ slug: 'affiliate-requests', referenceType: 'affiliate_request', referenceId: 'leon@elasticfunnels.io', fields: [ { field_key: 'affiliate_experience', value: 'Wellness' }, { field_key: 'niche', value: 'Health' } ] }); ``` Behavior matches `setCrmField`: * If an existing entry for the same reference already contains one of the requested keys, that entry is updated. * If the keys are new but the reference already has entries, the newest entry is reused. * A new CRM entry is created only when that reference has no entry yet. ### `addCrmFieldValue({ slug, fieldKey, value, referenceType?, referenceId?, async? })` Creates a **new** CRM entry with the given field value. Unlike `setCrmField` which upserts a single value per key, this always creates a new entry — enabling multiple values for the same key over time. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug | | `fieldKey` | string | **required** | Field key to store the value under | | `value` | any | **required** | The value to store (string, number, boolean, or object) | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | | `async` | boolean | `false` | When `true`, write runs in background | ```javascript theme={null} addCrmFieldValue({ slug: 'weight-tracker', fieldKey: 'weighin_history', value: { date: today, weight: 182, notes: 'post-workout' } }); ``` ### `getCrmFieldValues({ slug, fieldKey, limit?, referenceType?, referenceId? })` Collects all values for a given field key across CRM entries. Returns an array of the raw values (string, number, object, etc.), newest first. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug | | `fieldKey` | string | **required** | Field key to collect values for | | `limit` | number | `50` | Max values to return (max 200) | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | ```javascript theme={null} var history = getCrmFieldValues({ slug: 'weight-tracker', fieldKey: 'weighin_history', limit: 10 }); for (var i = 0; i < history.length; i++) { console.log(history[i].date, history[i].weight); } ``` ### `clearCrmEntries({ slug, referenceType?, referenceId?, async? })` Deletes **all** CRM entries for the given entity slug and reference. Returns `{ ok: boolean, deleted: number }`. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | | `async` | boolean | `false` | When `true`, deletes run in background | ```javascript theme={null} var result = clearCrmEntries({ slug: 'weight-tracker' }); console.log(result.deleted, 'entries removed'); ``` ### `moveCrmLead({ slug, pipelineId?, pipelineSlug?, stageId?, stageSlug?, referenceType?, referenceId?, allEntries?, async? })` Moves a CRM entry to a specific pipeline and stage. Either `pipelineId` or `pipelineSlug` is required; either `stageId` or `stageSlug` is required. | Property | Type | Default | Description | | - | - | - | - | | `slug` | string | **required** | CRM entity slug | | `pipelineId` | number | — | Pipeline id (or use `pipelineSlug`) | | `pipelineSlug` | string | — | Pipeline slug (or use `pipelineId`) | | `stageId` | number | — | Stage id (or use `stageSlug`) | | `stageSlug` | string | — | Stage slug (or use `stageId`) | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | | `allEntries` | boolean | `false` | When `true`, updates all matching entries (not just the newest) | | `async` | boolean | `false` | When `true`, update runs in background | Returns `{ ok, moved, totalMatched, pipeline_id, stage_id }` on success, or `{ ok: false, moved: 0, error }`. ```javascript theme={null} var result = moveCrmLead({ slug: 'deals', pipelineSlug: 'sales', stageSlug: 'qualified' }); if (!result.ok) { console.log('Move failed:', result.error); } ``` ### `moveCrmToStage({ slug, stageId?, stageSlug?, pipelineId?, pipelineSlug?, referenceType?, referenceId?, allEntries?, async? })` Moves a CRM entry to a specific stage. The pipeline is resolved from the stage automatically — only `stageId` or `stageSlug` is strictly required (pipeline id/slug can narrow the lookup if stage slugs are not unique across pipelines). ```javascript theme={null} moveCrmToStage({ slug: 'deals', stageSlug: 'won' }); ``` ### `moveCrmToPipeline({ slug, pipelineId?, pipelineSlug?, stageId?, stageSlug?, referenceType?, referenceId?, allEntries?, async? })` Moves a CRM entry into a pipeline. The entry lands on the pipeline's first stage unless a specific stage is provided. ```javascript theme={null} moveCrmToPipeline({ slug: 'deals', pipelineSlug: 'onboarding' }); ``` ### `moveCrmToEntity({ fromSlug, toSlug, fieldMap, referenceType?, referenceId?, pipelineId?, pipelineSlug?, stageId?, stageSlug?, removeFromOld?, async? })` Copies field values from one CRM entity to another for the same reference. Use this to promote a lead to a deal, or migrate data between entity types. | Property | Type | Default | Description | | - | - | - | - | | `fromSlug` | string | **required** | Source entity slug | | `toSlug` | string | **required** | Target entity slug (must differ from `fromSlug`) | | `fieldMap` | object | **required** | `{ sourceFieldKey: targetFieldKey }` mapping — at least one pair required | | `referenceType` | string | `'customer'` | Linked record type | | `referenceId` | string | session `customer_id` | Linked record id | | `pipelineId` / `pipelineSlug` | number / string | — | Optional pipeline to place the new entry in | | `stageId` / `stageSlug` | number / string | — | Optional stage within that pipeline | | `removeFromOld` | boolean | `false` | When `true`, deletes all source entries after copying | | `async` | boolean | `false` | When `true`, write runs in background | Returns `{ ok, moved, removed_from_old }` where `moved` is the number of fields copied. ```javascript theme={null} var result = moveCrmToEntity({ fromSlug: 'leads', toSlug: 'deals', fieldMap: { lead_score: 'initial_score', source: 'lead_source' }, pipelineSlug: 'sales', stageSlug: 'new', removeFromOld: false }); if (result.ok) { console.log(result.moved, 'fields copied'); } ``` # Examples Source: https://docs.elasticfunnels.io/backend-scripts/examples Common patterns and real-world examples for backend scripts. ## Access control — require login Redirect visitors who haven't purchased to a sales page: ```html theme={null}

Welcome to your dashboard, {{ customer.name }}

``` ## Promo code from URL Check for a promo query parameter and set discount variables: ```html theme={null} @if(var.show_promo_banner)
Use code for {{ var.discount }} off!
@endif ``` ## Geo-based redirect Redirect mobile visitors to a mobile-optimized page: ```html theme={null} ``` ## Product-aware logic Check if a product exists and set variables based on its properties: ```html theme={null}

{{ var.product_name }}

Price: ${{ var.product_price }}

@if(var.is_subscription)

This is a recurring subscription.

@endif ``` ## Returning customer detection Check order history and personalize the page: ```html theme={null} @if(var.returning)
Welcome back! Your last order was on {{ var.last_order_date }}.
@else
Welcome! Check out our products.
@endif ``` ## Course access gate Only allow access if the visitor has purchased the course product: ```html theme={null}

{{ var.course_title }}

{{ var.total_lessons }} lessons

``` ## JSON API endpoint Use a page as a lightweight JSON API: ```html theme={null} ``` ## Blog listing with backend logic Fetch blog articles and inject them as template variables: ```html theme={null}

{{ var.blog_name }}

``` ## Product catalog with savings badges ```html theme={null} ``` ## Course access with session items Share state between backend scripts and template-engine expressions: ```html theme={null} ``` ## Whitelist admin visitors ```html theme={null} ``` ## Shared auth guard with imports Create a backend script module (code: `auth`) with reusable access-control helpers: ```javascript theme={null} export function requireCustomer() { if (!is_customer) { redirect('/login'); } } export function requireAdmin(email) { var admins = ['admin@mybrand.com', 'support@mybrand.com']; for (var i = 0; i < admins.length; i++) { if (admins[i] === email) return true; } redirect('/unauthorized'); } ``` Then use it on any page — no need to duplicate the logic: ```html theme={null}

Admin Dashboard — {{ var.admin_name }}

``` You can also build a shared utility module (code: `utils`): ```javascript theme={null} export function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } export function truncate(str, len) { if (str.length <= len) return str; return str.substring(0, len) + '...'; } ``` ```html theme={null} ``` See [Imports](/backend-scripts/imports) for the full reference on `import` / `export` syntax and resolution rules. ## External API with proxy Verify a token with an external service (requires HTTP proxy to be configured): ```html theme={null}

Hello, {{ var.user_name }}

``` # HTTP Requests Source: https://docs.elasticfunnels.io/backend-scripts/http Make HTTP GET and POST requests to external APIs from backend scripts. Backend scripts can make HTTP requests to external APIs using `http.get()` and `http.post()`. All requests are routed through a secure proxy to prevent direct access to internal infrastructure. HTTP functions are only available when the HTTP proxy is configured. If the proxy is not set up, `http` will be `undefined`. Contact your administrator to enable this feature. ## `http.get(url, headers?)` Make an HTTP GET request. ```javascript theme={null} var result = http.get('https://api.example.com/user/123'); if (result.status === 200) { var data = JSON.parse(result.body); setVariable('user_name', data.name); } ``` | Parameter | Type | Description | | - | - | - | | `url` | string | Full URL to request | | `headers` | object | Optional request headers | ## `http.post(url, body, headers?)` Make an HTTP POST request. ```javascript theme={null} var result = http.post( 'https://api.example.com/verify', JSON.stringify({ token: request.query.token }), { 'Content-Type': 'application/json' } ); if (result.status !== 200) { redirect('/unauthorized'); } ``` | Parameter | Type | Description | | - | - | - | | `url` | string | Full URL to request | | `body` | string | Request body (typically JSON stringified) | | `headers` | object | Optional request headers | ## Response object Both `http.get` and `http.post` return an object: ```javascript theme={null} { status: 200, // HTTP status code (0 if request failed entirely) headers: {}, // Response headers body: '...' // Response body as a string } ``` To work with JSON APIs, parse the body: ```javascript theme={null} var result = http.get('https://api.example.com/data'); var data = JSON.parse(result.body); ``` ## Limits | Constraint | Value | | - | - | | Max requests per script execution | 5 | | Request timeout | 10 seconds | | Max response body size | 1 MB | ## Security * All requests are routed through an authenticated proxy — they never originate from the application server directly * Private/internal IPs are blocked (localhost, 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x, AWS metadata endpoint) * Only `http://` and `https://` protocols are allowed ## Example: external auth check ```html theme={null}

Welcome, {{ var.user_name }}

``` # Imports Source: https://docs.elasticfunnels.io/backend-scripts/imports Share and reuse code between backend scripts by importing from other pages. Backend scripts support `import` / `export` syntax to share functions, constants, and objects between pages. Create a "module" page with exported code, then import what you need in any other page's backend script. ## Quick example Create a page (e.g. slug `app`) with shared functions: ```html theme={null} ``` Import and use them in another page: ```html theme={null}

{{ var.greeting }}

``` ## Import specifier formats All of these resolve to the same page slug `app`: ```javascript theme={null} import { test } from "app"; // bare name import { test } from "app.js"; // with .js extension import { test } from "../app.js"; // relative path import { test } from "./app"; // relative path ``` The resolution rules: 1. The `.js` extension is stripped 2. Leading `./` or `../` prefixes are stripped 3. The remaining string is used as the page slug to look up For nested pages, the slug path is preserved: ```javascript theme={null} import { helper } from "utils/helpers"; // resolves to page slug "utils/helpers" ``` ## Named imports Import specific exports by name: ```javascript theme={null} // Module page (slug: "utils") export const TAX_RATE = 0.08; export function calculateTax(amount) { return amount * TAX_RATE; } export function formatCurrency(val) { return '$' + val.toFixed(2); } ``` ```javascript theme={null} // Importing page import { calculateTax, formatCurrency } from "utils"; var price = 49.99; var tax = calculateTax(price); setVariable('total', formatCurrency(price + tax)); ``` ## Default imports Use `export default` for a module's primary export: ```javascript theme={null} // Module page (slug: "config") export default { siteName: 'My Store', supportEmail: 'help@mystore.com', maxItems: 10 }; ``` ```javascript theme={null} // Importing page import config from "config"; setVariable('site_name', config.siteName); setVariable('support_email', config.supportEmail); ``` ## Multiple modules Import from several pages in the same script: ```html theme={null} ``` ## Module pages work on their own A page with `export` statements still works when visited directly — the `export` keywords are silently stripped before execution. This means you can add `setVariable` calls alongside exports for the module page's own rendering: ```html theme={null}

We offer {{ var.plan_count }} subscription plans.

``` When another page imports `getPlans`, only the function is used — the `setVariable` call in the module runs inside the importing page's context too (both scripts share the same sandbox). ## Supported export syntax | Syntax | Supported | | - | - | | `export const name = ...` | Yes | | `export let name = ...` | Yes | | `export var name = ...` | Yes | | `export function name() { ... }` | Yes | | `export async function name() { ... }` | Yes | | `export class Name { ... }` | Yes | | `export default ...` | Yes | | `export { name1, name2 }` | Yes | ## Limits | Constraint | Value | | - | - | | Max imports per script | 10 | | Import depth | 1 level (nested imports inside modules are not resolved) | ### Supported import shapes Each `import` line must be **either** a default import **or** a named import — not both on the same line: ```javascript theme={null} import { Greeter, wrap } from "app"; // named — OK import config from "config"; // default only — OK // import Greeter, { wrap } from "app"; // not supported — use named exports only, or split across modules ``` You can combine **classes and functions** from the same module with named imports, or import a class from one module and helpers from another. Exported functions may return **nested objects**, **arrays of objects**, or build structures from parameters — this is fully supported after inlining: ```javascript theme={null} // Module export function getPayload() { return { user: { name: 'Pat' }, items: [{ id: 1 }] }; } export const config = { api: { version: 2 } }; export function merge(input) { return { out: { a: input.a || 0 } }; } ``` ## How imports work When a backend script contains `import` statements: 1. Import statements are parsed and removed from the script 2. Each module specifier is normalized to a page slug 3. The module page's `

{{ var.greeting }}

``` ## Execution order Backend scripts run early in the page lifecycle, before funnel events: 1. Page resolved and loaded 2. Access control checked 3. **Backend script extracted, stripped from HTML, and executed** 4. Funnel events processed (script rules, splits, etc.) 5. Template engine renders the page 6. HTML sent to visitor Because backend scripts run before funnel events, session values you set are visible to script rules and other funnel nodes downstream. ## Sandboxing & security Backend scripts run inside a **locked-down sandbox**. This means: * No access to `process`, `require`, `fs`, `net`, or similar host APIs * No access to the file system or private networks * **8 MB memory limit** per execution * **10 second CPU timeout** — pure JavaScript execution stops if it exceeds this * **30 second wall-clock timeout** — the entire run (including data loads and HTTP calls) stops if it exceeds this * The ` ``` Modules are standalone JavaScript with `export` statements, managed separately from pages. See [Imports](/backend-scripts/imports) for the full reference. # Queue Source: https://docs.elasticfunnels.io/backend-scripts/queue Deferred background script execution with tag-based cancellation. Backend scripts can schedule other scripts to run in the background after a delay using the `queue` global. Jobs are stored in Redis and processed by a background worker. This is useful for sending follow-up emails, scheduling reminders, or any task that should happen later without blocking the current request. ## `queue.push(scriptCode, payload, opts?)` Enqueue a backend script for deferred execution. ```javascript theme={null} // Send an abandon email 1 hour after quiz completion queue.push('mailgun', { type: 'abandon', email: 'user@example.com', quizProfile: quizProfile, visitorId: visitorId, }, { delay: 3600, tag: 'abandon:' + visitorId }); ``` | Parameter | Type | Description | | - | - | - | | `scriptCode` | string | The backend script code to execute (e.g. `'mailgun'`) | | `payload` | object | Data passed to the script's exported function | | `opts` | object | Optional configuration (see below) | ### Options | Key | Type | Default | Description | | - | - | - | - | | `fn` | string | `'default'` | Name of the exported function to call | | `delay` | number | `0` | Seconds to wait before executing. Minimum **60s** when non-zero, maximum **7 days** (604,800s) | | `tag` | string | — | Logical group for cancellation (max 200 chars) | **Returns:** `{ ok: true, jobId: '...' }` on success, or `{ ok: false, error: '...' }` on failure. ### How the target script is called The queued script's exported function receives the `payload` object as its only argument: ```javascript theme={null} // scripts/mailgun.js function handleQueuedEmail(data) { if (data.type === 'abandon') { sendAbandonEmail(data.email, data.quizProfile, data.visitorId); } else { sendResultsEmail(data.email, data.quizProfile); } } export default handleQueuedEmail; ``` If you specify `opts.fn`, a named export is called instead of the default export: ```javascript theme={null} queue.push('my-script', { userId: 123 }, { fn: 'processUser', delay: 60 }); // → calls my-script.processUser({ userId: 123 }) after 60 seconds ``` ## `queue.cancel(tag)` Cancel all pending jobs with the given tag for the current brand. ```javascript theme={null} var result = queue.cancel('abandon:' + visitorId); console.log('Cancelled', result.cancelled, 'jobs'); ``` | Parameter | Type | Description | | - | - | - | | `tag` | string | The tag to cancel | **Returns:** `{ ok: true, cancelled: N }` where N is the number of jobs removed. ## Example: Multi-step abandon email sequence Queue 3–4 emails spaced over a few days. Cancel the whole sequence on purchase using the shared tag prefix. ```javascript theme={null} // On email capture — queue the abandon sequence var tag = 'abandon:' + visitorId; queue.push('mailgun', { type: 'abandon_1', email: leadEmail, quizProfile: quizProfile, visitorId: visitorId }, { delay: 3600, tag: tag }); // 1 hour queue.push('mailgun', { type: 'abandon_2', email: leadEmail, quizProfile: quizProfile, visitorId: visitorId }, { delay: 86400, tag: tag }); // 24 hours queue.push('mailgun', { type: 'abandon_3', email: leadEmail, quizProfile: quizProfile, visitorId: visitorId }, { delay: 172800, tag: tag }); // 48 hours queue.push('mailgun', { type: 'abandon_4', email: leadEmail, quizProfile: quizProfile, visitorId: visitorId }, { delay: 259200, tag: tag }); // 72 hours // On purchase — cancel all pending emails in one call queue.cancel(tag); ``` **Limits per brand:** * **25,000 pending jobs** maximum per brand * **64 KB** maximum payload size * **7-day** maximum delay (604,800 seconds) * **60-second** minimum delay when a non-zero delay is set * **5 pushes** per script execution * **5 cancel calls** per script execution * **3 retries** with exponential backoff on failure * Jobs expire after **8 days** if not processed Queued scripts run in a background worker **without session or customer context**. They have access to `cache`, `queue`, and CRM functions, but **not** `request`, `session`, `redirect`, or `response`. Design your scripts to receive all needed data in the `payload`. # Storage Source: https://docs.elasticfunnels.io/backend-scripts/storage Durable, database-backed key-value store for backend scripts. Backend scripts have access to a durable key-value store via the `storage` global. Values are persisted to the database, so they **survive restarts and deploys** and are readable from **every runtime server immediately** — there's no per-server cache to warm and no worker to run. Every key is automatically scoped to the current brand — scripts can never read or write another brand's data. Values are JSON-serialised, so you can store strings, numbers, booleans, arrays, and plain objects. ## Storage vs. Cache Both are brand-scoped key-value APIs, but they solve different problems: | | `storage` | `cache` | | - | - | - | | Backing store | Database (durable) | Redis (in-memory) | | Survives restarts | **Yes** | Until eviction / TTL | | Default lifetime | Permanent (no expiry) | 1 hour | | Max value size | 64 KB | 512 bytes | | Best for | Tokens, settings, cursors, idempotency markers | Hot counters, short-lived dedupe, rate windows | Reach for `cache` for high-frequency, disposable data. Reach for `storage` when losing the value would break something — OAuth refresh tokens, sync cursors, "have I already processed this?" markers. ## Options Every method takes an optional trailing `opts` object: | Field | Type | Default | Description | | - | - | - | - | | `namespace` | string | `'default'` | Groups keys (e.g. per integration). Isolated per namespace. | | `ttl` | number | — | Seconds until the value expires. Omit for permanent. | ## `storage.get(key, opts?)` Retrieve a value. ```javascript theme={null} var settings = storage.get('config', { namespace: 'aweber' }); if (settings) { setVariable('list_id', settings.list_id); } ``` **Returns:** The stored value, or `null` if the key is missing or expired. ## `storage.set(key, value, opts?)` Store a value, overwriting any existing one. ```javascript theme={null} storage.set('sync_cursor', { page: 3, updated_at: Date.now() }); storage.set('access_token', token, { namespace: 'aweber', ttl: 7200 }); ``` | Parameter | Type | Description | | - | - | - | | `key` | string | Storage key | | `value` | any | Value to store (JSON-serialised) | | `opts` | object | `{ namespace?, ttl? }` | **Returns:** `true` on success, `false` if the brand key limit was reached. ## `storage.del(key, opts?)` Delete a key. ```javascript theme={null} storage.del('sync_cursor'); ``` **Returns:** `true`. ## `storage.has(key, opts?)` Check whether a live (non-expired) key exists. ```javascript theme={null} if (!storage.has('seeded', { namespace: 'import' })) { // first run — do one-time setup } ``` **Returns:** `true` if the key exists and has not expired, `false` otherwise. ## `storage.setIfAbsent(key, value, opts?)` Write a value **only if the key is absent or expired**. This is atomic (row-locked), which makes it a reliable short-lived **mutex** across all runtime servers. ```javascript theme={null} // Ensure only one server sends the welcome email, ever. var isFirst = storage.setIfAbsent('welcome_sent:' + email, true, { namespace: 'email' }); if (isFirst) { // send it } ``` **Returns:** `true` if the value was written, `false` if a live value already existed. ## `storage.compareAndSet(key, expected, next, opts?)` Atomically set `next` **only if** the current stored value equals `expected` (compared by value). A missing or expired key is treated as `null`. Use this for lock-free optimistic updates where a stale writer must not clobber newer data. ```javascript theme={null} var current = storage.get('rotating_config'); var updated = Object.assign({}, current, { version: 5 }); var ok = storage.compareAndSet('rotating_config', current, updated); if (!ok) { // someone else updated it first — re-read and retry } ``` | Parameter | Type | Description | | - | - | - | | `key` | string | Storage key | | `expected` | any | Value you expect to currently be stored | | `next` | any | Value to write if `expected` still matches | | `opts` | object | `{ namespace?, ttl? }` | **Returns:** `true` if the swap happened, `false` if the current value didn't match `expected`. ## `storage.increment(key, amount?, opts?)` Atomically add to a numeric value, creating it (starting at `0`) if absent. ```javascript theme={null} var total = storage.increment('lifetime_signups'); ``` | Parameter | Type | Default | Description | | - | - | - | - | | `key` | string | — | Storage key | | `amount` | number | 1 | Value to add (can be negative or float) | | `opts` | object | — | `{ namespace?, ttl? }` | **Returns:** The new numeric value after incrementing. ## `storage.mutate(key, fn, opts?)` Atomically read a value, transform it, and write the result — the safe way to do read-modify-write without losing a concurrent update. `fn` receives the current value (`undefined` if the key is absent) and returns the value to store. Returning `undefined` aborts the write and leaves the stored value untouched. ```javascript theme={null} // Append to a durable list without clobbering a concurrent run. storage.mutate('recent_orders', function (list) { list = list || []; list.unshift(orderId); return list.slice(0, 50); }); ``` Under the hood this is an **optimistic loop**: read the current value, run `fn`, then commit with a row-locked `compareAndSet`. If another execution wrote first, it re-reads and runs `fn` again (a few times) until it commits cleanly. | Parameter | Type | Description | | - | - | - | | `key` | string | Storage key | | `fn` | function | `(current) => next`. Return `undefined` to abort. | | `opts` | object | `{ namespace?, ttl? }` | **Returns:** The newly stored value (or the unchanged value if `fn` returned `undefined`). Throws if it still can't commit after several retries (extreme contention). Because `fn` can run **more than once** (on retry), it must be a **pure function of its input** — derive `next` only from `current`. Do **not** put one-time side effects inside `fn` (spending a single-use token, sending an email, charging a card). For exactly-once side effects, use the claim pattern shown below. ## Example: rotating OAuth refresh tokens (exactly-once) The motivating case. Providers like AWeber hand out a **short-lived access token** plus a **single-use refresh token that rotates on every refresh** — using the old refresh token invalidates it. Two guarantees are needed, and `storage` gives you both: 1. **Durability** — the newly rotated refresh token must be persisted for the next run (backend scripts are stateless; there's nowhere else to keep it). 2. **Exactly-once refresh** — if two requests refresh at the same instant, they'd both spend the *same* single-use refresh token and one call would fail. Only one request may perform the refresh. The fool-proof approach is **refresh-ahead + a claim**: * Refresh *before* the access token actually expires (while it's still valid), so a request that doesn't win the claim can safely keep using the current token — no waiting. * Use `setIfAbsent` as a **non-blocking claim**: exactly one request wins and refreshes; the others fall through. Because a claim can never outlive its TTL, a crashed run can't wedge the integration. ```javascript theme={null} var NS = 'integrations'; var creds = storage.get('aweber_oauth', { namespace: NS }) || {}; var now = Date.now(); var REFRESH_AHEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry var needsRefresh = !creds.access_token || now >= ((creds.expires_at || 0) - REFRESH_AHEAD_MS); if (needsRefresh) { // Non-blocking claim — only the winner refreshes. TTL releases it if a run dies. var claimed = storage.setIfAbsent('aweber_refresh_claim', now, { namespace: NS, ttl: 30 }); if (claimed) { var res = http.post('https://auth.aweber.com/oauth2/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=refresh_token&refresh_token=' + encodeURIComponent(creds.refresh_token), }); if (res.status >= 200 && res.status < 300) { creds = { access_token: res.body.access_token, refresh_token: res.body.refresh_token, // rotated — persisted for the next run expires_at: now + (res.body.expires_in * 1000), }; storage.set('aweber_oauth', creds, { namespace: NS }); } storage.del('aweber_refresh_claim', { namespace: NS }); } // Didn't win the claim? Fall through — the current token is still valid // (that's the point of refreshing ahead of expiry). } // creds.access_token is valid here — make the API call. ``` **Why not refresh *inside* a locked callback?** Two reasons: holding a database lock across an external network call is an anti-pattern, and the script sandbox can only wait on **one** async operation at a time — a network call nested inside a locked callback isn't supported and would fail. Keep the network call in your main script flow, guarded by a claim. **Limits per brand:** * **10,000 keys** maximum per brand * **64 KB** maximum value size * **No expiry by default** (max TTL when set: 1 year) * **50 storage operations** per script execution (a `mutate` retry spends a `get` + a `compareAndSet`) Keys must match the pattern `[a-zA-Z0-9_.:-]` and be at most 191 characters. Namespaces follow the same pattern up to 64 characters. Values that don't fit, or keys/namespaces that don't match, are rejected. # Tags Source: https://docs.elasticfunnels.io/backend-scripts/tags Read, add and remove visitor tags from a backend script — the same tags the Tag User and Has Tag funnel nodes use. Tags mark a visitor so a later page or funnel step can branch on them: `seen_vsl`, `declined_upsell`, `vip`. Backend scripts get four functions for working with the same tags the [Tag User and Has Tag nodes](/funnels/page-events-nodes) set and read. ```javascript theme={null} if (!hasTag('seen_offer')) { addTag('seen_offer', 10080); // 7 days setVariable('first_visit', true); } ``` ## `hasTag(name)` Returns `true` if the visitor carries the tag, `false` otherwise. Finds tags set by the Tag User node, by `ef.addTag()` in the browser, and by `addTag()` below. ```javascript theme={null} if (hasTag('vip')) { setVariable('discount', 20); } ``` ## `addTag(name, expirationMinutes?)` Adds a tag. Returns `true` if it was added, `false` if the name was rejected or the per-execution cap was hit. | Parameter | Type | Default | Description | | - | - | - | - | | `name` | string | — | Tag name. Letters, digits, `_`, `-`, `.`, `:`; max 120 characters | | `expirationMinutes` | number | — | Omit or pass `0` for a session tag; a positive number expires the tag after that many minutes | ```javascript theme={null} addTag('abandoned_cart'); // until the browser session ends addTag('trial_started', 10080); // 7 days addTag('promo_seen', 60); // 1 hour ``` ## `removeTag(name)` Removes a tag. Returns `true` if the removal was queued, `false` if the name was rejected. Removing a tag the visitor does not have is not an error. ```javascript theme={null} removeTag('abandoned_cart'); ``` ## `getTags()` Returns an array of the visitor's tag names. ```javascript theme={null} setVariable('tag_count', getTags().length); ``` `getTags()` lists tags written by `addTag()` and by `ef.addTag()` in the browser. Tags written by the **Tag User** node are stored under a bare cookie name that is indistinguishable from any other cookie, so they cannot be enumerated — `hasTag('name')` still finds them. ## Reserved and invalid names `addTag()` and `removeTag()` return `false` without doing anything when the name is empty, longer than 120 characters, contains characters that are not valid in a cookie name (spaces, `;`, `=`), or collides with a reserved cookie such as `hop`, `subid`, `aff` or the session cookie. This stops a tag from overwriting affiliate or session state. ```javascript theme={null} addTag('hop'); // false — reserved addTag('my tag'); // false — space is not a valid cookie-name character addTag('cart_started'); // true ``` ## A tag is visible immediately A tag added by a backend script is visible to the rest of that same script, and to funnel nodes that run after it on the same request: ```javascript theme={null} addTag('qualified'); hasTag('qualified'); // true — right away ``` The **Tag User** node behaves differently: a tag it sets is only visible to a **Has Tag** node on the *next* request, not the one it was set on. If you need set-then-branch inside a single page load, set the tag with `addTag()` in a backend script rather than with a Tag User node. ## How tags are stored Tags are plain, unencrypted cookies. Two naming conventions exist for historical reasons: | Written by | Cookie name | | - | - | | **Tag User** node (server) | `vip` | | `ef.addTag()` (browser) | `tag_vip` | | `addTag()` (backend script) | both `vip` and `tag_vip` | `addTag()` writes both names so a tag set from a backend script is visible everywhere — the Has Tag node, `ef.hasTag()` in the browser, and the `tags` array in Script Rules. `hasTag()` and `removeTag()` accept either spelling, so you do not have to think about which side set a tag. Because tags are unencrypted cookies, a visitor can add or remove their own tags from the browser console. Use them for funnel flow and personalization — never as the only gate on paid content, pricing, or anything else where forging a tag would cost you. ## Example: one-time offer Show an offer once, then never again for 30 days. ```html theme={null} ``` ## Example: clearing a tag after purchase ```html theme={null} ``` See [Queue](/backend-scripts/queue) for the cancellation tag used here — that is a job-grouping label, unrelated to visitor tags. ## Limits * **50** tag writes per script execution. `addTag()` and `removeTag()` return `false` past that. * Each `addTag()` and `removeTag()` emits an `add-tag` / `remove-tag` tracking event, the same as the Tag User node and `ef.addTag()`. # Schema Validator Source: https://docs.elasticfunnels.io/backend-scripts/validation Built-in Joi-like Schema API for validating data in backend scripts — type-safe, sandbox-native, zero dependencies. A `Schema` validation library is available globally in every backend script. It provides a **Joi-like fluent API** to validate, coerce, and sanitize incoming data — especially `request.body` from POST requests, but usable with any object. The library is pure JavaScript and runs directly in the sandbox. No imports needed — `Schema` is a global. ## Quick start ```javascript theme={null} var schema = Schema.object({ name: Schema.string().required().min(1).max(255), email: Schema.string().required().email(), weight: Schema.number().min(50).max(500), }); var result = schema.validate(request.body); if (!result.valid) { response.json({ ok: false, errors: result.errors }); } // result.value is a sanitised deep copy — safe to use setCrmField({ slug: 'my-entity-slug', fieldKey: 'name', value: result.value.name }); setCrmField({ slug: 'my-entity-slug', fieldKey: 'current_weight', value: result.value.weight }); ``` ## How it works 1. Define a schema using `Schema.string()`, `Schema.number()`, etc. 2. Chain validators: `.required()`, `.min(5)`, `.email()`, etc. 3. Call `schema.validate(data)` — returns `{ valid, value, errors }` 4. If `valid` is `true`, `value` contains a **deep-cloned, type-coerced** copy of the input 5. If `valid` is `false`, `errors` is an array of human-readable error messages `validate()` returns a **deep clone** of the input. The original `request.body` is never mutated. Unknown keys in objects are silently dropped — only keys defined in the schema are kept. ## Types ### `Schema.string()` Validates that the value is a string. ```javascript theme={null} Schema.string() // any string Schema.string().required() // non-empty required Schema.string().min(3).max(100) // length constraints Schema.string().email() // email format Schema.string().url() // http/https URL Schema.string().pattern(/^[A-Z]+$/) // regex match Schema.string().lowercase() // must be lowercase Schema.string().valid('a', 'b', 'c') // enum — must be one of these ``` ### `Schema.number()` Validates that the value is a number. Strings like `"42"` are auto-coerced. ```javascript theme={null} Schema.number() // any finite number Schema.number().integer() // no decimals Schema.number().min(0) // >= 0 Schema.number().max(100) // <= 100 Schema.number().positive() // > 0 Schema.number().negative() // < 0 ``` ### `Schema.boolean()` Validates that the value is a boolean. Strings `"true"`, `"1"`, `"false"`, `"0"` are auto-coerced. ```javascript theme={null} Schema.boolean() Schema.boolean().required() ``` ### `Schema.array()` Validates arrays. Use `.items()` to validate each element. ```javascript theme={null} Schema.array() // any array Schema.array().min(1).max(10) // length constraints Schema.array().items(Schema.string().max(50)) // typed elements Schema.array().items(Schema.number().min(0)).max(100) // numbers array, max 100 items ``` ### `Schema.object()` Validates objects. Pass a key map to define the shape. ```javascript theme={null} Schema.object({ name: Schema.string().required(), score: Schema.number().min(0).max(100), tags: Schema.array().items(Schema.string()), address: Schema.object({ city: Schema.string().required(), zip: Schema.string().pattern(/^\d{5}$/), }), }) ``` Unknown keys are **automatically stripped** — only keys in the schema are kept in `result.value`. This prevents users from injecting unexpected fields. #### Dynamic keys For objects where keys are not known ahead of time, use `.pattern_values()`: ```javascript theme={null} Schema.object({}).pattern_values(Schema.string().max(500)) ``` This validates that every value in the object (regardless of key name) matches the rule. ### `Schema.any()` Accepts any type. Useful with `.required()` or `.valid()`. ```javascript theme={null} Schema.any().required() Schema.any().valid('draft', 'published', 42, true) ``` ## Modifiers | Method | Applies to | Description | | - | - | - | | `.required()` | all | Value must be present (not `null`, `undefined`, or `""`) | | `.optional()` | all | Value can be absent (this is the default) | | `.default(val)` | all | Use this value when input is missing | | `.label(name)` | all | Custom name in error messages | | `.valid(a, b, …)` | all | Must be one of the listed values | | `.min(n)` | string, number, array | Minimum length / value / item count | | `.max(n)` | string, number, array | Maximum length / value / item count | | `.email()` | string | Must be a valid email address | | `.url()` | string | Must be an http/https URL | | `.pattern(regex)` | string | Must match the regex | | `.lowercase()` | string | Must be all lowercase | | `.integer()` | number | Must be a whole number | | `.positive()` | number | Must be > 0 | | `.negative()` | number | Must be \< 0 | | `.items(rule)` | array | Validate each array element | | `.pattern_values(rule)` | object | Validate values of unknown keys | | `.strip()` | all | Include in validation but exclude from output | | `.custom(fn)` | all | Custom validator function (return error string or nothing) | ## Type coercion The validator automatically coerces compatible types: | Input | Schema | Result | | - | - | - | | `"42"` | `Schema.number()` | `42` | | `"3.14"` | `Schema.number()` | `3.14` | | `"true"` | `Schema.boolean()` | `true` | | `"false"` | `Schema.boolean()` | `false` | | `"1"` | `Schema.boolean()` | `true` | | `"0"` | `Schema.boolean()` | `false` | Non-coercible values fail validation (e.g. `"hello"` for `Schema.number()`). ## Custom validators Use `.custom()` for logic that built-in methods don't cover: ```javascript theme={null} var schema = Schema.object({ start_date: Schema.string().required().custom(function (v, label) { if (isNaN(Date.parse(v))) return label + ' must be a valid date'; }), password: Schema.string().required().min(8).custom(function (v, label) { if (!/[A-Z]/.test(v)) return label + ' must contain an uppercase letter'; if (!/[0-9]/.test(v)) return label + ' must contain a digit'; }), }); ``` The function receives `(value, label)` and should return an error string if validation fails, or nothing/undefined if it passes. ## Full POST example A page at `/api/submit-checkin` that validates and stores data: ```html theme={null} ``` Frontend: ```javascript theme={null} var res = await fetch('/api/submit-checkin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ weight: 182.5, unit: 'lbs', meal: 'lunch', }), }); var json = await res.json(); if (!json.ok) { console.error('Validation errors:', json.errors); } ``` ## Nested object example ```javascript theme={null} var schema = Schema.object({ user: Schema.object({ first_name: Schema.string().required().max(100), last_name: Schema.string().required().max(100), email: Schema.string().required().email(), }).required(), preferences: Schema.object({ notifications: Schema.boolean().default(true), theme: Schema.string().valid('light', 'dark').default('light'), }).default({}), tags: Schema.array().items(Schema.string().max(50)).max(20).default([]), }); var result = schema.validate(request.body); // result.value.user.email is guaranteed to be a valid email string // result.value.preferences.theme is guaranteed to be 'light' or 'dark' // Any extra keys not in the schema are stripped ``` **Always validate before storing.** Even though the Schema library strips unknown keys and enforces types, it's a good practice to apply `.max()` constraints on strings and arrays to prevent large payloads from consuming CRM storage. # Directives Source: https://docs.elasticfunnels.io/backend-template-engine/directives Backend template directives: @if, @else, @foreach, @set, session storage, @component, @extends, @block. This page describes the **directives** the backend template engine understands. Use them in the page builder via the corresponding **backend** blocks, or write them directly in coded templates. ## Conditionals: `@if`, `@else`, `@elseif`, `@endif` Show one block when a condition is true, optionally with else branches. ```html theme={null} @if(article)

{{ article.title }}

@else

Article not found.

@endif ``` * **`@if(condition)`** — Renders the following content only when the condition is truthy. * **`@else`** — Optional; rendered when the `@if` condition is false. * **`@elseif(condition)`** — Optional; additional condition; first matching branch is used. * **`@endif`** — Ends the conditional block. ### Conditions You can use: * **Word operators** (safe in HTML): `eq`, `neq`, `lt`, `lte`, `gt`, `gte`\ Example: `@if(orders.length gt 0)` * **Symbols** (where allowed): `===`, `!==`, `==`, `!=`, `<=`, `>=`, `<`, `>` * **Logic:** `and`, `or`, unary `!` * **Property checks:** `@if(article.author_avatar)`, `@if(related_articles && related_articles.length)` Example with `@elseif` and array length: ```html theme={null} @if(posts && posts.length) @foreach(post in posts)
…
@endforeach @else

No posts yet.

@endif ``` *** ## Loops: `@foreach` … `@endforeach` Repeat a block once per element in an array. ```html theme={null} @foreach(post in posts) @endforeach ``` * **`@foreach(item in array)`** — `item` is the variable name for the current element; `array` is an expression that must evaluate to an array (e.g. `posts`, `related_articles`). * **`@endforeach`** — Required; ends the loop. * **`@each(item in array)` … `@endeach`** — Same loop under another name; either closer works. * **`@foreach((key, value) in object)`** — Loop over an object's keys and values. Inside the block you can use `{{ item.property }}`, `@if`, nested `@foreach`, and filters. ### The `loop` variable Inside every loop, `loop` describes the current pass: | Property | Value | | - | - | | `loop.index` | Position, counting from **0** | | `loop.iteration` | Position, counting from **1** | | `loop.first` / `loop.last` | `true` on the first / last pass | | `loop.count` | Number of items | | `loop.remaining` | Items still to come after this one | | `loop.even` / `loop.odd` | Based on `loop.iteration` | | `loop.depth` | `1` for an outer loop, `2` inside it, and so on | | `loop.parent` | The enclosing loop's `loop`, in a nested loop | For example, an `ItemList` with a position and no trailing comma: ```html theme={null} ``` `loop`, like the item variable, exists only inside the loop. ### Variables set inside a loop A variable you `@set` inside a loop keeps its value after `@endforeach`, and each pass sees the value the previous pass left. Use it for counts and totals: ```html theme={null} @set(shown = 0) @set(total = 0) @foreach(product in products) @set(shown = shown + 1) @set(total = total + product.price) @endforeach

{{ shown }} products, {{ total }} in total

``` Because the value carries over, reset a per-item variable at the top of the loop body when only some items set it: ```html theme={null} @foreach(product in products) @set(badge = '') @if(product.on_sale)@set(badge = 'Sale')@endif {{ badge }} @endforeach ``` *** ## Variables: `@set` Define a variable for use later in the template. ```html theme={null} @set(greeting = "Hello")

{{ greeting }}, {{ name }}!

``` Use when you need a reusable value or a clearer expression. To put a quote of the same kind inside a string, escape it with a backslash, or use the other kind of quote around it: ```html theme={null} @set(title = "He said \"hi\"") @set(title = 'It\'s here') @set(title = 'The "best" plan') ``` *** ## Directives on one line Directives work on a line of their own or inline, next to other directives and text. These two are the same: ```html theme={null} @if(lang eq 'en')@set(show_banner = 'yes')@endif @if(lang eq 'en') @set(show_banner = 'yes') @endif ``` Inline you can use `@if(` `@elseif(` `@else` `@endif`, `@foreach(` `@endforeach`, `@each(` `@endeach`, `@set(`, `@setSessionItem(` `@clearSessionItem(`, `@component(` and `@yield(`: ```html theme={null}
    @foreach(item in items)
  • @if(loop.first){{ item }}@else{{ item }}@endif
  • @endforeach
Home ``` Two directives need their own line: * **`@extends(…)`** — On its own line, at the top of the file. * **`@block(…)`** — On its own line, directly after a tag (` @block("head")`), or with its `@endblock` on the same line (`@block("title")About@endblock`). In any other position they are printed as text, and `ef lint` warns about it. An `@` that doesn't start one of these directives is left as it is, so emails (`support@example.com`), handles (`@yourbrand`), CSS at-rules (`@media`, `@font-face`, `@import`) and `@` in scripts need no escaping. A word such as `@settings` or `use @set to` is text too: a directive needs its `(`. *** ## Components: `@component` Include a reusable component by name, with optional arguments. ```html theme={null} @component("header", { title: "Home" }) ``` The engine looks up the component by name and renders it with the given arguments in context. *** ## Inheritance: `@extends`, `@block`, and `@yield` Use a **base template** and override only certain regions. `@yield("name")` and `@block("name")…@endblock` both define named regions in a base layout that child templates can fill. The difference: `@yield` has no default content (renders empty if not overridden), while `@block` can include default content as a fallback. ### Base template (e.g. layout) The base defines named **blocks** that child templates can override: ```html theme={null} Site @block("content") Default content here. @endblock ``` ### Child template The child declares which base it extends and redefines one or more blocks: ```html theme={null} @extends("layout") @block("content")

{{ article.title }}

{{ article.body | raw }}
@endblock ``` * **`@extends("pageSlug")`** — Must be at the top; use the base page’s slug. * **`@block("name")` … `@endblock`** in the child — Replaces the same-named `@block` or `@yield` in the base. Only the blocks you define are overridden; the rest of the base layout is used as-is. ### `@yield` vs `@block` — when to use which `@yield("name")` is a slot with **no default content** — nothing renders if the child does not provide an override. `@block("name") default @endblock` has **optional default content** that renders when no child override is provided. | | `@block("name") default @endblock` | `@yield("name")` | | - | - | - | | Default content | Yes — rendered when child doesn't override | No — renders empty | | Typical use | Optional sections (header, sidebar, scripts) | Required slots (title, main content, page-level style) | Example base layout using `@yield` for required slots and `@block` for optional ones: ```html theme={null} @yield("title") · My Site @block("header")
Home
@endblock
@yield("content")
@block("scripts")@endblock ``` Child template: ```html theme={null} @extends("layout") @block("title")About@endblock @block("style").about { padding: 80px 0; }@endblock @block("content")

About us

@endblock ``` In the **page builder**, inheritance is supported with a read-only base layer: only the block regions are editable, and on save only the child source (extends + blocks) is stored. *** ## Session storage You can store and read simple values in the visitor’s session from templates. Use this for things like remembering a choice across requests (e.g. a selected option, a flag) without using the frontend. | Call | Purpose | | - | - | | **`@setSessionItem('key', value)`** | Store a value in the session. Use **`@setSessionItem('key', null)`** to clear that key. | | **`{{ getSessionItem('key') }}`** | Output the stored value, or empty if not set. | | **`@clearSessionItem('key')`** | Remove the key from the session (same as setting it to null). | **Rules:** * **Keys** may only use letters, numbers, and underscores (`a–z`, `A–Z`, `0–9`, `_`). Other characters are stripped. * **Values** are stored safely (encoded). You can pass strings or numbers; other types are JSON-serialized. There is a size limit per value (\~64 KB). * **Clearing:** Passing `null` (or nothing) to `setSessionItem` removes the key, as does `@clearSessionItem('key')`. **Session length and when it resets** * **Lifetime:** Session data (including values you store with `setSessionItem`) lasts **3 hours** from when the session was first created. The timer does **not** reset on each page load—so after 3 hours the session expires and all stored values are gone. * **When it resets:** The session is tied to the visitor’s session cookie. It is cleared when: * the 3-hour lifetime has passed, * the visitor clears their cookies, * or they use a different browser or device (new session). * Use session storage for short-lived, per-visit state (e.g. “has seen this step”, “selected option for this visit”). For long-lived or cross-device data, use a different mechanism (e.g. customer account, database). **Examples:** ```html theme={null} @setSessionItem('selected_plan', 'premium')

You chose: {{ getSessionItem('selected_plan') }}

``` ```html theme={null} @if(getSessionItem('seen_intro'))

Welcome back.

@else @setSessionItem('seen_intro', '1')

First time here? Read this…

@endif ``` ```html theme={null} @clearSessionItem('cart_note') @setSessionItem('cart_note', null) ``` *** ## Summary | Directive | Purpose | | - | - | | `@if(condition)` … `@endif` | Conditional block | | `@else` / `@elseif(condition)` | Else branches | | `@foreach(item in array)` … `@endforeach` | Loop (`@each` … `@endeach` is the same) | | `loop.index` / `loop.iteration` / `loop.first` / `loop.last` | Position inside a loop | | `@set(variable = expression)` | Set variable | | `@setSessionItem('key', value)` | Store (or clear with null) session value | | `{{ getSessionItem('key') }}` | Output session value | | `@clearSessionItem('key')` | Remove session value | | `@component("name", args)` | Include component | | `@extends("pageSlug")` | Extend base template | | `@block("name")` … `@endblock` | Define/override block | Next: [Variables and filters](/backend-template-engine/variables-and-filters). # Overview Source: https://docs.elasticfunnels.io/backend-template-engine/overview Introduction to the backend template engine: server-side directives and variable interpolation. The **Backend Template Engine** renders templates on the server (or in preview) using directives like `@if`, `@foreach`, and `{{ variable }}`. The output is plain HTML sent to the visitor. Use it for content that is fixed at request time, such as product lists, blog posts, conditional sections, or layout structure that does not change after the page loads. Use **`{{ }}`** for backend (server-side) variables. Use **`[[ ]]`** for frontend (client-side) values that update in the browser. Both can appear on the same page. For live-updating content (cart, totals, conditional UI), see [Frontend Template Engine](/frontend-template-engine/overview). This engine runs **last** on the server — after the page-events graph has already filled every split-test and dynamic container. An `@if` wrapped around a container therefore hides the result but does **not** prevent the container from being filled (or the visitor from being enrolled in the test). See [Rendering pipeline](/pages/rendering-pipeline) for the full order. ## When to use it Use the backend template engine when: * **Content is fixed at request time** - Blog posts, product details, category lists, or any data that does not change while the visitor is on the page. * **You need conditionals or loops in the HTML** — Show or hide blocks with `@if` / `@else`, or repeat a block per item with `@foreach`. * **You use layout inheritance** — A base layout with `@extends` and `@block` so child pages only define the changing regions. If the content depends on the visitor and must update without reloading (e.g. cart items, totals, coupon status), use the [Frontend Template Engine](/frontend-template-engine/overview) instead. ## Page builder vs coded templates | Context | How you work | What gets saved / rendered | | - | - | - | | **Page builder (GrapeJS)** | Drag **backend** blocks from the Template Engine category: “If (Backend)”, “Else (Backend)”, “Foreach (Backend)”, “Extends (Backend)”, “Block (Backend)”, etc. | The builder saves `@if(...)`, `@else`, `@foreach(...)`, `@extends(...)`, `@block(...)` in the page HTML. The backend engine renders these when the page is built or previewed. | | **Coded / hand-written templates** | Write the same directives directly in the template source. | No extra tags; the engine reads `@if`, `@foreach`, `{{ ... }}`, and so on. | So: * **In the page builder:** Use the **backend** blocks (If, Else, Foreach, Set, Component, Extends, Block) so the correct `@…` directives are emitted. * **In coded templates:** Use **`@if`**, **`@else`**, **`@foreach`**, **`{{ }}`**, **`@set`**, **`@component`**, **`@extends`**, **`@block`** directly. ## What the engine provides | Feature | Purpose | | - | - | | **`{{ path }}`** | Insert a value from the current context (variables, properties). | | **`{{ path \| filter }}`** | Apply a filter (e.g. `default`, `upper`, `slice`, `raw`). | | **`{{ "text" + path }}`** | Concatenate strings and values with `+`. | | **`@if(condition)` … `@endif`** | Show a block only when the condition is true. | | **`@else`** / **`@elseif(condition)`** | Optional branches inside an `@if`. | | **`@foreach(item in array)` … `@endforeach`** | Repeat a block once per item. | | **`@set(name = value)`** | Set a variable for the rest of the template. | | **`@setSessionItem('key', value)`** / **`getSessionItem('key')`** / **`@clearSessionItem('key')`** | Store, read, or clear a value in the visitor’s session. | | **`@component("name", args)`** | Include a reusable component. | | **`@extends("pageSlug")`** | Use a base template; override with `@block`. | | **`@block("name")` … `@endblock`** | Define or override a named region in the layout. | Details and examples are in [Directives](/backend-template-engine/directives), [Variables and filters](/backend-template-engine/variables-and-filters), and [Products, categories & shipping](/backend-template-engine/products-and-shipping) (catalog and shipping profile functions). ## Relation to other docs * **Frontend Template Engine** — [Overview](/frontend-template-engine/overview), [Syntax](/frontend-template-engine/syntax): client-side `[[ ]]`, ``, ``, etc. * **Backend Template Engine** (this section) — Server-side `{{ }}`, `@if`, `@foreach`, filters, inheritance. Next: [Directives](/backend-template-engine/directives). # Products, categories & shipping (backend) Source: https://docs.elasticfunnels.io/backend-template-engine/products-and-shipping Backend template functions for catalog data: products, product categories, shipping profiles, and tax-related hints. These functions load **your brand’s** product catalog and shipping configuration. They are available in **backend** templates (server-rendered `{{ }}` and `@foreach`, etc.). All data is **scoped to the current brand** automatically—the page’s brand context supplies the brand id, and the platform never mixes another brand’s products or profiles into your page. If the brand context is missing or invalid, these functions return **empty results** or **`null`** (they do not fall back to a global list). Category joins also require category rows to belong to the **same brand** as the product. ## Product lookups | Function | What it returns | | - | - | | **`getProduct(code)`** | One product by **code** or numeric **id** (for the current brand). | | **`getProduct(brandId, code)`** | Same, but the first argument is an explicit brand id (advanced; still validated). | | **`getAllProducts()`** | Every product for the brand, storefront visibility ignored (cached on the server for performance). | | **`getProducts(filters)`** | Filtered list, **listable products only** by default. Optional object `filters`: `type`, `classification`, `codes`, **`category_id`**, **`subcategory_id`**, **`show_in_frontend`** (see below). | | **`getProductByCode(brandId, merchantCode, productCode)`** | Resolve a product by merchant integration code (advanced). | These names are also available in **PascalCase** (`GetProducts`, `GetProduct` and so on). Both spellings call the same function, so pages written against the old names keep working. Use camelCase in new pages, which is what backend scripts and most other template functions already use. ### Storefront visibility Each product has a **Show in frontend** toggle (Products, open the product, **General**). It is **on by default**. **`getProducts()` returns only products with it on.** Your product table is not the same thing as your catalog: it also holds upsell-only SKUs, order bumps, bonuses and funnel-specific variants that were never meant to be browsed. Turning the toggle off keeps a product out of shop and category listings without touching anything else. | You want | Pass | | - | - | | Only listable products (**default**) | nothing, or `{ show_in_frontend: true }` | | Everything, hidden included | `{ show_in_frontend: 'any' }` | | Only the hidden ones | `{ show_in_frontend: false }` | ```html theme={null} @foreach(p in getProducts())

{{ p.title }}

@endforeach @foreach(p in getProducts({ show_in_frontend: 'any' }))

{{ p.title }} - listed: {{ p.show_in_frontend }}

@endforeach ``` **Hidden is not the same as unavailable.** A hidden product still resolves through `getProduct(code)`, still prices, still checks out and still fulfils, so every funnel that sells it keeps working. Use **Status: Archived** when you want to stop selling a product. `getAllProducts()` is deliberately **not** filtered. It is the literal everything list, which is what member areas and order pages need to resolve a product a customer has already bought, even after you have taken it off the shop. The flag also travels on the product object as **`show_in_frontend`** (boolean), so you can branch on it yourself. ### Fields you can use on a product Typical fields include **code**, **title**, **checkout\_title**, **slug**, **price**, **retail\_price**, **currency**, **image**, **description**, **classification**, **tax\_category** (tax provider code), **tax\_exempt**, subscription fields when applicable, downloadable files, and merchandising categories: * **`title`** — The customer-facing display title: uses **checkout\_title** when set, otherwise the internal title. * **`slug`** — SEO-friendly URL slug. Auto-generated from checkout\_title/title if not manually set in the SEO tab. Used with [wildcard routes](/pages/wildcard-routes) for clean product URLs (e.g. `{{ product | productUrl:'shop/product' }}` → `/shop/product/my-product-123`). * **`product_category_id`** / **`product_subcategory_id`** — ids in your **product category** tree (configured in the Products area). * **`category_name`** / **`subcategory_name`** — display names for those ids (when set on the product). * **`product_files`** — downloadable files attached to the product. Each row includes **`purpose`** (`digital` or `bonus`), **`title`**, **`description`**, **`image`**, **`file`**, **`kind`** (one of `'audio'`, `'video'`, `'document'`, `'image'`, `'archive'`, `'file'` — derived from extension/mime so you don't have to classify yourself), **`is_primary`**, **`sort_order`**, and **`metadata`** (`mime_type`, `size`, `checksum`, `extension`). * **`bonus_files`** / **`digital_files`** — purpose-filtered views of `product_files`. * **`primary_download_url`** — convenience URL for the product’s first primary downloadable file. Use **`productDownloads(product)`** when you want a single rolled-up array of everything the customer can download — `digital_files` then `bonus_files` then anything else in `product_files`, with the legacy `digital_file` / `bonus_file` scalars filled in only when no file rows exist, deduplicated by URL. Each entry: `{ url, title, kind, purpose, extension, mime_type, size, is_primary, sort_order }`. Use **`getProducts`** with **`category_id`** to limit to products in that category or subcategory row (matches either primary or subcategory assignment). Use **`subcategory_id`** to require a specific subcategory id on the product. ### Display helpers | Function / filter | Purpose | | - | - | | **`productSavings(product)`** | Object with savings **amount**, **percent**, and formatted strings for marketing copy. | | **`subscriptionSummary(product)`** | Short human-readable subscription line (trial, billing interval). | | **`productDownloads(product)`** | Flat, deduplicated array of every downloadable file for the product. Each entry: `{ url, title, kind, purpose, extension, mime_type, size, is_primary, sort_order }`. `kind` is one of `'audio'`, `'video'`, `'document'`, `'image'`, `'archive'`, `'file'`. | | **`formatPrice`** filter | Format a number with currency (e.g. `{{ product.price \| formatPrice:product.currency }}`). | | **`savings`**, **`compareAt`**, **`pricePerUnit`**, **`subscriptionLabel`**, **`merchantPriceId`** filters | Convenience formatting on a product object. | | **`productUrl`** filter | SEO-friendly product URL: `{{ product \| productUrl:'shop/product' }}` → `/shop/product/{slug}-{id}`. Use with [wildcard routes](/pages/wildcard-routes) for product detail pages. | ## Product categories | Function | What it returns | | - | - | | **`getProductCategories()`** | Default: full category **tree** in order, each item includes **`depth`** (0 = top level). | | **`getProductCategories({ parent_id: N })`** | Direct **children** of category `N` only (flat list, sorted). | | **`getProductCategories({ tree: false })`** or **`{ flat: true }`** | Flat list of all categories (no `depth`). | | **`getProductCategories({ top_level: true })`** | Only categories with no parent (then tree or flat as above). | Combine with **`@foreach`** to build category navigation or filter UI on landing pages. ## Shipping profiles Shipping profiles define **rates and rules** used at checkout. These functions help you build **shipping policy** or **FAQ** pages. | Function | What it returns | | - | - | | **`getShippingProfiles()`** | All shipping profiles for the brand. **`shipping_costs`** is a **parsed object** (e.g. regional keys, `default`, `overrides`). **`has_shipping_rates`** is true when that object has keys. | | **`getShippingProfiles({ selectable_only: true })`** | Only profiles visitors can pick at checkout (when that option is enabled on the profile). | | **`getShippingProfiles({ include_product_stats: true })`** | Same as list mode, but each row also includes **`products_count`** and **`tax_categories`**. Use only when you need counts or tax-code summaries—extra queries run per profile. | | **`getShippingProfile(profileId)`** | One profile by id, **including** product stats (**`products_count`**, **`tax_categories`**). | | **`getDefaultShippingProfile()`** | The profile marked default, with the same enrichment as a single **`getShippingProfile`**. | ### `tax_categories` on a shipping profile Shipping profiles do **not** store a tax code themselves. **`tax_categories`** is the list of **distinct `tax_category` values** from **products** that use that shipping profile—useful for disclosure text (“products shipped under this method may use tax codes …”), not for live tax calculation (checkout handles that). ### Other useful fields * **`name`**, **`description`** * **`is_default`**, **`allow_select_on_checkout`** * **`product_stats_loaded`** — `false` on list responses unless you set **`include_product_stats: true`** ## Examples **List products in a category** (replace `3` with a real category id from your store): ```html theme={null} @foreach(p in getProducts({ category_id: 3 }))

{{ p.title }} — {{ p.price | formatPrice:p.currency }}

@endforeach ``` **Category menu (tree):** ```html theme={null} @foreach(c in getProductCategories())

{{ c.name }}

@endforeach ``` **List a product's downloads with type-aware icons** (no URL parsing required): ```html theme={null} @foreach(file in productDownloads(getProduct('my-sku'))) @if(file.kind eq 'audio')🎧 @elseif(file.kind eq 'video')🎬 @elseif(file.kind eq 'document')📄 @elseif(file.kind eq 'image')🖼️ @elseif(file.kind eq 'archive')📦 @else📁 @endif {{ file.title }} ({{ file.extension }}) @endforeach ``` **Shipping methods (names only, fast):** ```html theme={null} @foreach(s in getShippingProfiles())
  • {{ s.name }}@if(s.is_default) (default)@endif
  • @endforeach ``` ## See also * [Backend Template Engine overview](/backend-template-engine/overview) * [Directives](/backend-template-engine/directives) * [Variables and filters](/backend-template-engine/variables-and-filters) * Product links and checkout: [Buy links](/products/buy-links), [Cart page tag](/pages/cart) # Variables and filters Source: https://docs.elasticfunnels.io/backend-template-engine/variables-and-filters Backend template variables {{ path }} and filters: default, slice, raw, t (translations), upper, lower, truncate, replace, currency, join, and more. Backend templates insert values with **`{{ path }}`** and can transform them with **filters** using the pipe character: **`{{ path | filter }}`** or **`{{ path | filter:arg }}`**. For `filter:arg`, the argument can be either: * a **context path/expression** (for example `product.currency_code`) * or a **literal** (recommended as quoted text, for example `'USD'` or `'stripe'`) ## Variables: `{{ path }}` Values are resolved from the current context (e.g. page data, article, posts, featured). | You write | Meaning | | - | - | | `{{ article.title }}` | Property of the current article | | `{{ post.details_url }}` | URL for a post (e.g. in a loop) | | `{{ featured[0].title }}` | First featured item's title | | `{{ blog_base_url \| default:'/blog' }}` | URL with fallback | Paths can use dot notation and array indexing. If a value is missing, it usually renders as empty unless you use the **`default`** filter. *** ## Filters Filters are applied left to right. You can chain several: **`{{ value | filter1 | filter2:arg }}`**. ### Filter arguments (`:arg`) Use `:arg` when a filter needs one parameter. ```html theme={null} {{ product.price | currency:product.currency_code }} {{ product.price | currency:'USD' }} ``` In the first example, `product.currency_code` is resolved from context (for example, `USD`).\ In the second example, `'USD'` is a fixed literal. If you want text to always be treated as literal, quote it. *** ## Translation filter: `t` For [brand translations](/pages/internationalization), **`| t`** treats the piped value as a **translation key** string. Resolution matches **`t()`**: brand dictionary for the resolved locale first, then built-in locale strings. Optional **`| t:params`** passes a context variable or safe expression that resolves to a **plain object** for built-in placeholder interpolation (same rules as **`t(key, params)`**). ```html theme={null} {{ 'hero.title' | t }} {{ t('hero.title') }} {{ 'product.savings_percent' | t:savingsOpts }} ``` Use **`t()`** and **`| t`** in templates for the same resolution order (brand first, then built-in). See [Internationalization (i18n)](/pages/internationalization) for **`locales()`**, **`{{ locale }}`**, and backend **`await t()`**. *** ## Fallback filters ### `default` — fallback when value is empty If the value is empty (null, undefined, or empty string), the filter returns the argument instead. ```html theme={null} {{ article.date_short | default:article.published_at | default:article.created_at }} ``` Chain multiple fallbacks — the first non-empty value wins. ```html theme={null} {{ article.author_name | default:'Author' }} {{ article.reading_time | default:'5' }} min read ``` ### `default_if_none` — fallback only for null/undefined Like `default`, but only triggers when the value is `null` or `undefined`. An **empty string** passes through unchanged. ```html theme={null} {{ score | default_if_none:'0' }} ``` *** ## String filters ### `upper` / `lower` / `capitalize` / `title` ```html theme={null} {{ customer.first_name | upper }} {{ customer.first_name | lower }} {{ customer.first_name | capitalize }} {{ product.name | title }} ``` ### `trim` Strip leading and trailing whitespace. ```html theme={null} {{ var.label | trim }} ``` ### `first` / `last` Return the first or last character of a string (or first/last element of an array). ```html theme={null} {{ customer.first_name | default:'A' | upper | first }} {{ items | last }} ``` ### `slice` — substring Use **`slice:start:end`** (e.g. `slice:0:1` for the first character, `slice:0:3` for the first 3). ```html theme={null} {{ (article.author_name | default:'A') | slice:0:1 }} ``` Parentheses ensure the whole expression is evaluated first, then `slice` is applied to the result. ### `replace` — find and replace Replace all occurrences of a substring. Arg format: `'old':'new'`. Plain string replacement — no regex. ```html theme={null} {{ product.sku | replace:'-':' ' }} {{ phone | replace:'(':'') | replace:')':'' | replace:' ':'' }} ``` ### `truncate` — limit character length Truncate to N characters, appending `...` if the value was cut. ```html theme={null} {{ article.excerpt | truncate:160 }} ``` ### `truncatewords` — limit word count Truncate to N words, appending `...` if cut. ```html theme={null} {{ article.body | truncatewords:30 }} ``` ### `nl2br` — newlines to `
    ` Convert newline characters to `
    ` tags. The value is HTML-escaped first, so user content is safe. Chain with `| raw` to render the output as HTML. ```html theme={null} {{ customer.notes | nl2br | raw }} ``` ### `strip_tags` — remove HTML tags Strip all HTML tags from a string. Useful for getting plain text from rich content. ```html theme={null} {{ article.body | strip_tags | truncate:200 }} ``` ### `url_encode` — percent-encode for URLs Encode a value for safe use in a URL query string. ```html theme={null} Search ``` ### `length` / `size` Return the number of characters in a string, or the number of elements in an array. `size` is an alias. ```html theme={null} {{ customer.bio | length }} {{ order.items | size }} ``` *** ## Number filters ### `round` Round to the nearest integer, or to N decimal places. ```html theme={null} {{ price | round }} {{ price | round:2 }} ``` ### `floor` / `ceil` Round down or up to the nearest integer. ```html theme={null} {{ price | floor }} {{ price | ceil }} ``` ### `abs` Absolute value. ```html theme={null} {{ order.balance | abs }} ``` ### `number_format` Format a number with thousands separator and fixed decimal places (default 2). ```html theme={null} {{ revenue | number_format }} {{ count | number_format:0 }} ``` ### `currency` Format a number as currency. Optional arg is the ISO 4217 currency code (default `USD`). ```html theme={null} {{ price | currency }} {{ price | currency:'EUR' }} {{ price | currency:order.currency_code }} ``` *** ## Boolean / conditional filters ### `yesno` Return a string based on whether the value is truthy or falsy. Arg format: `'yes':'no'` or `'yes':'no':'maybe'` (the third value is used for `null`/`undefined`). ```html theme={null} {{ order.active | yesno:'Active':'Inactive' }} {{ member.verified | yesno:'✓':'✗' }} {{ score | yesno:'Pass':'Fail':'No score yet' }} ``` *** ## Array filters ### `join` Join an array into a string with a separator. ```html theme={null} {{ tags | join:', ' }} {{ order.skus | join:' | ' }} ``` ### `sort` Sort an array alphabetically (strings) or numerically. ```html theme={null} {{ categories | sort | join:', ' }} ``` ### `reverse` Reverse a string or array. ```html theme={null} {{ items | reverse }} {{ 'hello' | reverse }} ``` ### `unique` Remove duplicate values from an array. ```html theme={null} {{ tags | unique | join:', ' }} ``` *** ## Date filters ### `date` — format a date Format a `Date` object, timestamp (ms), or the string `'now'` using format tokens. | Token | Meaning | Example | | - | - | - | | `yyyy` | 4-digit year | 2024 | | `yy` | 2-digit year | 24 | | `MMMM` | Full month name | January | | `MMM` | Short month name | Jan | | `MM` | 2-digit month | 01 | | `M` | Month (no padding) | 1 | | `dd` | 2-digit day | 05 | | `d` | Day (no padding) | 5 | | `HH` / `H` | Hour 24h (padded / not) | 09 / 9 | | `hh` / `h` | Hour 12h (padded / not) | 09 / 9 | | `mm` / `m` | Minute (padded / not) | 04 / 4 | | `ss` / `s` | Second (padded / not) | 07 / 7 | ```html theme={null} {{ order.created_at | date:'MMM d, yyyy' }} {{ 'now' | date:'yyyy' }} {{ order.created_at | date:'dd/MM/yyyy' }} ``` ### `date_add` — add days to a date Add N days to a date value. Returns a `Date` object — chain with `| date` to format. ```html theme={null} {{ order.created_at | date_add:30 | date:'MMM d, yyyy' }} {{ subscription.start_date | date_add:365 | date:'MMM d, yyyy' }} ``` Use a negative number to subtract days. ```html theme={null} {{ 'now' | date_add:-7 | date:'MMM d, yyyy' }} ``` ### `timeago` — relative time Render a date as a human-readable relative string. ```html theme={null} {{ order.created_at | timeago }} {{ event.starts_at | timeago }} ``` *** ## Output / serialization filters ### `raw` — output HTML unescaped By default, output is HTML-escaped for safety. Use **`raw`** only for trusted HTML (e.g. stored rich content like article body or excerpt). ```html theme={null} {{ article.body | raw }} {{ (post.excerpt | default:post.summary | default:'') | raw }} {{ customer.notes | nl2br | raw }} ``` ### `json` — serialize to JSON Serialize any value to a JSON string. Safe: uses `JSON.stringify`, no code is executed. Chain with `| raw` to embed the output in HTML attributes or ` ``` *** ## Patterns from real templates **Date with fallbacks:** ```html theme={null} {{ article.date_short | default:article.published_at | default:article.created_at }} ``` **Avatar initial when no image:** ```html theme={null} {{ (article.author_name | default:'A') | slice:0:1 }} ``` **Excerpt or summary, then output as HTML:** ```html theme={null} {{ (featured[0].excerpt | default:featured[0].summary | default:'') | raw }} ``` **First initial from full name:** ```html theme={null} {{ customer.first_name | default:'F' | upper | first }} ``` **Price formatted as currency:** ```html theme={null} {{ product.price | currency:'USD' }} ``` **Trial end date:** ```html theme={null} Your trial ends on {{ subscription.start_date | date_add:14 | date:'MMM d, yyyy' }}. ``` **Multi-line user notes:** ```html theme={null} {{ customer.notes | nl2br | raw }} ``` **Embed data as JSON:** ```html theme={null}
    ``` **Optional category:** ```html theme={null} @if(post.category) {{ post.category.name }} @endif ``` *** ## String concatenation: `+` Use the **`+`** operator to join strings, variables, and filtered expressions together. `+` works left to right as in JavaScript: two numbers are **added**, and as soon as a string is involved the values are **joined**. `{{ 1 + 2 }}` is `3`, `{{ "Order #" + 42 }}` is `Order #42`, and `{{ qty + extra + " items" }}` adds the two numbers first. A missing value joins as an empty string. That is what lets a loop keep a counter: `@set(n = n + 1)`. ```html theme={null} {{ "(" + var.guarantee_title + ")" }} ``` You can combine it with filters by wrapping the filtered part in **parentheses**: ```html theme={null} {{ "(" + (var.guarantee_title | default:"90-Day Money Back Guarantee") + ")" }} ``` This works everywhere expressions are accepted — in `{{ }}`, in `@set`, and in `@component` arguments: ```html theme={null} @component("purchase-box", { title_suffix: "(" + (var.guarantee_title | default:"90-Day Guarantee") + ")" }) ``` ```html theme={null} @set(label = "Order #" + order.id) {{ label }} ``` Strings are **literal** — there is no variable interpolation inside quotes. Writing `"price is: {{ price }}"` outputs the text `price is: {{ price }}` verbatim, it does **not** resolve the variable. To mix text and variables, use concatenation: ```html theme={null} {{-- ✗ wrong — {{ }} inside a string is literal text, not a variable --}} {{ "price is: {{ price }}" }} {{-- ✓ correct — concatenate the string and the variable --}} {{ "price is: " + price }} ``` *** ## Filter reference | Filter | Category | Arg | Example | | - | - | - | - | | `default` | Fallback | `'fallback'` | `{{ name \| default:'Anonymous' }}` | | `default_if_none` | Fallback | `'fallback'` | `{{ score \| default_if_none:'0' }}` | | `upper` | String | — | `{{ name \| upper }}` | | `lower` | String | — | `{{ name \| lower }}` | | `capitalize` | String | — | `{{ name \| capitalize }}` | | `title` | String | — | `{{ name \| title }}` | | `trim` | String | — | `{{ label \| trim }}` | | `first` | String/Array | — | `{{ name \| first }}` | | `last` | String/Array | — | `{{ name \| last }}` | | `slice` | String/Array | `start:end` | `{{ name \| slice:0:1 }}` | | `replace` | String | `'old':'new'` | `{{ sku \| replace:'-':' ' }}` | | `truncate` | String | `N` | `{{ text \| truncate:100 }}` | | `truncatewords` | String | `N` | `{{ text \| truncatewords:20 }}` | | `nl2br` | String | — | `{{ notes \| nl2br \| raw }}` | | `strip_tags` | String | — | `{{ body \| strip_tags }}` | | `url_encode` | String | — | `{{ query \| url_encode }}` | | `length` / `size` | String/Array | — | `{{ items \| length }}` | | `round` | Number | `decimals` | `{{ price \| round:2 }}` | | `floor` | Number | — | `{{ price \| floor }}` | | `ceil` | Number | — | `{{ price \| ceil }}` | | `abs` | Number | — | `{{ balance \| abs }}` | | `number_format` | Number | `decimals` | `{{ revenue \| number_format:2 }}` | | `currency` | Number | `'USD'` | `{{ price \| currency:'USD' }}` | | `yesno` | Boolean | `'yes':'no'` | `{{ active \| yesno:'Yes':'No' }}` | | `join` | Array | `','` | `{{ tags \| join:', ' }}` | | `sort` | Array | — | `{{ items \| sort }}` | | `reverse` | String/Array | — | `{{ items \| reverse }}` | | `unique` | Array | — | `{{ tags \| unique }}` | | `date` | Date | `'format'` | `{{ date \| date:'MMM d, yyyy' }}` | | `date_add` | Date | `N days` | `{{ date \| date_add:30 }}` | | `timeago` | Date | — | `{{ date \| timeago }}` | | `raw` | Output | — | `{{ html \| raw }}` | | `json` | Output | — | `{{ obj \| json \| raw }}` | | `t` | Translation | optional object | `{{ 'hero.title' \| t }}` | *** ## Summary * Use **`{{ path }}`** to output a value; use **`| filter`** or **`| filter:arg`** to transform it. * **Chained `default`** — `a | default:b | default:c` gives the first non-empty value. * **`slice:0:1`** — First character; combine with `(expression | default:'X')` for safe initials. * **`raw`** — Use only for trusted HTML (e.g. article body, nl2br output, json). * **Parentheses** — `{{ (expression) | filter }}` so the filter applies to the whole expression. * **Concatenation** — `"text" + variable + "more text"` joins values. No interpolation inside strings; use `+` instead. * **`| t`** — Pipe a string key for translations; see [Internationalization](/pages/internationalization). ## Purchase Link Functions These functions generate URLs for purchase and subscription actions. Use them in link `href` attributes. | Function | Output | Description | | - | - | - | | `{{ upsell_upgrade('old_sku', 'new_sku') }}` | JS action URL | Upgrade subscription from one product to another | | `{{ upsell_cancel() }}` | JS action URL | Cancel the current subscription | These can also be used as filters: `{{ 'old_sku' | upsell_upgrade('new_sku') }}`. See [Subscription Upsells](/products/subscription-upsells) for usage details and examples. *** For conditionals and loops, see [Directives](/backend-template-engine/directives). For client-side templates, see [Frontend Template Engine](/frontend-template-engine/overview). # Checkout functionality reference Source: https://docs.elasticfunnels.io/checkouts/checkout-functionality-reference Canonical reference for ElasticFunnels checkout: scope, fields, checkout_settings, abuse protection, submit flow, and redirects. This page is the **source of truth** for how ElasticFunnels checkout works: configuration keys, server behavior, and what visitors experience. For a **step-by-step page build** (HTML patterns, wizard layout, copy-paste examples), use [How to design a checkout page](/guides/how-to-design-a-checkout-page). That guide defers to this page for exact semantics of `checkout_settings` and security options. For **template syntax** (`[[ ]]`, bindings, `checkout.*` in the page builder), see [Frontend Template Engine: Checkout](/frontend-template-engine/checkout). *** ## 1) Enable checkout on a page Checkout behavior runs only when the page is marked as a checkout page: 1. Open **Page List** → **Edit Page** 2. Enable **Is checkout page** 3. Save If this is off, `checkout` scope data, submit handling, payment processing, and post-purchase redirects may not run as documented here. *** ## 2) Checkout scope (frontend) On checkout pages, the `checkout` scope is available to templates and bindings. Common keys: | Area | Keys | | - | - | | Customer | `checkout.customer.*` (names, email, phone, shipping/billing address fields) | | Geography | `checkout.countries`, `checkout.usStates` (dropdown options) | | Commerce | `checkout.coupon`, `checkout.coupon_message`, `checkout.bump_products`, `checkout.selectedBumpLines` | | Totals | `checkout.subtotal`, `checkout.tax`, `checkout.shipping`, `checkout.total`, plus `*_raw` variants (see template engine doc) | | Toggle | `checkout.shipping_same_as_billing` | | Wallets | `checkout.wallet_available` (`{ applePay, googlePay }` — starts `false/false`, updated once Collect.js / Stripe.js detects support), `checkout.wallet_visible` (boolean — `true` once a wallet button actually renders; only promoted, never reset), `checkout.wallet_settings` (from `checkout_settings.wallet`) | | Merchant | `checkout.merchant_country` (merchant's country, falling back to the domain's, then `US`) | | Payment panel | `checkout.panel_billing_fields` (from `checkout_settings.billing_fields`; `null` = show all) | | Subscription | `checkout.is_subscription`, `checkout.subscription.*` (see template engine doc for the full shape, including `intro_offer`, `price_steps`, `prepaid`, `tiers`) | Template engine details: [Checkout](/frontend-template-engine/checkout). **`checkout.shipping` renders `$0.00` when the buyer's region has no rate defined.** Shipping comes from the product's shipping profile, and its **Mainland US** and **International** rates are independent - an empty rate is treated as free shipping for that region, not as a fallback to the other rate. A blank International Cost therefore ships free to every non-US buyer, and a product with no shipping profile ships free everywhere. See [How a shipping rate is chosen](/products/product-management). *** ## 3) Supported form field names These align with what the checkout runtime validates and submits (shipping-first; billing can mirror shipping). **Names** Two name pairs exist and both are recognised: * `shipping_first_name`, `shipping_last_name` — what the **Checkout Details** builder block emits, and what the server requires (with fallbacks). Use these on shipping-collecting pages. * `first_name`, `last_name` — what `` emits, and the pair the card panel checks when deciding whether to inject its own "Cardholder Name" field. The cardholder-name detector only looks for `first_name` / `last_name`. A page that collects `shipping_first_name` / `shipping_last_name` (the builder default) will therefore also get a **redundant** Cardholder Name field injected into the card panel. Add `first_name` / `last_name` inputs — hidden ones are fine — if you want to suppress it. Where a name is missing, the server backfills it: personal ← shipping ← billing ← the cardholder name (split into first / last). **Shipping / personal** * `email` (required), `phone` (optional) * `shipping_address`, `shipping_address2` (optional) * `shipping_country` (required, 2-letter, uppercased), `shipping_city`, `shipping_state`, `shipping_zip` Client-side validation requires any of these that are **present on the page**; server-side, only `email`, `shipping_country` and the shipping name pair are strictly required — address, city, state and ZIP are accepted empty. `shipping_country` defaults to `US` when left blank. **Billing** (when shown; if empty, shipping values are used) * `billing_first_name`, `billing_last_name` — never client-side required * `billing_address`, `billing_address2`, `billing_country`, `billing_city`, `billing_state`, `billing_zip` — validated client-side only when a billing field is visible; all optional server-side **Toggle** * `shipping_same_as_billing` → `checkout.shipping_same_as_billing` **Errors** * Use `data-checkout-error=""` matching the input `name` for inline validation messages. *** ## 4) `checkout_settings` (backend script) Configure checkout using variables set in a **backend** script on the **same checkout page** (` ``` **`setVariable` is what publishes the settings. A bare `var` does nothing.** Backend scripts run in a sandbox, and the only value that leaves it is what you hand to `setVariable`. Writing `var checkout_settings = {...}` and stopping there leaves the object inside the sandbox where nothing reads it. Nothing errors and the page renders normally, so the mistake is invisible: your country allowlist is silently not applied and your bumps never appear. If you would rather build the object up in steps, that is fine. Just publish it at the end: ```html theme={null} ``` ### 4.1 Countries and US states allowlist Keys: * `checkout_settings.countries` → drives `checkout.countries` in the UI * `checkout_settings.usStates` → drives `checkout.usStates` for US state dropdowns Allowed entry formats: * Two-letter codes as strings: `'US'`, `'ca'`, `'tx'` * `{ value: 'US' }` * `{ value: 'US', label: 'United States' }` Behavior: 1. Values are normalized (e.g. uppercase) and validated against built-in lists. 2. Invalid entries are dropped. 3. If nothing valid remains, the full default lists are used (not restricted). 4. The effective allowlist is stored in the visitor **session** and **re-validated on submit**, so blocked values cannot be forced with browser devtools alone. Example, countries only: ```html theme={null} ``` Example, US states only: ```html theme={null} ``` ### 4.2 Order bumps the page always offers `checkout_settings.bumps` names the order bumps this checkout page offers, so they do not depend on the URL or on a funnel run. Each entry is a **bump product code**, or an object carrying overrides: ```html theme={null} ``` ```html theme={null} ``` Behavior: 1. Each code is resolved against the product catalog. A product whose classification is not `bump` is skipped, so a typo shows nothing rather than selling the wrong item. 2. `name`, `price` and `image` always come from the product record. `description`, `image` and `added_message` given here override it when non-empty. 3. These merge with any bumps from `?bumps=` and from the **Set Checkout Bumps** node. A code declared in more than one place is offered once. Prefer this over the URL when a page should always carry the same bump. Because it lives on the page, it survives a direct hit, a refresh and a shared link, none of which is true of `?bumps=`. Use the **Set Checkout Bumps** node instead when the bump set is decided per funnel branch or is being split-tested. ### 4.3 Card panel billing fields `checkout_settings.billing_fields` controls which inputs the card panel's auto-injected billing block renders. It is surfaced to templates as `checkout.panel_billing_fields`. ```html theme={null} ``` | Value | Effect | | - | - | | omitted / `null` | Both Country and ZIP are shown (default) | | `['country']` | Country select only | | `['zip']` | ZIP / postal code only | | `['country', 'zip']` | Both — same as the default | | `['address']` | Legacy alias for `['zip']`, kept so existing pages keep working | If the resulting set is empty, the billing block is not rendered at all. ### 4.4 Wallet labels `checkout_settings.wallet` is surfaced as `checkout.wallet_settings` and its values are handed straight to Apple on the **Apple Pay recurring payment sheet**. They are not an ElasticFunnels concept: Apple defines these fields, and Apple requires them on a recurring transaction. ```html theme={null} ``` | Key | Goes to | Falls back to | | - | - | - | | `recurring_description` | the sheet's description of what recurs | the product title, else `Subscription` | | `recurring_label` | the line item label on the sheet | the product title, else `Subscription` | | `management_url` | the "manage subscription" link Apple shows the customer | the page's own origin | `management_url` should point at a page where the customer can genuinely view and cancel the subscription, such as your members area. It is shown to the buyer inside Apple's sheet and in their Apple Pay subscription settings afterwards. Leaving it unset silently falls back to the checkout page's origin, which is rarely a page that can actually cancel anything. These apply only when **all** of the following hold: the gateway is NMI with Collect.js, the product is a subscription, and the page has an Apple Pay container. On a one-time purchase they are ignored. *** ## 5) Checkout abuse protection (card-testing / velocity) Optional **progressive** friction on **main sale** card checkout submissions (`POST /process-checkout`). It is **off by default** unless you set `checkout_settings.abuse_protection.enabled` to `true`. This is separate from **site-wide** page rate limiting / HTML captcha on normal page views. Abuse protection here applies only to the checkout payment submit path when enabled on the checkout page. ### 5.1 Configuration ```html theme={null} ``` | Key | Type | Default when omitted | | - | - | - | | `checkout_settings.abuse_protection.enabled` | boolean | `false` (feature off) | | `checkout_settings.abuse_protection.mode` | string | `"progressive"` (only mode documented) | | `thresholds.captcha_requests` | integer | `8` | | `thresholds.hard_block_requests` | integer | `20` | | `thresholds.window_ms` | integer | `60000` (1 minute) | | `thresholds.block_duration_ms` | integer | platform default block window (typically one hour) | You can omit `thresholds` entirely to use defaults. Partial overrides are merged with defaults for any key you specify. ### 5.2 What is protected * **In scope:** Main sale submissions to **`POST /process-checkout`** when the request is **not** marked as an upsell (`type` ≠ `upsell`). * **Out of scope:** One-click upsell charges, PayPal/Klarna flows that do not use the same submit path, and arbitrary API calls. Tighten other surfaces separately if needed. ### 5.3 Session lifecycle 1. When a visitor loads a checkout page, sanitized abuse settings are stored in session (with `brand_id`, checkout `page_id` from the render context, and a timestamp). 2. Each qualifying `POST /process-checkout` is counted per visitor IP inside a **checkout-specific** bucket (not mixed with generic page-view rate limits). 3. If settings are **stale** (e.g. older than 24 hours) or **brand / page** no longer match the request, the stored config is cleared and protection does not apply until the visitor loads checkout again. 4. After a successful **checkout captcha** verification, the visitor receives a **short-lived** “verified” flag for checkout (default **15 minutes**) and the checkout attempt counter for that IP is reset, so legitimate users can complete payment after solving the challenge. ### 5.4 Progressive behavior Within `window_ms`, counting **checkout submit attempts** for the IP: | Condition | HTTP | Visitor experience | | - | - | - | | Below `captcha_requests` | 200 path | Normal checkout continues. | | At or above `captcha_requests`, below `hard_block_requests` | `429` | JSON response asks for captcha; checkout UI opens a **modal**, visitor solves math challenge, then checkout **resubmits** automatically. | | At or above `hard_block_requests` | `403` | Submit blocked for a period (`block_duration_ms`); message indicates too many attempts. | Local and private IPs are not rate-limited by this checkout guard. ### 5.5 JSON response fields (checkout submit) When abuse protection returns an error-shaped response, the JSON may include: | Field | Meaning | | - | - | | `success` | `false` | | `error` | Human-readable message | | `error_code` | Machine code (see below) | | `error_type` | `"abuse_protection"` when from this feature | | `retry_allowed` | `true` when captcha path may retry; `false` on hard block | | `captcha_required` | `true` when the UI should show the captcha step | | `captcha_payload` | `{ question, token }` for the modal challenge | **`error_code` values (submit / middleware)** | Code | Typical HTTP | Meaning | | - | - | - | | `CHECKOUT_CAPTCHA_REQUIRED` | 429 | Solve captcha, then retry submit. | | `CHECKOUT_HARD_BLOCKED` | 403 | Too many attempts; wait before retrying. | Captcha verification (handled inside checkout UI, not a merchant integration surface) may return `CHECKOUT_CAPTCHA_INVALID`, `CHECKOUT_CAPTCHA_DISABLED`, `CHECKOUT_CAPTCHA_SAVE_FAILED`, or `CHECKOUT_CAPTCHA_ERROR` if something goes wrong; the UI refreshes the challenge or shows an error. ### 5.6 Tuning and false positives * Corporate NAT or shared IPs can concentrate traffic on one address; if legitimate buyers are challenged too often, **raise** `captcha_requests` or **widen** `window_ms`. * For testing, use **low** thresholds only on a **sandbox** checkout or test brand. * If protection “never fires,” confirm the checkout page was **loaded in the same browser session** before submit (session must contain the config). *** ## 6) Payment panel and submit wiring Recommended card flow: ```html theme={null}
    ``` Submission is **formless**: the checkout runtime never submits a `
    `. It listens for a click on `[data-checkout-submit]` (or `.checkout-button`), calls `preventDefault()`, and collects every named / `data-template-value` field from the page wrapper. `type="submit"`, `method="POST"` and `form="..."` are cosmetic. ### `` attributes Written on the custom tag, these are read server-side and re-emitted onto the generated `
    `: | Attribute | Values | Default | Description | | - | - | - | - | | `data-accepted-cards` | Comma-separated card names | `visa,mastercard,amex,discover` | Card brands shown in the payment tab and validated during input. Supported: `visa`, `mastercard`, `amex`, `discover`, `diners`, `jcb` | | `data-applepay` | `"false"` to disable | enabled | When `"false"`, Apple Pay is not rendered inside this panel. See the caveat below. | | `data-applepay-force` | `"true"` | — | Pre-renders a native `` placeholder and sets `checkout.wallet_visible` immediately | | `data-paypal` | Merchant ID | — | Enables the PayPal tab (resolved server-side). Ignored unless the ID is a PayPal merchant in this brand. See [PayPal](/merchants/paypal). | | `data-klarna` | Merchant ID | — | Enables the Klarna tab (resolved server-side). Ignored unless the ID is a Klarna merchant in this brand. | The panel builds its UI client-side from these attributes and `efScope.checkout`. When PayPal, Klarna, or the Apple Pay tab is active, the panel renders as a tabbed accordion; otherwise it renders card fields only. `data-applepay-layout` is **not** in this list. The client-side panel does read `data-applepay-layout` (`"separated"`, `"tab"`, `"inline"`, or unset for auto), but the server-side tag expander does not forward it — the generated `
    ` carries only the attributes above. Setting it on `` has no effect. Use `` for an above-the-panel wallet. `data-applepay="false"` does not always suppress Google Pay. On **NMI**, a `#googlePay` container is still injected when the visitor is on Android. On **Stripe**, both wallets share one mount point, so it disables both. ### Layout selection With no wallet tag elsewhere on the page, the panel picks its layout automatically: | Condition | Layout | | - | - | | HTTPS, gateway is NMI or Stripe, `data-applepay` not `"false"`, and no `#applePay` already in the DOM | Apple Pay as a **radio tab** alongside Card (and PayPal / Klarna). The tab relabels to Google Pay if that is what gets detected. | | Any of the above not met | Legacy **inline** layout — wallet containers above the card fields with an `or pay with card` divider | Because the tab layout requires that no `#applePay` element already exists, adding `` anywhere on the page automatically stops the panel from rendering its own wallet tab. ### Smart field detection The card panel automatically detects which checkout fields are present on the page and fills in any gaps: * **Cardholder name:** If `first_name` **or** `last_name` is missing from the page, a "Cardholder Name" field appears inside the card panel. Note this checks only that pair — `shipping_first_name` / `shipping_last_name` do not satisfy it. * **Billing block:** If neither `billing_zip` nor `shipping_zip` is on the page, the card panel renders a billing block containing a **Country** select and a **ZIP / postal code** input. It never adds a name or street-address field. Which of the two appear is controlled by `checkout_settings.billing_fields` (section 4.3). * **Auto-copy:** When billing fields are inside the card panel and no shipping fields exist on the page, billing values are automatically copied to shipping during submission (and vice versa). This means a minimal checkout page with only email and the payment panel still collects enough billing information for AVS. ### Wallet contact field auto-detection NMI / Collect.js only. On **Stripe**, the payment request is always created with `requestPayerName`, `requestPayerEmail` and `requestShipping` enabled and no phone request, regardless of which fields are on the page. | Page has | Wallet requests | | - | - | | `shipping_address` or `shipping_country` | Shipping postal address | | `billing_address` or `billing_country` | Billing postal address | | `first_name`, `shipping_first_name`, or `billing_first_name` | Name | | `email` | Email | | `phone` | Phone | | None of the above (card-only) | No contact fields — just the payment token | Only first-name variants are checked; `last_name` alone does not trigger the name request. No configuration is needed — for digital products that only need email and payment, the wallet skips address collection entirely. ### `` Standalone wallet placement. Renders `
    ` containing both the `#applePay` and `#googlePay` containers, so the buttons can sit anywhere on the page: ```html theme={null} ``` | Attribute | Values | Description | | - | - | - | | `force` | `"true"` | Pre-renders a native `` placeholder so iOS shows it immediately, and sets `checkout.wallet_visible` to `true` without waiting for detection | | `wallets` | `apple_pay`, `google_pay` (comma-separated) | Restricts which wallets are allowed. Emitted as `data-wallets` and used for Stripe's `disableWallets` | Both are plain attributes, **not** `data-` prefixed. The tag may be self-closing. `` is the legacy single-wallet form of this tag. It accepts `force="true"` and renders only the Apple Pay container. Prefer `` on new pages. `` and `` accept a self-closing form. The other checkout tags — ``, ``, ``, `` — **require an explicit closing tag**. Written self-closing they are neither expanded nor stripped, and pass through to the browser as unknown markup. ### Tag content handling | Tag | Children you write inside it | | - | - | | `` | Preserved, rendered through the template engine, and appended **after** the fields the tag generates | | `` | Same — appended after the generated shipping / billing fields | | `` | **Discarded.** The tag is replaced by an empty `
    ` that the client-side panel fills in | `` and `` generate their own inputs (from `data-fields` and `data-shipping-fields` respectively) — you do not write the inputs yourself. **`` attributes** | Attribute | Values | Default | | - | - | - | | `data-fields` | Comma-separated: `email`, `phone`, `first_name`, `last_name` | `email,phone` | **`` attributes** | Attribute | Description | | - | - | | `data-shipping-fields` | Which shipping fields to render | | `data-countries` | Country allowlist for the dropdown | | `data-theme-class` | Extra class applied to the generated container | | `data-input-style`, `data-label-style` | Inline style strings applied to generated inputs / labels | | `data-input-border-color`, `data-input-bg-color`, `data-input-text-color`, `data-input-focus-border-color`, `data-input-focus-shadow-color`, `data-input-border-radius`, `data-input-padding` | Generated input theming | | `data-label-color`, `data-label-bg-color`, `data-label-active-color`, `data-label-font-size`, `data-label-font-weight` | Generated floating-label theming | ### Wallet conditional display | Key | Meaning | | - | - | | `checkout.wallet_available` | `{ applePay, googlePay }`. Starts `false / false` server-side; updated once the payment library detects support, typically 1-2 seconds after load | | `checkout.wallet_visible` | `true` once a wallet button has actually rendered. Only promoted `false` → `true`, never reset. Set immediately by `force="true"` | Prefer `wallet_visible` for layout decisions — it will not flicker on first paint the way `wallet_available` does: ```html theme={null}
    or pay another way
    ``` ### `window.ef.checkout` API A small JavaScript API for programmatic wallet control: | Member | Description | | - | - | | `ef.checkout.walletAvailable` | Getter returning the same `{ applePay, googlePay }` as `checkout.wallet_available` | | `ef.checkout.payWithWallet(method)` | Triggers `'applePay'` or `'googlePay'` by clicking the matching `[data-wallet]` button. Requires NMI Collect.js; logs a warning if the wallet or button is unavailable | | `ef.checkout.onWalletComplete(callback)` | Registers a callback fired after a wallet payment attempt | See [Apple Pay and Google Pay](/merchants/apple-pay-google-pay) for full setup, configuration, and layout examples. ### Styling checkout fields Checkout input fields (floating labels, focus rings, active label color) respond to a single CSS variable: | Variable | Default | Controls | | - | - | - | | `--ef-primary` | `#3b82f6` | Focus border color, focus glow, active floating label color | Set it in the page's ` ``` You can also scope it to a specific container instead of `:root`: ```html theme={null} ``` All standard input fields (`ef-floating-input`, `ef-form-input`) and quiz fields (`ef-quiz-floating-input`) inherit the value automatically. No other changes are needed. ### Styling the wallet divider The divider between the wallet buttons and the card panel uses the `ef-wallet-divider` classes. The platform emits the text in lowercase — `or pay another way` in the separated layout, `or pay with card` in the inline layout — and applies **no styling of its own**, so define these classes in your page's ` ``` *** ## 7) After successful payment (redirects) * If the server returns `redirectUrl`, the visitor is sent there. * Otherwise, the default purchase confirmation route is used (query parameters typically include order and email). Details: [Checkout redirect behavior](/funnels/checkout-redirect-behavior). *** ## 8) Troubleshooting | Issue | What to check | | - | - | | Country/state restriction ignored | `checkout_settings` on backend scope; valid codes; session + server enforcement after page load | | Abuse protection never triggers | `enabled: true`; visitor opened checkout page first; thresholds not too high; not upsell-only flow | | Too many captchas | Increase `captcha_requests` or `window_ms`; check shared IP / VPN concentration | | Hard blocks too aggressive | Increase `hard_block_requests` or `window_ms`; review `block_duration_ms` | *** ## Related docs * [How to design a checkout page](/guides/how-to-design-a-checkout-page) — build walkthrough and HTML patterns * [Frontend Template Engine: Checkout](/frontend-template-engine/checkout) — `checkout.*` in templates * [Frontend Template Engine: Cart](/frontend-template-engine/cart) — cart lines, bumps, qty controls * [Checkout redirect behavior](/funnels/checkout-redirect-behavior) — post-checkout routing * [reCAPTCHA setup](/domains/recaptcha-setup) — site-wide captcha (separate from checkout abuse modal) # Courses Source: https://docs.elasticfunnels.io/courses/overview Create structured courses with modules, lessons, and progress tracking — gated by customer enrollment from purchase Courses let you deliver structured learning content (video, audio, or text lessons) to customers who have purchased access. Each course is organized into **modules** with **lessons** (contents). Enrollment is tied to a product purchase — when a customer buys the linked product, they are automatically enrolled. ## How courses work ```mermaid theme={null} flowchart LR A[Customer buys product] --> B[Enrollment created automatically] B --> C[is_assigned = true on that course] C --> D[Customer accesses course content] D --> E[Progress tracked per lesson] ``` 1. You create a course in the ElasticFunnels dashboard with modules and lessons. 2. You link a **product** to that course — purchasing the product grants enrollment. 3. On your course pages, `getCourseForCustomer(slug)` returns `is_assigned: true` for enrolled customers and `false` for everyone else. 4. You gate content in the template: show the full course to enrolled customers, show an "Get access" CTA to others. ## Setting up a course ### 1. Create the course In the dashboard, go to **Courses** and create a new course. Add: * **Title**, **slug**, **description**, **cover image**, **instructor name** * **Category** (optional — used for filtering on the course list page) * **Modules** — each with a title and one or more **lessons** * **Lessons** — each has a title, type (`video`, `audio`, or `text`), and a content URL or embed ### 2. Link a product to the course In the course settings, select the **product** that grants access. When a customer purchases this product, an enrollment record is created automatically. You can link multiple products to the same course (e.g. a bundle and a standalone SKU both grant access to the same course). ### 3. Create your course pages You need at minimum two pages: | Page | Purpose | | - | - | | **Course list** | Show all available courses with enrollment status (assigned vs. not) | | **Course detail** | Show a single course — full content if enrolled, "Get access" CTA if not | See [Courses — Template Reference](/courses/template) for complete backend script and template examples. ## Enrollment and `is_assigned` Enrollment is created automatically by `BrandCourseEnrollment` when a purchase conversion is recorded for a product that is linked to a course. The `is_assigned` flag is resolved per-customer when you call `getCourseForCustomer({ slug })` or `getCourses({ assignedOnly: false })`. It is `true` if the currently logged-in customer has an enrollment record for the course. `started` is `true` when the enrollment has any progress (at least one lesson completed or in progress). Customers are automatically logged in after checkout, so a customer who just purchased will have `is_assigned: true` the moment they land on the course page — no extra login step needed. ## Progress tracking Progress is tracked server-side per customer, per lesson. All course functions that resolve enrollment (`getCourseForCustomer`, `getCourses`, `getCoursesForCustomer`) include the customer's progress: * `completed_content_ids` — array of completed lesson IDs * `progress_percent` — 0–100 * `is_completed` — `true` when the course reaches 100% * `current_unfinished_lesson` — object with module/lesson ids, titles, and resume URL when in progress * `resume_url` — direct resume link when available The front-end marks lessons complete by calling the progress API (`POST /api/course/courses/:courseId/lessons/:contentId/complete`). For dashboards and aggregate views, two additional functions are available: * `getCompletedLessonsCount(opts?)` — returns the total number of completed lessons (optionally filtered by course slug) * `getCourseProgress(opts?)` — returns a structured summary with per-course and overall lesson counts See [Template Reference](/courses/template) for full API signatures and examples. ## Course object structure All course functions return objects with this shape: ```javascript theme={null} { id: 42, title: "12-Week Fat Loss Blueprint", slug: "fat-loss-blueprint", description: "A step-by-step 12-week program combining nutrition, cardio, and strength training.", instructor_name: "Coach Sarah Mills", cover: "https://cdn.example.com/courses/fat-loss-cover.jpg", // hero/cover image logo: "https://cdn.example.com/courses/fat-loss-logo.png", // small logo image_url: "https://cdn.example.com/courses/fat-loss-card.jpg", // card thumbnail category_slug: "weight-loss", category_name: "Weight Loss", module_count: 4, lesson_count: 24, estimated_minutes: 120, // Enrollment fields (present when the customer has an enrollment): is_assigned: true, // customer is enrolled started: false, // enrollment has progress completed_content_ids: [101, 105, 108], // IDs of completed lessons progress_percent: 31, // 0–100 // Only on detail call (getCourseForCustomer, getCourseBySlug): modules: [ { id: 10, title: "Week 1 — Foundation", order: 1, contents: [ { id: 101, title: "Introduction", type: "video", // 'video' | 'audio' | 'text' content_url: "https://...", content_embed: "