A dataset can’t gate on payment, because the write happens in the browser where the visitor controls it. It has to come from backend code instead.
Set the collection’s Add permission to Admin, so visitors can’t write to it at all. Backend code still can, using suppressAuth, which only works from backend code. Keep the price there too rather than passing it up from the page, since anything the browser sends can be edited on the way.
The page calls a backend function, which writes the row as unpaid and creates a payment. The page opens checkout with that payment id, and a backend event flips the row to paid when the transaction succeeds. Filter whatever displays the listings on status equals paid. The row does exist before payment, but nobody can see it.
This goes in a backend file called listings.web.js:
import { webMethod, Permissions } from 'wix-web-module';
import wixData from 'wix-data';
import wixPayBackend from 'wix-pay-backend';
const COLLECTION = 'Listings';
// Server side on purpose.
const LISTING_PRICE = 120.0;
const LISTING_CURRENCY = 'USD';
const LISTING_NAME = 'Business listing (1 year)';
// Only these fields are accepted from the page.
const ALLOWED_FIELDS = ['firstName', 'lastName', 'businessName', 'category', 'email', 'phone'];
const backendOnly = { suppressAuth: true };
function clean(submission) {
const out = {};
for (const field of ALLOWED_FIELDS) {
const value = submission[field];
if (typeof value === 'string') {
out[field] = value.trim().slice(0, 300);
}
}
return out;
}
function validate(fields) {
const missing = ['firstName', 'lastName', 'businessName', 'email']
.filter((field) => !fields[field]);
if (missing.length > 0) {
throw new Error(`Missing required fields: ${missing.join(', ')}`);
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(fields.email)) {
throw new Error('That email address does not look valid.');
}
}
export const submitListing = webMethod(Permissions.Anyone, async (submission) => {
const fields = clean(submission || {});
validate(fields);
const row = await wixData.insert(COLLECTION, {
...fields,
status: 'unpaid',
submittedAt: new Date(),
}, backendOnly);
const payment = await wixPayBackend.createPayment({
items: [{ name: LISTING_NAME, price: LISTING_PRICE, quantity: 1 }],
amount: LISTING_PRICE,
currency: LISTING_CURRENCY,
userInfo: {
firstName: fields.firstName,
lastName: fields.lastName,
email: fields.email,
phone: fields.phone,
},
});
// Keep the payment id on the row so the payment event can find it later.
await wixData.update(COLLECTION, { ...row, paymentId: payment.id }, backendOnly);
return { paymentId: payment.id };
});
This goes in the backend events.js file, merged with anything already there:
import wixData from 'wix-data';
const COLLECTION = 'Listings';
const backendOnly = { suppressAuth: true };
export async function wixPay_onPaymentUpdate(event) {
const paymentId = event.payment.id;
const status = event.status;
const found = await wixData.query(COLLECTION)
.eq('paymentId', paymentId)
.limit(1)
.find(backendOnly);
if (found.items.length === 0) {
console.warn(`No listing found for payment ${paymentId}`);
return;
}
const row = found.items[0];
if (status === 'Successful') {
row.status = 'paid';
row.paidAt = new Date();
row.transactionId = event.transactionId;
} else {
row.status = status.toLowerCase();
}
await wixData.update(COLLECTION, row, backendOnly);
}
The page code picks up at the click, with your own element IDs swapped in:
import { submitListing } from 'backend/listings.web';
import wixPayFrontend from 'wix-pay-frontend';
$w.onReady(() => {
$w('#submitButton').onClick(async () => {
$w('#submitButton').disable();
$w('#statusText').text = 'One moment...';
try {
const { paymentId } = await submitListing({
firstName: $w('#firstName').value,
lastName: $w('#lastName').value,
businessName: $w('#businessName').value,
category: $w('#category').value,
email: $w('#email').value,
phone: $w('#phone').value,
});
const result = await wixPayFrontend.startPayment(paymentId);
if (result.status === 'Successful') {
$w('#statusText').text = 'Thanks, your listing is paid for and will appear shortly.';
} else if (result.status === 'Pending') {
$w('#statusText').text = 'Your payment is still processing. Your listing goes live once it clears.';
$w('#submitButton').enable();
} else if (result.status === 'Failed') {
$w('#statusText').text = 'That payment did not go through.';
$w('#submitButton').enable();
} else if (result.status === 'Cancelled') {
$w('#statusText').text = 'Payment cancelled. Your details are saved.';
$w('#submitButton').enable();
}
} catch (error) {
$w('#statusText').text = error.message || 'Something went wrong.';
$w('#submitButton').enable();
}
});
});
Your captcha already gates the submit button, so none of this touches that.
The result from startPayment() is only good for showing a message, since it comes back through the browser. The docs say the same, that business decisions like updating collection data belong in the event:
Accept Payments needs a Premium or Studio plan that supports payments, so on the free plan none of this can take a payment until the site is upgraded. Backend events also don’t fire in Preview, so save and publish before testing, or the row just sits at unpaid. Turning on the manual payment method lets you test without charging yourself.
I’ve checked this against the current API reference, but I haven’t run it against a live payments account. You will need to change the element IDs and field names to match yours.