Skip to main content

Overview

The Fanbasis integration provides payment-gated access to AdRecon. It consists of two endpoint groups:
  1. Admin endpoints (/api/admin/fanbasis) — Manage products, webhook registration, and view logs
  2. Webhook endpoint (/api/fanbasis/webhook) — Public webhook receiver for payment events
Fanbasis is a payment platform for digital products. This integration automatically provisions Supabase auth accounts when users purchase enabled offers.

Configuration

Set these environment variables:
VariablePurposeRequired
FANBASIS_API_KEYFanbasis API key for admin actionsYes (admin features)
SUPABASE_URLSupabase project URLYes
SUPABASE_SERVICE_ROLE_KEYServer-side auth keyYes
FANBASIS_MAGIC_LINK_REDIRECT_URLPost-login redirect URLNo (falls back to request host)
Webhook secret is stored in Supabase (integration_secrets table) after registration.

Admin Endpoints

All admin endpoints require:
Authorization: Bearer <admin_access_token>

GET /api/admin/fanbasis

Query Fanbasis integration status, offers, and webhook logs.

Query Parameters

action
string
required
Action to perform.Options:
  • status — Check API key and webhook secret configuration
  • offers — List enabled offers from database
  • webhook-log — Paginated webhook event log
page
number
Page number for webhook-log action (default: 1)
perPage
number
Items per page for webhook-log action (default: 25, max: 100)

Response Examples

{
  "apiKeySet": true,
  "webhookSecretSet": true
}

POST /api/admin/fanbasis

Perform admin actions.

Request Body

action
string
required
Action to perform.Options:
  • test_connection — Verify Fanbasis API key
  • sync_products — Fetch and sync all products from Fanbasis
  • toggle_offer — Enable/disable an offer
  • register_webhook — Register webhook subscription with Fanbasis
  • send_test_webhook — Trigger test event from Fanbasis
  • send_direct_test — Send synthetic test payload directly to webhook
  • manual_provision — Manually provision access for an email

Action: test_connection

Verify API key by making a test request to Fanbasis.
Request
{
  "action": "test_connection"
}
Response (Success)
{
  "ok": true
}
Response (Failure)
{
  "ok": false,
  "error": "Fanbasis API returned HTTP 401: Invalid API key"
}

Action: sync_products

Fetch all products from Fanbasis and upsert into fanbasis_enabled_offers table.
Syncing preserves the enabled flag for existing offers — only updates title/price.
Request
{
  "action": "sync_products"
}
Response
{
  "ok": true,
  "synced": 3
}
Sync Behavior (source:api/admin/fanbasis.js:180-274):
  1. Fetch all products via paginated /products endpoint (max 50 pages)
  2. For each product:
    • Use product ID as canonical service_id
    • Extract checkout session ID from payment_link URL
    • If checkout session ID differs from product ID, migrate old row to prevent duplicates
  3. Upsert with conflict resolution on service_id
  4. Newly synced offers default to enabled: false

Action: toggle_offer

Enable or disable an offer for webhook provisioning.
serviceId
string
required
Product/service ID to toggle
enabled
boolean
required
Enable (true) or disable (false) the offer
Request
{
  "action": "toggle_offer",
  "serviceId": "999999",
  "enabled": true
}
Response
{
  "ok": true
}

Action: register_webhook

Register a webhook subscription with Fanbasis.
Before registering, this action deletes existing subscriptions for the same endpoint URL to avoid stale secret mismatches (source:api/admin/fanbasis.js:319-342).
Request
{
  "action": "register_webhook"
}
Response
{
  "ok": true,
  "webhookUrl": "https://your-domain.vercel.app/api/fanbasis/webhook",
  "hasSecret": true,
  "eventTypes": [
    "payment.succeeded",
    "payment.failed",
    "payment.expired",
    "payment.canceled",
    "product.purchased",
    "subscription.created",
    "subscription.renewed",
    "subscription.completed",
    "subscription.canceled",
    "payment.refunded",
    "payment.dispute.opened"
  ]
}
Event Type Fallback (source:api/admin/fanbasis.js:344-384): The endpoint first attempts to register with all events (core + optional). If Fanbasis rejects optional events, it retries with core events only:
  • Core events: payment.succeeded, payment.failed, payment.expired, payment.canceled, product.purchased, subscription.created, subscription.renewed, subscription.completed, subscription.canceled
  • Optional events: payment.refunded, payment.dispute.opened

Action: send_test_webhook

Trigger a test event from Fanbasis to your webhook.
eventType
string
Event type to test (default: payment.succeeded)
Request
{
  "action": "send_test_webhook",
  "eventType": "payment.succeeded"
}
Response
{
  "ok": true,
  "subscriptionId": "12345",
  "eventType": "payment.succeeded",
  "fanbasisResponse": { "..." }
}

Action: send_direct_test

Send a synthetic test payload directly to your webhook endpoint (bypasses Fanbasis).
eventType
string
Event type to simulate (default: payment.succeeded)
email
string
Test email address (default: [email protected])
Request
{
  "action": "send_direct_test",
  "eventType": "payment.succeeded",
  "email": "[email protected]"
}
Response
{
  "ok": true,
  "webhookUrl": "https://your-domain.vercel.app/api/fanbasis/webhook",
  "eventType": "payment.succeeded",
  "email": "[email protected]",
  "webhookResponse": { "ok": true, "result": "user_created" }
}

Action: manual_provision

Manually grant access to a user (e.g., for comped accounts or failed webhook recovery).
email
string
required
Email address to provision
serviceId
string
Service ID to associate (optional)
logEntryId
number
Original webhook log entry ID (optional, for audit trail)
Request
{
  "action": "manual_provision",
  "email": "[email protected]",
  "serviceId": "999999"
}
Response
{
  "ok": true,
  "result": "user_created",
  "email": "[email protected]",
  "magicLinkSent": true
}
Provisioning Logic (source:api/admin/fanbasis.js:548-648):
  1. Search for existing user by email (scans up to 100 pages of 200 users each)
  2. If not found: create user with email_confirm: true
  3. If found and revoked: update metadata to restore access and remove ban
  4. If found and active: update metadata with new purchase timestamp
  5. Send magic link (OTP) for immediate login
  6. Log manual provision event to fanbasis_webhook_log
Result Types:
  • user_created — New account created
  • user_exists — Existing active user updated
  • user_reactivated — Previously revoked user restored

Webhook Endpoint

POST /api/fanbasis/webhook

Public endpoint that receives payment events from Fanbasis.
This endpoint is unauthenticated (no Bearer token required). Webhook payloads are validated via secret signature (when configured).

Request Body

Fanbasis sends webhook payloads in various formats. The endpoint normalizes these fields:
event_type
string
Event type (also checks type, event.type, data.event_type)
data
object
Event payload (structure varies by event type)
data.fan
object
Buyer information (also checks buyer)
data.fan.email
string
Buyer email address
data.fan.name
string
Buyer name
data.service
object
Product information (also checks item)
data.service.id
string
Product/service ID (also checks service_id, product_id)
data.service.title
string
Product title/name

Webhook Validation

Currently, the webhook endpoint does not enforce secret validation (source:api/fanbasis/webhook.js:286). All POST requests are processed.This is intentional to ensure no legitimate purchases are blocked. Secret storage is implemented for future validation.

Event Handling

Purchase Events
These events create or update user accounts:
  • payment.succeeded
  • product.purchased
  • subscription.created
  • subscription.renewed
Provisioning Flow (source:api/fanbasis/webhook.js:366-433):
  1. Extract buyer email and service ID from payload
  2. Validate offer is enabled (checks fanbasis_enabled_offers table)
  3. If offer disabled, check if product title contains “adrecon” (case-insensitive override)
  4. Find existing user by email or create new account
  5. Update user_metadata:
    {
      "source": "fanbasis",
      "fanbasis_service_id": "999999",
      "fanbasis_buyer_name": "John Doe",
      "fanbasis_access_revoked": false,
      "fanbasis_access_revoked_at": null,
      "fanbasis_revocation_reason": null,
      "fanbasis_last_purchase_at": "2026-03-02T14:30:00.000Z",
      "fanbasis_last_event_type": "payment.succeeded"
    }
    
  6. Send magic link (OTP) for immediate login
  7. Log event to fanbasis_webhook_log with result
Offer Validation Logic (source:api/fanbasis/webhook.js:94-128):
Offer Validation
// Extract all candidate IDs from payload
const candidateIds = [
  service.service_id,
  service.id,
  service.product_id,
  payload.checkout_session_id,
  extractIdFromPaymentLink(service.payment_link),
  // ... more variations
]

// Check if any ID matches enabled offer
const enabledOffers = await client
  .from('fanbasis_enabled_offers')
  .select('service_id, checkout_session_id')
  .eq('enabled', true)

const offerEnabled = candidateIds.some(id => 
  enabledOffers.some(offer => 
    offer.service_id === id || offer.checkout_session_id === id
  )
)

// Fallback: allow if title contains "adrecon"
if (!offerEnabled && hasAdReconTitle(payload)) {
  offerEnabled = true
}
Revocation Events
These events revoke access:
  • payment.refunded
  • payment.dispute.opened
  • subscription.canceled
  • subscription.completed
Revocation Flow (source:api/fanbasis/webhook.js:303-344):
  1. Find user by email
  2. If not found, log skipped_user_missing and return
  3. Update user_metadata:
    {
      "fanbasis_access_revoked": true,
      "fanbasis_access_revoked_at": "2026-03-02T14:30:00.000Z",
      "fanbasis_revocation_reason": "payment.refunded",
      "fanbasis_last_event_type": "payment.refunded"
    }
    
  4. Apply auth ban: ban_duration: "876000h" (100 years)
  5. Log event with result
Other Events
All other events are logged but do not modify user accounts.

Response

The webhook always returns 200 OK with a result code:
ok
boolean
Always true (prevents Fanbasis retries)
result
string
Processing result.Values:
  • user_created — New account created
  • user_exists — Existing account updated
  • user_reactivated — Previously revoked account restored
  • access_revoked — Access removed
  • skipped_no_email — No email in payload
  • skipped_offer_not_enabled — Offer not enabled
  • skipped_user_missing — User not found (revocation events only)
  • logged — Event logged without action
  • error — Processing failed

Example Webhook Payloads

{
  "event_type": "payment.succeeded",
  "data": {
    "fan": {
      "id": 12345,
      "email": "[email protected]",
      "name": "John Doe"
    },
    "service": {
      "id": 999999,
      "title": "AdRecon Lifetime Access",
      "payment_link": "https://checkout.fanbasis.com/482200"
    },
    "amount": 197,
    "currency": "USD",
    "payment_id": "pay_abc123"
  }
}

Database Schema

fanbasis_enabled_offers

Stores synced products and their enabled state.
Schema
CREATE TABLE fanbasis_enabled_offers (
  service_id TEXT PRIMARY KEY,
  title TEXT,
  price NUMERIC,
  enabled BOOLEAN DEFAULT false,
  checkout_session_id TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

fanbasis_webhook_log

Audit log for all webhook events.
Schema
CREATE TABLE fanbasis_webhook_log (
  id BIGSERIAL PRIMARY KEY,
  event_type TEXT,
  service_id TEXT,
  fan_email TEXT,
  payload JSONB,
  result TEXT,
  error_message TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

integration_secrets

Secure storage for webhook secrets.
Schema
CREATE TABLE integration_secrets (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);
The webhook secret is stored with key = 'fanbasis_webhook_secret'.

Complete Admin Integration Example

Fanbasis Admin Dashboard
import { useState, useEffect } from 'react'
import { useSupabaseClient } from '@/hooks/useSupabase'

export function FanbasisAdmin() {
  const [status, setStatus] = useState(null)
  const [offers, setOffers] = useState([])
  const [webhookLog, setWebhookLog] = useState([])
  const supabase = useSupabaseClient()
  
  const getToken = async () => {
    const { data: { session } } = await supabase.auth.getSession()
    return session.access_token
  }
  
  const fetchStatus = async () => {
    const token = await getToken()
    const response = await fetch('/api/admin/fanbasis?action=status', {
      headers: { 'Authorization': `Bearer ${token}` }
    })
    setStatus(await response.json())
  }
  
  const fetchOffers = async () => {
    const token = await getToken()
    const response = await fetch('/api/admin/fanbasis?action=offers', {
      headers: { 'Authorization': `Bearer ${token}` }
    })
    const { offers } = await response.json()
    setOffers(offers)
  }
  
  const syncProducts = async () => {
    const token = await getToken()
    const response = await fetch('/api/admin/fanbasis', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ action: 'sync_products' })
    })
    const { synced } = await response.json()
    alert(`Synced ${synced} products`)
    await fetchOffers()
  }
  
  const toggleOffer = async (serviceId, enabled) => {
    const token = await getToken()
    await fetch('/api/admin/fanbasis', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        action: 'toggle_offer',
        serviceId,
        enabled
      })
    })
    await fetchOffers()
  }
  
  const registerWebhook = async () => {
    const token = await getToken()
    const response = await fetch('/api/admin/fanbasis', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ action: 'register_webhook' })
    })
    const result = await response.json()
    alert(`Webhook registered: ${result.webhookUrl}`)
    await fetchStatus()
  }
  
  const sendTestWebhook = async () => {
    const token = await getToken()
    const response = await fetch('/api/admin/fanbasis', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        action: 'send_test_webhook',
        eventType: 'payment.succeeded'
      })
    })
    const result = await response.json()
    alert('Test webhook sent')
  }
  
  useEffect(() => {
    fetchStatus()
    fetchOffers()
  }, [])
  
  return (
    <div>
      <h1>Fanbasis Integration</h1>
      
      {status && (
        <div className="status">
          <p>API Key: {status.apiKeySet ? '✓ Configured' : '✗ Missing'}</p>
          <p>Webhook Secret: {status.webhookSecretSet ? '✓ Configured' : '✗ Missing'}</p>
        </div>
      )}
      
      <div className="actions">
        <button onClick={syncProducts}>Sync Products</button>
        <button onClick={registerWebhook}>Register Webhook</button>
        <button onClick={sendTestWebhook}>Send Test Event</button>
      </div>
      
      <h2>Offers</h2>
      <table>
        <thead>
          <tr>
            <th>Title</th>
            <th>Price</th>
            <th>Service ID</th>
            <th>Enabled</th>
          </tr>
        </thead>
        <tbody>
          {offers.map(offer => (
            <tr key={offer.service_id}>
              <td>{offer.title}</td>
              <td>${offer.price}</td>
              <td>{offer.service_id}</td>
              <td>
                <input
                  type="checkbox"
                  checked={offer.enabled}
                  onChange={(e) => toggleOffer(offer.service_id, e.target.checked)}
                />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

Best Practices

Sync before enabling

Always run “Sync Products” before enabling offers to ensure latest data

Test webhooks

Use “Send Test Event” to verify provisioning before going live

Monitor webhook log

Regularly check webhook log for failed provisions or errors

Manual provision recovery

Use manual provision to grant access if webhook fails

Troubleshooting

Webhook Not Receiving Events

  1. Check webhook registration: GET /api/admin/fanbasis?action=status
  2. Verify webhookSecretSet: true
  3. Check Fanbasis dashboard for webhook subscription status
  4. Send test event: POST /api/admin/fanbasis with action: send_test_webhook
  5. Check webhook log: GET /api/admin/fanbasis?action=webhook-log

User Not Provisioned After Purchase

  1. Check webhook log for event with buyer email
  2. Look for skipped_offer_not_enabled result — enable offer if needed
  3. Check for error result — fix underlying issue
  4. Use manual provision as recovery: POST /api/admin/fanbasis with action: manual_provision

Duplicate Offers After Sync

This occurs when Fanbasis changes checkout session IDs. The sync logic automatically:
  1. Detects old checkout-session-ID rows
  2. Transfers enabled state to product-ID row
  3. Deletes old row to prevent duplicates (source:api/admin/fanbasis.js:213-248)
No manual intervention needed.

Build docs developers (and LLMs) love