You can use your own payment gateway (or manual billing) and still get the full Web2Wave stack: analytics, A/B tests, pixel integrations (Facebook, TikTok, etc.), email sequences, webhooks, and cabinet subscription lists. Web2Wave doesn’t process the charge—you do. You only tell Web2Wave when a subscription or invoice was created, via API or JS. In the system this is payment_system: 8 (Custom).
Prerequisites: Create prices first
You need at least one Price (and its Plan) in the project. Web2Wave uses it for conversion events, amounts, and paywall display.
How to create a Custom price
- Cabinet → Prices → Create new price (or open
/cabinet/prices/custom-price). - Set Payment system = Custom, Project, Livemode, Name, External code ID, Currency, Amount, Period (and interval), Double purchase behaviour as needed.
- Save. Use the new price_id (and plan) in API/JS.
If Create new price is missing, your project isn’t enabled for custom prices—ask your account admin.
Integration options
A. Embedded (iframe)
Put your payment page inside the quiz/paywall (e.g. iframe). When payment succeeds, run w2w.createSubscription(...) on the same Web2Wave page and send conversion events with the returned event_name / event_properties via w2w.logEvent(). User never leaves Web2Wave.
Setup on a Web2Wave paywall
- Set the paywall Main Payment System to Custom and attach Custom prices to it.
- In the paywall editor, add an HTML block to one of the popups (e.g. the payment popup).
- In that block, embed an iframe (e.g.
id="custom-payment-iframe") pointing at your payment page. You will update itssrcwhen the user picks a price. - Use
w2w.onEvent("price_selected", ...)to react when the user selects a price. This event is fired by the paywall withgetActivePriceInfo()(frompayment-page.js). When the price changes, set the iframe URL with query params:token= your custom token oruser_id,sku=price.id(so your payment page knows who is paying and which price to charge). - Your payment page (inside the iframe) should postMessage to the parent when payment finishes: e.g.
{ type: 'PAYMENT_SUCCESS', orderId: '...', amount: 29.99 }or{ type: 'PAYMENT_REJECTED', message: '...' }. - In the parent (paywall) page, listen for message: on PAYMENT_SUCCESS call
w2w.createSubscription(...), then in the callback send events withw2w.logEvent(event_name, event_properties)and redirect to the after-pay URL; on PAYMENT_REJECTED show an error (e.g.alert).
Example (simplified): iframe + price_selected → update iframe URL; message → create subscription, send events, redirect or show error.
<!-- Embed your payment page in the paywall popup -->
<iframe id="custom-payment-iframe" src="" width="100%" height="600"></iframe>
<script>
var iframe = document.getElementById('custom-payment-iframe');
var currentPriceInfo = null;
// When user selects a price in the paywall, update iframe URL with token and sku
w2w.onEvent('price_selected', function (data) {
currentPriceInfo = data;
if (currentPriceInfo && iframe) {
iframe.src =
'https://your-payment-page.com/checkout?token=' +
encodeURIComponent(window.yourCustomToken || window.user_id || '') +
'&sku=' +
encodeURIComponent((currentPriceInfo && (currentPriceInfo.price_id || currentPriceInfo.id)) || '');
}
});
// Handle postMessage from your payment page (inside iframe) when payment finishes
function onMessage(event) {
if (!event.data || !event.data.type) return;
if (event.data.type === 'PAYMENT_SUCCESS') {
// Tell Web2Wave about the subscription so analytics, pixels, emails work
w2w.createSubscription(
{
pay_system_id: event.data.orderId || ('order_' + Date.now()),
price_id: currentPriceInfo.id,
amount: currentPriceInfo.amount,
currency: currentPriceInfo.currency,
subscription_invoices: [{ amount: currentPriceInfo.amount }]
},
function (res) {
if (res.event_name) {
// Send conversion events to Meta/analytics/etc (one event per name for deduplication)
res.event_name.split(',').forEach(function (eventName) {
eventName = eventName.trim();
if (!eventName) return;
w2w.logEvent(eventName, Object.assign({}, res.event_properties, {
eventID: window.user_id + '_' + eventName + '_' + res.data.id + '_0'
}));
});
}
// Redirect to after-payment page; short delay so events are not lost
setTimeout(function () {
window.location.href = getPaymentUrl(window.after_pay);
}, 500);
},
function (xhr) {
w2w.showToast('Subscription creation failed: ' + (xhr.responseJSON?.error_msg));
}
);
} else if (event.data.type === 'PAYMENT_REJECTED') {
w2w.showToast(event.data.message);
}
}
window.addEventListener('message', onMessage);
</script>Example: Xsolla Pay Station iframe
On price change, get a Xsolla token (using the project’s price_id or price_external_id), then set the iframe src to the Pay Station URL. Requires Xsolla configured in Cabinet → Project → Payment systems. Token endpoint: POST /api/xsolla/token (same origin, send api_key header).
<!-- Xsolla Pay Station iframe: add to paywall HTML block -->
<iframe
id="xsolla-payment-iframe"
src=""
width="100%"
height="1000"
allow="clipboard-read; clipboard-write; payment"
></iframe>
<script>
(function () {
var iframe = document.getElementById('xsolla-payment-iframe');
var currentPriceInfo = null;
// Build Xsolla Pay Station URL from token (sandbox vs live)
function getPayStationUrl(token, sandbox) {
var host = sandbox
? 'https://sandbox-secure.xsolla.com'
: 'https://secure.xsolla.com';
return host + '/paystation4/?token=' + encodeURIComponent(token);
}
// On price selection: request Xsolla token from our API, then load Pay Station in iframe
w2w.onEvent('price_selected', function (data) {
currentPriceInfo = data;
if (!currentPriceInfo || !iframe) return;
var body = {
user_id: String(window.user_id || ''),
email: String((window.user_properties && window.user_properties.email) || ''),
price_external_id: currentPriceInfo.external_id,
};
iframe.src = '';
var tokenUrl = '/api/xsolla/token';
var testProjectId = window.test_project_id || (window.location.search && new URLSearchParams(window.location.search).get('test_project_id'));
if (testProjectId) tokenUrl += '?test_project_id=' + encodeURIComponent(testProjectId);
fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body)
})
.then(function (res) { return res.json().then(function (data) { return { ok: res.ok, data: data }; }); })
.then(function (result) {
if (!result.ok) {
throw new Error(result.data.message || 'Token request failed');
}
var token = result.data.token;
var sandbox = result.data.sandbox !== false;
iframe.src = getPayStationUrl(token, sandbox);
})
.catch(function (err) {
w2w.showToast('Xsolla token error: ' + (err.message || 'unknown'));
});
});
// Xsolla Pay Station sends postMessage with JSON: { command: string, data?: object, ... }.
// Success: order-status (order status = done), open-status-success.
// Failure: open-status-error, error (data.code, data.message), show-error-page.
// See: https://developers.xsolla.com/payment-ui-and-flow/features/paystation-analytics/
function onMessage(event) {
var raw = event.data;
if (raw == null) return;
var d = typeof raw === 'string' ? (function () { try { return JSON.parse(raw); } catch (e) { return null; } })() : raw;
if (!d || !d.command) return;
var cmd = d.command;
var payload = d.data || {};
// Payment succeeded: create subscription in Web2Wave and send conversion events
if (cmd === 'order-status' || cmd === 'open-status-success') {
var paySystemId = payload.order_id != null ? String(payload.order_id) : (payload.purchase_invoice_id != null ? 'xsolla_' + payload.purchase_invoice_id : 'order_' + Date.now());
w2w.createSubscription({
pay_system_id: paySystemId,
price_id: currentPriceInfo.price_id || currentPriceInfo.id,
amount: currentPriceInfo.amount,
currency: currentPriceInfo.currency,
subscription_invoices: [{ amount: currentPriceInfo.amount }]
}, function (res) {
if (res.event_name) {
res.event_name.split(',').forEach(function (eventName) {
eventName = eventName.trim();
if (!eventName) return;
w2w.logEvent(eventName, Object.assign({}, res.event_properties, {
eventID: (window.user_id || '') + '_' + eventName + '_' + res.data.id + '_0'
}));
});
}
setTimeout(function () {
window.location.href = typeof getPaymentUrl === 'function' ? getPaymentUrl(window.after_pay) : (window.after_pay || '/');
}, 500);
}, function (xhr) {
w2w.showToast('Subscription creation failed: ' + (xhr.responseJSON && xhr.responseJSON.error_msg));
});
return;
}
// Payment failed: show error to user
if (cmd === 'open-status-error' || cmd === 'error' || cmd === 'show-error-page') {
var msg = (payload.message || payload.value || 'Payment failed');
w2w.showToast(msg);
}
}
// Register message listener (avoid duplicates when script runs again)
var messageHandlerKey = '__w2wXsollaOnMessage';
if (window[messageHandlerKey]) {
window.removeEventListener('message', window[messageHandlerKey]);
}
window[messageHandlerKey] = onMessage;
window.addEventListener('message', onMessage);
})();
</script>
B. External payment (user leaves to your site/gateway)
After payment you can:
- B1. Backend: Your server calls POST /api/subscription (and optionally POST /api/subscription/invoice) with api_key. No redirect needed.
- B2. Redirect back: Send the user to a Web2Wave “after pay” page (thank-you URL on your quiz/paywall domain). There, JS calls
w2w.createSubscription(...)(e.g. from URL params or your API) andw2w.logEvent()with the response—same flow as A, conversion on same domain. - B3. Web2Wave JS SDK on the external site: Keep the user on your checkout / thank-you page. Load
web2wave-sdk.min.js, passuser_idfrom the quiz, and callweb2wave.purchase(...). web2wave records the event and sends it to Meta / TikTok via Conversion API — no ad pixels on the external website. Combine with B1 if you also need a subscription row in the cabinet.
Frontend (JS)
Use relative URLs (same origin as quiz/paywall). Subscriptions: w2w.createSubscription(). Invoices: POST /api/subscription/invoice (fetch or $.ajax).
Create subscription (w2w)
Response includes event_name and event_properties—send them with w2w.logEvent() so analytics, pixels, and emails work like with Stripe. If event_name is comma-separated (e.g. "Subscribe,StartTrial"), send one event per name with its own eventID for deduplication:
w2w.createSubscription(
{
// optional; default 8 (Custom)
payment_system: 8,
pay_system_id: 'order_' + Date.now(),
plan_id: 'my_plan',
price_id: 123,
amount: 2999,
currency: 'USD',
// optional; default window.user_id
customer: 'cust_' + window.user_id,
// optional; default window.user_id
user_id: window.user_id,
// optional; default window.user_properties?.email
user_email: window.user_properties?.email,
// optional; default window.user_visit_id
user_visit_id: window.user_visit_id,
subscription_invoices: [{ amount: 29.99 }]
},
function (res) {
console.log('Created', res.data);
w2w.set('subscription_id', res.data.id);
if (res.event_name && res.event_properties) {
var userId = res.data.user_id || window.user_id || '';
var subId = res.data.id;
var eventNames = res.event_name.split(',').map(function (s) { return s.trim(); });
eventNames.forEach(function (eventName) {
if (!eventName) return;
var props = Object.assign({}, res.event_properties, {
eventID: userId + '_' + eventName + '_' + subId + '_0'
});
w2w.logEvent(eventName, props);
});
}
},
function (xhr, status, err) {
console.error('Error', xhr.responseJSON?.error_msg || err);
}
);Minimal (defaults: payment_system = 8, user_id / customer / user_email / user_visit_id from window):
w2w.createSubscription({
pay_system_id: 'order_123',
plan_id: 'my_plan',
price_id: 123,
amount: 2999,
currency: 'USD'
});Create invoice (fetch)
fetch('/api/subscription/invoice', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api_key': window.project?.api_key
},
body: JSON.stringify({
subscription_id: 12345,
amount: 19.99
})
})
.then(r => r.json())
.then(data => {
if (data.success) console.log('Invoice created', data.data);
else console.error(data.error_msg);
});With jQuery:
$.ajax({
url: '/api/subscription/invoice',
type: 'POST',
contentType: 'application/json',
headers: { api_key: window.project?.api_key },
data: JSON.stringify({ subscription_id: 12345, amount: 19.99 })
}).done(function (res) {
if (res.success) console.log('Invoice', res.data);
}).fail(function (xhr) {
console.error(xhr.responseJSON?.error_msg);
});Backend (API)
Send api_key in header or query to the full project URL (e.g. https://your-project.web2wave.com).
Create subscription
POST /api/subscription
Minimal (defaults: status = active). price_id must exist in the project:
{
"payment_system": 8,
"pay_system_id": "order_123",
"plan_id": "my_plan",
"price_id": 123,
"amount": 2999,
"currency": "USD",
"customer": "customer_ref_456"
}With user and optional invoice (invoice defaults: status = Paid, date = now, amount_usd = amount). Omit real_payment to derive from price plan livemode:
{
"payment_system": 8,
"pay_system_id": "order_123",
"plan_id": "my_plan",
"price_id": 123,
"amount": 2999,
"currency": "USD",
"customer": "customer_ref_456",
"user_id": "{USER_ID}",
"user_email": "[email protected]",
"user_visit_id": "{USER_VISIT_ID}",
"subscription_invoices": [
{
"amount": 29.99
}
]
}Full invoice item (all optional except amount):
{
"invoice_id": "in_abc",
"status": 1,
"date": "2025-02-24 12:00:00",
"amount": 29.99,
"amount_usd": 29.99,
"is_refund": false
}cURL:
curl -X POST "https://YOUR_PROJECT_DOMAIN/api/subscription" \
-H "Content-Type: application/json" \
-H "api_key: YOUR_API_KEY" \
-d '{
"payment_system": 8,
"pay_system_id": "order_123",
"plan_id": "my_plan",
"price_id": 123,
"amount": 2999,
"currency": "USD",
"customer": "cust_1",
"subscription_invoices": [{ "amount": 29.99 }]
}'Response (201): Includes event_name and event_properties—send them with w2w.logEvent() so pixels and emails fire. If event_name is comma-separated, send one event per name with eventID: user_id + '_' + eventName + '_' + subscription_id + '_0'.
{
"success": 1,
"data": {
"id": 12345,
"user_id": "...",
"payment_system": 8,
"pay_system_id": "order_123",
"status": "active",
"amount": 2999,
"currency": "USD",
"invoices_new": [
{
"id": 1,
"subscription_id": 12345,
"status": "Paid",
"amount": 29.99,
"amount_usd": 29.99,
"date": "2025-02-24 12:00:00"
}
]
},
"event_name": "Subscribe",
"event_properties": {
"currency": "USD",
"value": "29.99",
"price_id": 123,
"has_subscription": "1",
"paywall_id": "456",
"is_skip_send_events": false,
"subscription_id": 12345,
"price_id_internal": 123
}
}Create invoice (for existing subscription)
POST /api/subscription/invoice
Minimal (defaults: status = Paid, date = now, amount_usd = amount):
{
"subscription_id": 12345,
"amount": 19.99
}With all fields:
{
"subscription_id": 12345,
"invoice_id": "ch_xyz",
"status": 1,
"date": "2025-02-24 12:00:00",
"amount": 19.99,
"amount_usd": 19.99,
"is_refund": false
}cURL:
curl -X POST "https://YOUR_PROJECT_DOMAIN/api/subscription/invoice" \
-H "Content-Type: application/json" \
-H "api_key: YOUR_API_KEY" \
-d '{"subscription_id": 12345, "amount": 19.99}'Invoice status: 0 = Processing, 1 = Paid, 2 = Unpaid, 3 = Refund, 4 = Unknown.
Flow summary
- Create prices in Cabinet (Custom) → get
price_idand plan. - Collect payment (iframe or external).
- Create subscription via JS (
w2w.createSubscription) or Backend (POST/api/subscription); add invoices in the same call or with POST/api/subscription/invoice. - Send conversion events (when using JS): use response
event_nameandevent_propertieswithw2w.logEvent()so analytics, pixels, and emails run like with built-in gateways. - Web2Wave treats the subscription like any other—cabinet, webhooks, and reports all work.
Uniqueness: One subscription per (payment_system, pay_system_id, real_payment) in the project. Use a stable external id (e.g. order id) as pay_system_id.