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.
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.