📋 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
⚠️ Important

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:

  • /health
    API health check
  • /auth
    Authenticate and obtain JWT token for API access.
  • /merso-buy-item
    Purchase an item using the Merso PNPL fiat option.

0️⃣ Health Check

GET /health

Purpose: Verify API connectivity and status

Request:

cURL
curl -X GET "https://api2.dev.merso.io/health"

Response:

JSON
{
  "success": true,
  "message": "Merso backend is running"
}

JavaScript Example:

JavaScript
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);
  }
}

1️⃣ Auth

POST /auth

Purpose: Authenticate and obtain JWT token for API access

Request

cURL
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

JSON
{
  "authResult": {
    "token": "YOUR_NEW_JWT_TOKEN",
    "expires_at": "2025-08-05T21:21:13.000Z"
  }
}

Example Request Body

JavaScript (Axios)
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);
    }
  }
}

2️⃣ Buy Item

POST /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:

JSON
{
  "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:

JSON
{
  "paymentIntentId": "STRIPE_PAYMENT_INTEND_ID",
  "clientSecret": "STRIPE_CLIENT_SECRET",
  "totalAmount": "TOTAL_ITEM_PRICE",
  "message": "Full payment setup completed"
}

JavaScript Example

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

React (TypeScript)
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.

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:

POST /set-url-webhook-asset-unpaid

Purpose: Set the URL to communicate games that an asset is unpaid after several tries.

Request

cURL
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

JSON
{
  "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.

Webhook Payload

We will call your endpoint with the following JSON payload in the event of an asset is unpaid:

JSON
{
  "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.

Node.js (Express)
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;
  }
}
📝
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.