Register this webhook URL in the Zeno Bank Dashboard:
- Custom domain
- Free Wix domain
https://<yourdomain.com>/_functions/updateZenoBankTransaction
https://zenobank.io/_functions/updateZenoBankTransactionhttps://<your-site>.wixsite.com/<site-name>/_functions/updateZenoBankTransaction
https://zenobankio.wixsite.com/my-site-2/_functions/updateZenoBankTransactionThis is the code you need to copy:
- zeno-bank-config.js
- zeno-bank.js
- http-functions.js
zeno-bank-config.js
import * as paymentProvider from 'interfaces-psp-v1-payment-service-provider';
// ─────────────────────────────────────────────────────────────
// CUSTOMIZE HERE (optional)
// Edit these values to change how your payment method appears
// on the dashboard and at checkout.
// ─────────────────────────────────────────────────────────────
const PAYMENT_METHOD_TITLE = 'Pay with Crypto';
const LOGOS = {
white: {
svg: 'https://cryptologos.zenobank.io/library/tether-icon-dark.svg',
png: 'https://cryptologos.zenobank.io/library/tether-icon-dark.png',
},
colored: {
svg: 'https://cryptologos.zenobank.io/library/tether-icon-light.png',
png: 'https://cryptologos.zenobank.io/library/tether-icon-light.svg',
},
};
// ─────────────────────────────────────────────────────────────
/** @returns {import('interfaces-psp-v1-payment-service-provider').PaymentServiceProviderConfig} */
export function getConfig() {
return {
title: 'Zeno Bank',
paymentMethods: [{
hostedPage: {
title: PAYMENT_METHOD_TITLE,
billingAddressMandatoryFields: [],
logos: LOGOS,
},
}],
credentialsFields: [
{
simpleField: { name: 'apiKey', label: 'Zeno API Key' },
},
{
simpleField: { name: 'webhookSecret', label: 'Webhook Signing Secret (whsec_...)' },
},
],
};
}
zeno-bank.js
import * as paymentProvider from 'interfaces-psp-v1-payment-service-provider';
import { secrets } from 'wix-secrets-backend.v2';
import { elevate } from 'wix-auth';
const ZENO_WEBHOOK_SECRET_NAME = 'ZENO_WEBHOOK_SECRET';
const listSecretInfoElevated = elevate(secrets.listSecretInfo);
const createSecretElevated = elevate(secrets.createSecret);
const updateSecretElevated = elevate(secrets.updateSecret);
/**
* @param {import('interfaces-psp-v1-payment-service-provider').ConnectAccountOptions} options
* @param {import('interfaces-psp-v1-payment-service-provider').Context} context
* @returns {Promise<import('interfaces-psp-v1-payment-service-provider').ConnectAccountResponse | import('interfaces-psp-v1-payment-service-provider').BusinessError>}
*/
export const connectAccount = async (options, context) => {
const { apiKey, webhookSecret } = options.credentials;
if (!webhookSecret) {
return {
reasonCode: 2009,
errorCode: 'INVALID_CREDENTIALS',
errorMessage: 'Webhook signing secret is required',
};
}
const list = await listSecretInfoElevated();
const existing = list.secrets.find((s) => s.name === ZENO_WEBHOOK_SECRET_NAME);
const payload = { name: ZENO_WEBHOOK_SECRET_NAME, value: webhookSecret };
if (existing) {
await updateSecretElevated(existing._id, payload);
} else {
await createSecretElevated(payload);
}
return {
credentials: { apiKey },
accountId: 'zenobank',
accountName: 'Zeno Bank',
};
};
function minorToMajor(amount, currency) {
const fractionDigits = new Intl.NumberFormat('en', {
style: 'currency',
currency,
}).resolvedOptions().maximumFractionDigits;
if (fractionDigits === undefined || fractionDigits === null) {
throw new Error(`Fraction digits not found for currency ${currency}`);
}
return +amount / Math.pow(10, fractionDigits);
}
/**
* @param {import('interfaces-psp-v1-payment-service-provider').CreateTransactionOptions} options
* @param {import('interfaces-psp-v1-payment-service-provider').Context} context
* @returns {Promise<import('interfaces-psp-v1-payment-service-provider').CreateTransactionResponse | import('interfaces-psp-v1-payment-service-provider').BusinessError>}
*/
export const createTransaction = async (options, context) => {
const { totalAmount, currency } = options.order.description;
const { successUrl } = options.order.returnUrls;
const request = await fetch('https://api.zenobank.io/api/v1/checkouts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': options.merchantCredentials.apiKey,
},
body: JSON.stringify({
orderId: options.wixTransactionId,
priceAmount: minorToMajor(totalAmount, currency),
priceCurrency: currency,
successRedirectUrl: successUrl,
}),
});
const response = await request.json();
return {
pluginTransactionId: response.id,
redirectUrl: response.checkoutUrl,
};
};
/**
* @param {import('interfaces-psp-v1-payment-service-provider').RefundTransactionOptions} options
* @param {import('interfaces-psp-v1-payment-service-provider').Context} context
* @returns {Promise<import('interfaces-psp-v1-payment-service-provider').CreateRefundResponse | import('interfaces-psp-v1-payment-service-provider').BusinessError>}
*/
export const refundTransaction = async (options, context) => {
return {
pluginRefundId: Math.random().toString(36).slice(2) + Date.now().toString(36),
reasonCode: 3022,
errorCode: 'REFUND_NOT_ALLOWED',
errorMessage: 'Refund not allowed',
};
};
http-functions.js
import wixPaymentProviderBackend from "wix-payment-provider-backend";
import { secrets } from "wix-secrets-backend.v2";
import { elevate } from "wix-auth";
import { ok, forbidden, badRequest, serverError } from "wix-http-functions";
import crypto from "crypto";
const TOLERANCE_SECONDS = 5 * 60;
const ZENO_WEBHOOK_SECRET_NAME = "ZENO_WEBHOOK_SECRET";
const getSecretValueElevated = elevate(secrets.getSecretValue);
function readHeader(headers, primary, fallback) {
return headers[primary] ?? headers[fallback] ?? null;
}
async function verifyZenoWebhook(request) {
const rawBody = await request.body.text();
const webhookId = readHeader(request.headers, "svix-id", "zeno-id");
const webhookTimestamp = readHeader(request.headers, "svix-timestamp", "zeno-timestamp");
const webhookSignature = readHeader(request.headers, "svix-signature", "zeno-signature");
if (!webhookId || !webhookTimestamp || !webhookSignature) return null;
const ts = parseInt(webhookTimestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(ts) || Math.abs(now - ts) > TOLERANCE_SECONDS) return null;
const { value } = await getSecretValueElevated(ZENO_WEBHOOK_SECRET_NAME);
let secret = value;
if (secret.startsWith("whsec_")) secret = secret.slice(6);
const keyBytes = Buffer.from(secret, "base64");
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
const expectedSig = crypto
.createHmac("sha256", keyBytes)
.update(signedContent)
.digest("base64");
const expectedBuf = Buffer.from(expectedSig);
for (const versioned of webhookSignature.split(" ")) {
const [version, sig] = versioned.split(",");
if (version !== "v1" || !sig) continue;
const receivedBuf = Buffer.from(sig);
if (
receivedBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(receivedBuf, expectedBuf)
) {
return JSON.parse(rawBody);
}
}
return null;
}
export async function post_updateZenoBankTransaction(request) {
console.log("Zeno webhook received");
const response = { headers: { "Content-Type": "application/json" } };
let event;
try {
event = await verifyZenoWebhook(request);
} catch (err) {
console.error("Verification error:", err);
return serverError(response);
}
if (!event) {
return forbidden({ ...response, body: { error: "Invalid signature" } });
}
if (event.type !== "checkout.completed") {
return ok(response);
}
const wixTransactionId = event.data.orderId;
const pluginTransactionId = event.data.id;
if (!wixTransactionId || !pluginTransactionId) {
return badRequest({ ...response, body: { error: "Missing transaction IDs" } });
}
try {
await wixPaymentProviderBackend.submitEvent({
event: {
transaction: { wixTransactionId, pluginTransactionId },
},
});
} catch (err) {
console.error("submitEvent failed:", err);
return serverError(response);
}
return ok(response);
}

