API Integration
This guide covers the technical implementation of Merso BNPL API integration for game companies.
You can integrate Merso via web3, via web2 or using an hybrid system.
The web3 API allows you players to buy NFTs using your in-game token, while the web2 API is the recommended way if your players buy in-game items (non NFTs) and they pay using fiat. On the other hand, the hybrid model is built for those Web3 games that allow their players buy NFTs and pay them using fiat.
In every case you must modify your game UX/UI adding a button to communicate with the Merso API.
⚠️ Before Starting
Prior to integrating the Merso BNPL API into your game, follow these preliminary steps:
When a company expresses the desire to integrate BNPL into their game, we generate an API Key and a Game ID for them. These are unique identifiers for your project and should be kept confidential to prevent unauthorized access. The JWT is generated when you authenticate with the /auth endpoint and will expire every 12 hours, requiring you to re-authenticate to continue making requests.
To call the /auth endpoint, the client needs to send the following parameters in the request body:
gameIdapiKey
We provide both parameters to the companies once they decide to implement BNPL in their games.
In addition, we have two different environments in order to allow you integrate the Merso Protocol safely in your system.
You will have to use one of these URLs depending on the integration phase:
Development: https://api2.dev.merso.io
Production: https://api2.merso.io
0. Auth
Endpoint: POST /auth
Purpose: Verify API connectivity and status
Request:
curl -X POST https://api2.dev.merso.io/auth \
-H "Content-Type: application/json" \
-d '{
"game_id": "YOUR_GAME_ID",
"api_key": "YOUR_API_KEY"
}'Response:
{
"authResult": {
"token": "YOUR_NEW_JWT_TOKEN",
"expires_at": "2025-08-05T21:21:13.000Z"
}
}Example Request Body:
const axios = require('axios');
async function authenticateGame() {
try {
const response = await axios.post('/auth', {
gameid: 'exampleGameId',
apikey: 'exampleApiKey'
});
console.log('Authentication successful:', response.data.authResult);
} catch (error) {
if (error.response) {
console.error('Error:', error.response.data.error);
} else {
console.error('Failed to authenticate game. Error Message:', error.message);
}
}
}🎯 Integration Overview
The Merso BNPL API provides three core endpoints for game integration:
/health- API health check/merso-buy-item- Purchase an item using the Merso BNPL fiat option.
📋 Web2 API Endpoints
1. Health Check
Endpoint: GET /health
Purpose: Verify API connectivity and status
Request:
curl -X GET "https://api2.dev.merso.io/health"Response:
{
"success": true,
"message": "Merso BNPL web2 backend is running",
}JavaScript Example:
async function checkAPIHealth()
try {
const response = await fetch(https://api2.dev.merso.io/health, {
method: 'GET',
headers: headers
});
const data = await response.json();
console.log('API Status:', data.status);
return data;
} catch (error) {
console.error('Health check failed:', error);
}
}2. Buy item.
Endpoint: POST /merso-buy-item
Purpose: Send the fiat payment data to the player.
Request:
curl -X POST https://api2.dev.merso.io/merso-buy-item \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_token>" \
-d '{
"itemPrice": ITEM_PRICE_IN_DOLLARS,
"itemID": ITEM_ID,
"itemName": ITEM_NAME,
"userEmail": "user@email.com,
"playerLevel": "PLAYER_GAME_LEVEL",
"playerCountry": "PLAYER_COUNTRY",
}'Parameters:
itemPrice(number): The price of the item in USD.itemId(string): item ID to purchase.itemName(string): The name of the item.playerEmail(string): User's in-game email.playerLevel(string): The level of the player in your game.playerCountry(string): Country where the player is based on.
Response:
{
"paymentIntentId": "STRIPE_PAYMENT_INTEND_ID",
"clientSecret": "STRIPE_CLIENT_SECRET",
"firstPaymentAmount": "UPFRONT_PAYMENT",
"weeklyPaymentAmount": "WEEKLY_PAYMENT",
"totalAmount": "TOTAL_ITEM_PRICE",
"message": "Item bought successfully"
}JavaScript example:
async function buyTokenWithFiat(itemPrice, itemId, itemName, playerEmail, playerLevel, playerCountry){
try {
const response = await fetch(
`https://api2.dev.merso.io/merso-buy-item`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwtToken}`,
},
body: JSON.stringify({
itemPrice: itemPrice,
itemId: itemId,
itemName: itemName,
playerEmail: playerEmail,
playerLevel: playerLevel,
playerCountry: playerCountry,
}),
}
);
if (!response.ok) {
throw new Error("Failed to process card payment");
}
const responseData = await response.json();
// Your function to show the payment form
showStripePanel(responseData)
} catch (error) {
console.error('Failed to process payment:', error);
throw error;
}
}
React frontend example:
This is an example of how you can show a payment form that acts agains our Stripe account.
import React, { useState } from "react";
import {
Elements,
PaymentElement,
useStripe,
useElements,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { CreditCard, Loader2, CheckCircle } from "lucide-react";
// Initialize Stripe
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ||
"pk_test_our_publishable_key_here"
);
interface StripePaymentProps {
clientSecret: string;
amount: number;
itemName: string;
onSuccess: () => void;
onCancel: () => void;
}
const CheckoutForm: React.FC<{
amount: number;
itemName: string;
onSuccess: () => void;
onCancel: () => void;
}> = ({ amount, itemName, onSuccess, onCancel }) => {
const stripe = useStripe();
const elements = useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
setIsProcessing(true);
setError(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message || "An error occurred");
setIsProcessing(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/payment-success`,
},
});
if (confirmError) {
setError(confirmError.message || "Payment failed");
setIsProcessing(false);
} else {
// Payment successful - our webhook will handle item transfer
onSuccess();
}
};
return (
<div className="bg-white/10 backdrop-blur-md rounded-xl p-3 sm:p-4 border border-white/20 max-w-md mx-auto">
<div className="flex items-center justify-between mb-3 sm:mb-4">
<h3 className="text-base sm:text-lg font-semibold text-white">
Complete Purchase
</h3>
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-green-400" />
</div>
<div className="mb-3 sm:mb-4">
<div className="bg-white/5 rounded-lg p-2 sm:p-3 mb-2 sm:mb-3">
<h4 className="text-white font-medium mb-1 text-xs sm:text-sm">
{itemName}
</h4>
<p className="text-green-400 font-bold text-base sm:text-lg">
${amount.toFixed(2)}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
<div className="min-h-[180px] sm:min-h-[200px]">
<PaymentElement />
</div>
{error && (
<div className="p-2 sm:p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p className="text-red-300 text-xs sm:text-sm">{error}</p>
</div>
)}
<div className="flex gap-2 sm:gap-3 pt-1 sm:pt-2">
<button
type="button"
onClick={onCancel}
disabled={isProcessing}
className="flex-1 bg-gray-600 hover:bg-gray-700 disabled:opacity-50 text-white font-semibold py-2 px-2 sm:px-3 rounded-lg transition-all duration-300 text-xs sm:text-sm"
>
Cancel
</button>
<button
type="submit"
disabled={!stripe || isProcessing}
className="flex-1 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white font-semibold py-2 px-2 sm:px-3 rounded-lg transition-all duration-300 flex items-center justify-center text-xs sm:text-sm"
>
{isProcessing ? (
<>
<Loader2 className="w-3 h-3 sm:w-4 sm:h-4 mr-1 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
Pay ${amount.toFixed(2)}
</>
)}
</button>
</div>
</form>
</div>
);
};
const StripePayment: React.FC<StripePaymentProps> = ({
clientSecret,
amount,
itemName,
onSuccess,
onCancel,
}) => {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-2 sm:p-4 overflow-y-auto">
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 max-w-md w-full my-4 sm:my-8 max-h-[95vh] overflow-y-auto">
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: "night",
variables: {
colorPrimary: "#10b981",
colorBackground: "#1f2937",
colorText: "#ffffff",
colorDanger: "#ef4444",
fontFamily: "system-ui, sans-serif",
},
},
}}
>
<CheckoutForm
amount={amount}
itemName={itemName}
onSuccess={onSuccess}
onCancel={onCancel}
/>
</Elements>
</div>
</div>
);
};
export default StripePayment;
3. Event system
We have developed an event system that you must subscribe to. This system allows us to inform the games when an item is not paid and they must take an action.
The events we currently have:
asset.payment_failure: Triggered when a subscription is unpaid and asset will be retired from user.
To subscribe to this event you must call the following endpoint:
Endpoint: POST /set-url-webhook-asset-unpaid`
Purpose: Set the url to communicate games that an asset is unpaid after several tries.
Request:
curl -X POST https://api2.dev.merso.io/set-url-webhook-asset-unpaid \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_token>" \
-d ''{
"url_webhook": "https://yourdomain.com/your-webhook-endpoint"
}Parameters:
url_webhook(string): The URL that will receive the event.
Response:
{
"message": "Game url webhook updated successfully",
"url_webhook": "https://yourdomain.com/your-webhook-endpoint",
"webhook_secret": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"
}The webhook_secret is a unique cryptographic key for your game. It allows you to verify that webhook requests really come from us and not from a third party.
We will call your endpoint with the following JSON payload in the event of an asset is unpaid:
{
"event": "asset.payment_failure",
"timestamp": "2025-10-16T09:24:35.123Z",
"data": {
"game_id": "your-game-id",
"game_name": "Your game name",
"player_email": "player@example.com",
"item_id": "item_12345",
"item_name": "item name",
"reason": "Payment failed 3 times",
}
}event (string): Event identifier, always "asset.payment_failure" for this case.
timestamp (string): ISO 8601 timestamp indicating when the event occurred.
game_id (string): The unique ID of the game related to the event.
game_name (string): The name of the game.
player_email (string): The email of the player whose asset was deleted.
item_id (string): The unique ID of the item that was deleted.
item_name (string): The name of the deleted item.
reason (string): The reason for the webhook call (e.g., payment failed multiple times).
Verification example:
This example shows how to verify our signature, so you can be sure that the requests to your endpoint are genuinely sent by us.
app.post('/your-webhook-endpoint', async (req, res) => {
const signature = req.headers['x-webhook-signature'];
if (!signature) {
return res.status(401).json({ error: 'Missing signature' });
}
// Verify webhook signature (similar to Stripe's verification)
const payload = req.body.toString('utf8');
const isValid = verifyWebhookSignature(payload, signature, YOUR_WEBHOOK_SECRET);
if (!isValid) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse the verified payload
const webhookData = JSON.parse(payload);
//HERE DO YOUR OWN ACTIONS
}
// Signature verification function
function verifyWebhookSignature(payload, signatureHeader, secret, tolerance = 300) {
try {
// Parse signature header (format: timestamp,signature)
const [timestamp, signature] = signatureHeader.split(',');
if (!timestamp || !signature) {
return false;
}
// Check timestamp tolerance (prevent replay attacks)
const currentTimestamp = Math.floor(Date.now() / 1000);
const signatureTimestamp = parseInt(timestamp, 10);
if (Math.abs(currentTimestamp - signatureTimestamp) > tolerance) {
console.log('Error verifying webhook signature: Timestamp tolerance exceeded');
return false;
}
// Recreate the signature
const normalizedPayload = JSON.stringify(payload);
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
} catch (error) {
console.error('Error verifying webhook signature:', error);
return false;
}
}Note: Please, as the client, give us a response with code 200 if you receive this event and you successfully take the action we previously agreed.
Last updated