# Working with membership signup ## Introduction This guide explains how to integrate a membership signup flow using the Open API and the Universal Payment Component (UPC). This implementation requires careful orchestration of multiple API calls and payment sessions to handle both recurring and one-time payments. For general information on working with the Payment API, see [Working with Payment API](/apis/magicline/usecases/payment-api). ## Table of Contents 1. [Flow Overview](#flow-overview) 2. [Step 1: Fetch Membership Offers](#step-1-fetch-membership-offers) 3. [Step 2: Get Offer Details](#step-2-get-offer-details) 4. [Step 3: Collect Member Information](#step-3-collect-member-information) 5. [Step 4: Create Contract Preview](#step-4-create-contract-preview) 6. [Step 5: Payment Session Logic](#step-5-payment-session-logic) 7. [Step 6: Mount Universal Payment Component](#step-6-mount-universal-payment-component) 8. [Step 7: Session Persistence & Page Reload](#step-7-session-persistence--page-reload) 9. [Step 8: Final Contract Submission](#step-8-final-contract-submission) 10. [Common Gotchas & Best Practices](#common-gotchas--best-practices) ## Flow Overview The membership signup flow typically involves these stages: 1. **Fetch offers** – Retrieve available membership options from API 2. **Select offer & term** – Determine specific contract duration 3. **Collect member info** – Gather personal details (name, email, address, DOB) 4. **Generate preview** – Calculate pricing with discounts/vouchers 5. **Setup recurring payment** – Create MEMBER_ACCOUNT session & mount widget 6. **Setup upfront payment** – Create ECOM session & mount widget (if needed) 7. **Submit contract** – Finalize membership creation **Critical**: Two separate payment sessions are required: - **Session 1**: `MEMBER_ACCOUNT` scope – for recurring monthly payments (amount: 0) - **Session 2**: `ECOM` scope – for upfront/setup fees (amount: actual due amount) Each session must map to the same Finion Pay customer, which is achieved by passing the `finionPayCustomerId` from Session 1 into Session 2. ## Step 1: Fetch Membership Offers **Endpoint**: [GET /v1/memberships/membership-offers](/apis/magicline/openapi/openapi#operation/getMembershipOffers) **Purpose**: Retrieve all available membership offers that are configured by the studio under Settings / Offer Configuration / Open API. ### Example Request ### Response **Note**: The response is an array of `MembershipOffer` objects. ## Step 2: Get Offer Details **Endpoint**: [GET /v1/memberships/membership-offers/{offerId}](/apis/magicline/openapi/openapi#operation/getMembershipOfferById) **Purpose**: Fetch complete offer details including `flatFees` (setup/registration fees). ### Example Request ### Response ### Critical: Understanding `flatFees` - **`flatFees`**: Array of one-time fees (registration, setup, admin fees) - **`starterPackage: true`**: Fee is due at signup (included in upfront payment) - **Initial payment estimate**: Sum of all `flatFees` with `starterPackage: true` **Note**: The `flatFees` array provides an **estimate** of upfront costs. The **authoritative source** for the exact amount due at signup is the **preview API** response (`dueOnSigningAmount`). Always use the preview API before creating payment sessions. ## Step 3: Collect Member Information Gather required personal details from the user: ### Required Fields - **Personal Info**: - `firstName` (string) – Required - `lastName` (string) – Required - `email` (string, valid email format) – Required - `dateOfBirth` (string, ISO date format: `YYYY-MM-DD`) – Required - `phoneNumberMobile` (string, optional but recommended) - **Address**: - `street` (string, including house number) – Required - `city` (string) – Required - `zipCode` (string) – Required - `countryCode` (string, ISO 3166-1 alpha-2, e.g., "DE") – Required - **Language**: - `language.languageCode` (string, ISO 639-1, e.g., "de") – Required - `language.countryCode` (string, ISO 3166-1, e.g., "DE") – Optional - **Contract Details**: - `startDate` (string, ISO date format: `YYYY-MM-DD`) – Required - `preuseDate` (string, ISO date, optional – allows gym access before contract start) - `voucherCode` (string, optional – for discounts) ## Step 4: Create Contract Preview **Endpoint**: [POST /v1/memberships/signup/preview](/apis/magicline/openapi/openapi#operation/postSignupPreview) **Purpose**: Calculate exact pricing including discounts, age-based pricing, vouchers, and the **authoritative `dueOnSigningAmount`**. ### When to Call Preview API The preview API should be called: 1. **Initially** – After selecting a membership term (optional, can be conducted with dummy data) 2. **On voucher application** – When voucher code is added or removed 3. **On date of birth change** – For age-based pricing adjustments (debounced) 4. **On contract start date change** – May affect promotional periods (debounced) 5. **On pre-use date change** – May affect initial billing (debounced) 6. **Before payment step** – Final pricing confirmation with complete member information **Debouncing**: For fields that trigger preview updates (DOB, dates), implement debounce to avoid excessive API calls. ### Example Request ### Request Body ### Response ### Critical: `dueOnSigningAmount` **This is the authoritative amount for upfront payment.** - **Location**: `preview.paymentPreview.dueOnSigningAmount.amount` - **Purpose**: Exact amount to charge immediately (setup fees + first payment if applicable) - **Usage**: Use this value when creating the ECOM payment session (Session 2) - **Configuration in ERP**: Go to a membership offer → Offer Options → Set `Require Initial Payment for Open API sign-ups` - **Calculation**: This amount is calculated based on the rate / term configuration. The amount contains all claims that are due on the contract start date. If a pre-use is configured at a price, this will be included. For more reference, refer to the support documentation. ## Step 5: Payment Session Logic ### Understanding Payment Scenarios Based on the preview API response, determine which payment sessions are needed: ```javascript function analyzePaymentNeeds(preview) { const dueOnSigning = preview.paymentPreview?.dueOnSigningAmount?.amount || 0; const totalContractVolume = preview.contractVolumeInformation?.totalContractVolume?.amount || 0; let needsRecurring = true; let needsUpfront = false; // Scenario 1: Full payment upfront (no recurring payments) if (dueOnSigning > 0 && dueOnSigning === totalContractVolume) { needsRecurring = false; needsUpfront = true; } // Scenario 2: No upfront payment (recurring only) else if (dueOnSigning === 0) { needsRecurring = true; needsUpfront = false; } // Scenario 3: Both payments needed else if (dueOnSigning > 0) { needsRecurring = true; needsUpfront = true; } return { needsRecurring, needsUpfront, upfrontAmount: dueOnSigning }; } ``` ### Session 1: Recurring Payment (MEMBER_ACCOUNT) **Create this session FIRST** to obtain the `finionPayCustomerId`. For detailed information on creating payment sessions, see [Creating a User Payment Session](/apis/magicline/usecases/payment-api#creating-a-user-payment-session). #### Example Request #### Request Body **For membership recurring payments**: - Set `amount` to `0` (MEMBER_ACCOUNT sessions store payment method only) - Set `scope` to `"MEMBER_ACCOUNT"` - Set `referenceText` to something like `"Membership Contract Recurring Payment"` - Set `permittedPaymentChoices` to the `allowedPaymentChoices` from the offer details - Do NOT include `finionPayCustomerId` for a new customer #### Response #### Critical Points 1. **Amount is always 0** – MEMBER_ACCOUNT sessions store payment method only 2. **`referenceText` is required** – This appears on bank statements 3. **No `finionPayCustomerId` in request** – This is the first session for a new customer 4. **For `permittedPaymentChoices`** use `allowedPaymentChoices` list from offer details endpoint 5. **Store the `finionPayCustomerId` from response** – Required for Session 2 6. **Store the `token`** – Required for widget remounting after page reload ### Session 2: Upfront Payment (ECOM) **Create this session SECOND** using the `finionPayCustomerId` from Session 1. #### Example Request #### Request Body **For membership upfront payments**: - Set `amount` to the `dueOnSigningAmount` from the preview API response - Set `scope` to `"ECOM"` - Set `referenceText` to something like `"Membership Setup Fee"` - Omit `permittedPaymentChoices` to expose all studio-configured payment methods for ECOM scope - **MUST include `finionPayCustomerId`** from Session 1 response to link both sessions #### Response #### Critical Points 1. **Amount is the actual due amount** – Use `dueOnSigningAmount` from preview 2. **`referenceText` is required** – This appears on bank statements 3. **MUST include `finionPayCustomerId`** – Links both sessions to same customer 4. **Omit `permittedPaymentChoices`** – Recommended to expose all studio-configured payment methods for ECOM scope 5. **Store the `token`** – Required for widget remounting after page reload ## Step 6: Mount Universal Payment Component For detailed widget integration instructions, see [Payment Widget Integration Guide](/apis/magicline/usecases/payment-api#payment-widget-integration-guide). **Use either the preview or stable version URI of the widget** ### Load Widget Library ```html ``` Or dynamically: ```javascript function loadWidgetLibrary() { return new Promise((resolve, reject) => { if (window.paymentWidget) { resolve(); return; } const script = document.createElement('script'); script.src = 'https://widget.dev.payment.sportalliance.com/widget.js'; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } ``` ### Mount Recurring Payment Widget ```javascript // Ensure DOM element exists const containerEl = document.getElementById('recurring-payment-container'); if (!containerEl) { throw new Error('Container element not found'); } // Clear any existing content containerEl.innerHTML = ''; // Initialize widget const recurringWidget = window.paymentWidget.init({ userSessionToken: recurringSession.token, container: containerEl, // Can be element or element ID countryCode: 'DE', locale: 'en-US', environment: 'test', // or 'sandbox', 'live' onSuccess: (paymentRequestToken, paymentInstrumentDetails) => { console.log('Recurring payment setup successful'); console.log('Payment Request Token:', paymentRequestToken); console.log('Payment Method:', paymentInstrumentDetails); // Store token for final submission recurringPaymentToken = paymentRequestToken; // Proceed to upfront payment (if needed) if (needsUpfront) { mountUpfrontWidget(); } }, onError: (error) => { console.error('Recurring payment error:', error); // Handle error appropriately } }); ``` ### Mount Upfront Payment Widget ```javascript const containerEl = document.getElementById('upfront-payment-container'); if (!containerEl) { throw new Error('Container element not found'); } containerEl.innerHTML = ''; const upfrontWidget = window.paymentWidget.init({ userSessionToken: upfrontSession.token, container: containerEl, countryCode: 'DE', locale: 'en-US', environment: 'test', onSuccess: (paymentRequestToken, paymentInstrumentDetails) => { console.log('Upfront payment successful'); console.log('Payment Request Token:', paymentRequestToken); console.log('Transaction:', paymentInstrumentDetails); // Store token for final submission upfrontPaymentToken = paymentRequestToken; // Mark upfront payment as complete upfrontPaymentComplete = true; }, onError: (error) => { console.error('Upfront payment error:', error); // Handle error appropriately } }); ``` ### Widget Cleanup **Always destroy widgets before unmounting components**: ```javascript // Before component unmounts or page navigation function cleanup() { if (recurringWidget && typeof recurringWidget.destroy === 'function') { recurringWidget.destroy(); } if (upfrontWidget && typeof upfrontWidget.destroy === 'function') { upfrontWidget.destroy(); } } // In React/Svelte/Vue lifecycle onDestroy(() => { cleanup(); }); ``` ## Step 7: Session Persistence & Page Reload ### The Problem Payment widgets render iframes with external payment forms (credit card inputs, bank redirects, etc.). If the user: - Refreshes the page - Navigates away and back - Browser session times out ...the widget state is lost, forcing the user to re-enter payment details. ### The Solution: Session Token Persistence Store session tokens and remount widgets on page load. For more details, see [Handling Redirects](/apis/magicline/usecases/payment-api#handling-redirects). ### Implementation #### 1. Store Session Data ```javascript // After creating payment sessions, store tokens const sessionData = { // Session tokens for remounting widgets recurringSessionToken: recurringSession.token, upfrontSessionToken: upfrontSession.token, // Payment tokens (if already completed) recurringPaymentToken: recurringPaymentToken || null, upfrontPaymentToken: upfrontPaymentToken || null, // FinionPay customer mapping finionPayCustomerId: recurringSession.finionPayCustomerId, // Other state preview: preview, personalInfo: personalInfo, selectedOfferId: offer.id, selectedTermId: term.id, // Timestamp for TTL timestamp: Date.now() }; // Store in sessionStorage (cleared on tab close) sessionStorage.setItem('contractFlowState', JSON.stringify(sessionData)); ``` #### 2. Restore Session on Page Load ```javascript function restoreSession() { const storedData = sessionStorage.getItem('contractFlowState'); if (!storedData) return null; const data = JSON.parse(storedData); // Check TTL (e.g., 1 hour) const ONE_HOUR = 60 * 60 * 1000; if (Date.now() - data.timestamp > ONE_HOUR) { sessionStorage.removeItem('contractFlowState'); return null; } return data; } ``` #### 3. Remount Widgets ```javascript async function remountWidgets() { const session = restoreSession(); if (!session) return; // Remount recurring widget if session exists if (session.recurringSessionToken) { const containerEl = document.getElementById('recurring-payment-container'); if (!containerEl) return; containerEl.innerHTML = ''; recurringWidget = window.paymentWidget.init({ userSessionToken: session.recurringSessionToken, container: containerEl, countryCode: 'DE', locale: 'en-US', environment: 'test', onSuccess: (paymentRequestToken, paymentInstrumentDetails) => { recurringPaymentToken = paymentRequestToken; updateSessionStorage({ recurringPaymentToken }); }, onError: (error) => { console.error('Remount error:', error); } }); // If payment was already completed, restore token if (session.recurringPaymentToken) { recurringPaymentToken = session.recurringPaymentToken; recurringPaymentComplete = true; } } // Remount upfront widget if session exists if (session.upfrontSessionToken) { // Similar logic... } } // Call on component mount onMount(async () => { await loadWidgetLibrary(); await remountWidgets(); }); ``` #### 4. Update Session Storage ```javascript function updateSessionStorage(updates) { const storedData = sessionStorage.getItem('contractFlowState'); if (!storedData) return; const data = JSON.parse(storedData); const updatedData = { ...data, ...updates, timestamp: Date.now() }; sessionStorage.setItem('contractFlowState', JSON.stringify(updatedData)); } ``` ### Session Token Expiry - **Token validity**: Check `tokenValidUntil` from API response - **Typical duration**: 15-30 minutes - **Handle expiry**: If token expired, create new session and remount widget ```javascript function isTokenValid(tokenValidUntil) { const expiryTime = new Date(tokenValidUntil).getTime(); return Date.now() < expiryTime; } // Before remounting if (!isTokenValid(session.recurringSession.tokenValidUntil)) { // Create new session const newSession = await createPaymentSession({ /* ... */ }); updateSessionStorage({ recurringSessionToken: newSession.token }); } ``` ## Step 8: Final Contract Submission **Endpoint**: [POST /v1/memberships/signup](/apis/magicline/openapi/openapi#operation/signupMembership) ### Example Request ### Request Body ### Critical: Two Payment Tokens The API expects two separate payment tokens in specific locations: 1. **`customer.paymentRequestToken`**: Recurring payment token (MEMBER_ACCOUNT) - Used for monthly membership fees - From Session 1 `onSuccess` callback 2. **`contract.initialPaymentRequestToken`**: Upfront payment token (ECOM) - Used for immediate payment (setup fees) - From Session 2 `onSuccess` callback **Never use the same token for both fields.** Each widget session produces a unique token. ### Response **Note**: The API returns the `customerId` of the newly created membership. You can use this ID to fetch additional customer details if needed via other endpoints. ### Error Handling ```javascript try { const result = await createMembership(signupRequest); // Success - clear session storage sessionStorage.removeItem('contractFlowState'); // Handle successful signup console.log('Membership created successfully:', result.customerId); // Navigate to next step or confirmation return { success: true, customerId: result.customerId }; } catch (error) { console.error('Membership signup failed:', error); if (error.status === 400) { // Validation error - check error.validationErrors for field-specific issues return { success: false, type: 'validation', errors: error.validationErrors }; } else if (error.status === 409) { // Duplicate customer - customer with this email may already exist return { success: false, type: 'duplicate_customer', message: error.message }; } else { // Generic error return { success: false, type: 'generic', message: 'Failed to create membership' }; } } ``` ## Common Gotchas & Best Practices ### 1. Payment Amount Format **Always use decimal format, NOT cents.** ```javascript // ❌ WRONG const request = { amount: Math.round(49.99 * 100) }; // 4999 - will charge €4999! // ✅ CORRECT const request = { amount: 49.99 }; // Decimal format ``` ### 2. FinionPay Customer ID Mapping ```javascript // ❌ WRONG - Creating both sessions without customer ID const session1 = await createSession({ scope: 'MEMBER_ACCOUNT' }); const session2 = await createSession({ scope: 'ECOM' }); // Missing finionPayCustomerId // ✅ CORRECT - Linking sessions const session1 = await createSession({ scope: 'MEMBER_ACCOUNT' }); const customerId = session1.finionPayCustomerId; const session2 = await createSession({ scope: 'ECOM', finionPayCustomerId: customerId }); ``` ### 3. Preview API - Authoritative Source ```javascript // ❌ WRONG - Using offer flatFees for payment amount const upfrontAmount = offer.terms[0].flatFees.reduce((sum, fee) => sum + fee.paymentFrequency.price.amount, 0 ); // ✅ CORRECT - Using preview API const preview = await createContractPreview(/* ... */); const upfrontAmount = preview.paymentPreview.dueOnSigningAmount.amount; ``` ### 4. Widget Container Management ```javascript // ❌ WRONG - Not clearing container before remount const widget = window.paymentWidget.init({ container: containerEl }); // ✅ CORRECT - Clear container first containerEl.innerHTML = ''; const widget = window.paymentWidget.init({ container: containerEl }); ``` ### 5. Token Storage Security ```javascript // ❌ WRONG - Storing in localStorage (persists across sessions) localStorage.setItem('paymentTokens', JSON.stringify(tokens)); // ✅ CORRECT - Use sessionStorage (cleared on tab close) sessionStorage.setItem('contractFlowState', JSON.stringify(state)); // ✅ BETTER - Clear after successful submission sessionStorage.removeItem('contractFlowState'); ``` ### 6. Debouncing Preview Updates ```javascript // ❌ WRONG - Calling preview on every keystroke inputField.addEventListener('input', () => { fetchPreview(); // Excessive API calls }); // ✅ CORRECT - Debounce API calls let debounceTimer; inputField.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => fetchPreview(), 500); }); ``` ### 7. Payment Choice Restrictions ```javascript // ❌ WRONG - Using same payment choices for both sessions const paymentChoices = ['SEPA', 'CREDIT_CARD', 'CASH']; createSession({ scope: 'MEMBER_ACCOUNT', permittedPaymentChoices: paymentChoices }); createSession({ scope: 'ECOM', permittedPaymentChoices: paymentChoices }); // ✅ CORRECT - Use offer choices for MEMBER_ACCOUNT, omit for ECOM const recurringChoices = offer.allowedPaymentChoices || ['SEPA', 'BACS', 'CREDIT_CARD', 'CASH', 'BANK_TRANSFER']; await createSession({ scope: 'MEMBER_ACCOUNT', permittedPaymentChoices: recurringChoices }); // For ECOM, omit permittedPaymentChoices to expose all studio-configured payment methods await createSession({ scope: 'ECOM', // No permittedPaymentChoices - allows all payment methods configured for ECOM in studio }); ``` ### 8. Widget Lifecycle Management ```javascript // ❌ WRONG - Not destroying widget before navigation navigate('/next-page'); // Widget iframe still active in background // ✅ CORRECT - Always cleanup function cleanup() { if (widget && typeof widget.destroy === 'function') { widget.destroy(); } } // Before navigation cleanup(); navigate('/next-page'); // In framework lifecycle hooks onBeforeUnmount(() => cleanup()); ``` ### 9. Error Recovery ```javascript // ❌ WRONG - No recovery from failed session creation const session = await createSession({ /* ... */ }); mountWidget(session.token); // ✅ CORRECT - Handle errors and allow retry try { const session = await createSession({ /* ... */ }); mountWidget(session.token); } catch (error) { console.error('Failed to create payment session:', error); // Store error state and provide retry mechanism return { success: false, error: error.message, canRetry: true }; } ``` ### 10. Preview Before Payment ```javascript // ❌ WRONG - Creating payment session without final preview collectPersonalInfo(); createPaymentSessions(); // Amount might be wrong // ✅ CORRECT - Always fetch final preview first collectPersonalInfo(); const preview = await fetchPreview(); // Get final amount const upfrontAmount = preview.paymentPreview.dueOnSigningAmount.amount; createPaymentSessions({ upfrontAmount }); ``` ### 11. Required Fields ```javascript // ❌ WRONG - Missing referenceText const sessionRequest = { amount: 0, scope: "MEMBER_ACCOUNT" }; // ✅ CORRECT - Include all required fields const sessionRequest = { amount: 0, scope: "MEMBER_ACCOUNT", referenceText: "Membership Contract Recurring Payment" // Required! }; ``` ## Summary This integration requires careful orchestration of multiple API calls and payment sessions. Key points: 1. **Preview API is authoritative** – Always use `dueOnSigningAmount` for upfront payment 2. **Two separate sessions** – MEMBER_ACCOUNT (recurring) and ECOM (upfront) 3. **Customer ID mapping** – Pass `finionPayCustomerId` from Session 1 to Session 2 4. **Session persistence** – Store tokens for widget remounting after page reload 5. **Two payment tokens** – Store both tokens and submit in correct fields: - `customer.paymentRequestToken` for recurring payments - `contract.initialPaymentRequestToken` for upfront payments 6. **Amount format** – Always use decimal format (not cents) 7. **Required fields** – Include `referenceText` in all payment session requests 8. **Widget cleanup** – Always destroy widgets before unmounting 9. **Response structure** – API returns only `{ customerId: number }` Following this guide ensures a robust membership signup flow that handles edge cases, maintains state across page reloads, and correctly processes both recurring and upfront payments. ## Relevant Endpoints - [GET /v1/memberships/membership-offers](/apis/magicline/openapi/openapi#operation/getMembershipOffers) - [GET /v1/memberships/membership-offers/{offerId}](/apis/magicline/openapi/openapi#operation/getMembershipOfferById) - [POST /v1/memberships/signup/preview](/apis/magicline/openapi/openapi#operation/postSignupPreview) - [POST /v1/memberships/signup](/apis/magicline/openapi/openapi#operation/signupMembership) - [POST /v1/payments/user-session](/apis/magicline/openapi/openapi#operation/userSession) For payment-related endpoints and concepts, see [Working with Payment API](/apis/magicline/usecases/payment-api).