API Integration
Technical implementation guide for Merso System API integration.
Overview
This guide covers the technical implementation of Merso Web2 API integration for your application.
Before Starting
Prior to integrating the Merso System API into your game, follow these preliminary steps:
When a company expresses the desire to integrate Merso 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:
-
✓
gameId -
✓
apiKey
We provide both parameters to the companies once they decide to implement Merso in their games.
Environment
We have two different environments to allow you to integrate the Merso Protocol safely in your system.
| Environment | Base URL |
|---|---|
| Development | https://api2.dev.merso.io |
| Production | https://api2.merso.io |
We're using the DEVELOPMENT URL on the following examples. Please, use the PRODUCTION URL if you need.
Integration Overview
The Merso PNPL API provides three core endpoints for game integration:
-
✓
/healthAPI health check -
✓
/authAuthenticate and obtain JWT token for API access. -
✓
/merso-buy-itemPurchase an item using the Merso PNPL fiat option.
Health Check
/health
Purpose: Verify API connectivity and status
Request:
curl -X GET "https://api2.dev.merso.io/health"
Response:
{
"success": true,
"message": "Merso 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);
}
}
Auth
/auth
Purpose: Authenticate and obtain JWT token for API access
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);
}
}
}
Buy Item
/merso-buy-item
Purpose: Send the fiat payment data to the player.
Request Body
| Parameter | Type | Description |
|---|---|---|
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. |
Optional Parameters
| Parameter | Type | Description |
|---|---|---|
paymentMode |
string | Desired way to pay the asset. Values must be: "BNPL" or "UPFRONT". Default in case you don't want/need it to send is set as "BNPL" |
Response
We give a concrete response depending on payment mode selected:
PNPL Response:
{
"paymentIntentId": "STRIPE_PAYMENT_INTEND_ID",
"clientSecret": "STRIPE_CLIENT_SECRET",
"firstPaymentAmount": "UPFRONT_PAYMENT",
"weeklyPaymentAmount": "WEEKLY_PAYMENT",
"totalAmount": "TOTAL_ITEM_PRICE",
"message": "PNPL payment setup completed"
}
UPFRONT Response:
{
"paymentIntentId": "STRIPE_PAYMENT_INTEND_ID",
"clientSecret": "STRIPE_CLIENT_SECRET",
"totalAmount": "TOTAL_ITEM_PRICE",
"message": "Full payment setup completed"
}
JavaScript Example
async function buyTokenWithFiat(itemPrice, itemId, itemName, playerEmail, playerLevel, playerCountry, paymentMode) {
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,
paymentMode: paymentMode
}),
}
);
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 against 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;
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.
Available Events
| Event | Description |
|---|---|
asset.payment_failure |
Triggered when a subscription is unpaid and asset will be retired from user. |
Subscribe to Events
To subscribe to this event you must call the following endpoint:
/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
| Parameter | Type | Description |
|---|---|---|
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"
}
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.
Webhook Payload
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"
}
}
Payload Fields
| Field | Type | Description |
|---|---|---|
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
res.status(200).json({ received: true });
});
// 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;
}
}