Backend OSP Step-by-Step

1. Create URL

The first step is to add the functionality to your app to generate the URL as specified in OSP URL Structure.

  1. The domain must be the package name and it must be certified in Catappult even for testing purposes.
$DOMAIN = 'com.mygamestudio.game';
DOMAIN = "com.mygamestudio.game"
const DOMAIN = "com.mygamestudio.game";
private static final String DOMAIN = "com.mygamestudio.game";
const val DOMAIN = "com.mygamestudio.game"
  1. For extra security of the URL and to validate if its content has not been tampered with, the developer should include the signature parameter in the URL, this parameter is necessary for the backend implementation.
    The signature parameter is built as shown below, where the URL is signed using an HMAC function with the use of the SHA256 algorithm.
    The required secret key should be available only at the server level and should be shared between the developer and provider. This secret key may be obtained by contacting the Catappult dev support team. \
$signature = hash_hmac('sha256', $url, $SECRET, false);
signature = hmac.new(SECRET, url.encode("utf-8"), hashlib.sha256).hexdigest()
const signature = hmacSHA256(url, SECRET);
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
SecretKeySpec secret_key =
    new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256_HMAC.init(secret_key);

byte[] signature = sha256_HMAC.doFinal(url.getBytes(StandardCharsets.UTF_8));
val sha256Hmac = Mac.getInstance("HmacSHA256")
val secretKey = SecretKeySpec(SECRET.toByteArray(), "HmacSHA256")
sha256Hmac.init(secretKey)
  
val signature = sha256Hmac.doFinal(url.toByteArray()).toHex()
  1. Add a calback_url which must be a valid endpoint to your servers, don't forget to encode it later.
$CALLBACK_URL = 'https://www.mygamestudio.com/appcoins';

// Before adding to the url
// out_trade_no parameter is used as an example.
$callback_url = urlencode($CALLBACK_URL . "out_trade_no=1234")
CALLBACK_URL = "https://www.mygamestudio.com/appcoins?"

# Before adding to the url
# out_trade_no parameter is used as an example.
callback_url = urllib.parse.quote(f"{CALLBACK_URL}out_trade_no=1234", safe='')
const CALLBACK_URL = "https://www.mygamestudio.com/appcoins?";

// Before adding to the url
// out_trade_no parameter is used as an example.
let callback_url = encodeURIComponent(CALLBACK_URL + "out_trade_no=1234")
private static final String CALLBACK_URL = "https://www.mygamestudio.com/appcoins?";

private static String encodeURL(String url) {
    try {
      return URLEncoder.encode(url, StandardCharsets.UTF_8.toString());
    } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e.getCause());
    }
  }

// Before adding to the url
// out_trade_no parameter is used as an example.
String callbackUrl = encodeURL(CALLBACK_URL + "out_trade_no=1234");
const val CALLBACK_URL = "https://www.mygamestudio.com/appcoins?"

// Before adding to the url
// out_trade_no parameter is used as an example.
val callbackUrl = URLEncoder.encode(CALLBACK_URL + "out_trade_no=1234")
  1. Then to have the URL accessible to the app make an endpoint that receives the obligatory and desired parameters and returns the URL with its content, instead of creating the URL in the app, make a request to the designed endpoint with any HTTP library.

🚧

From early 2021 the signature was made obligatory for backend implementations of OSP.
Apps that were already implemented with OSP without a signature were added to a list so they can continue to work. If a developer has an app that is in that list but is implementing a new one when the secret key is generated every app from that developer will be taken from that list making the signature necessary for old apps.

The following examples show how the endpoint can be implemented to create a URL that contains value, currency, product, domain, data, callback_url, and order_reference.

<?php

$DOMAIN = 'com.mygamestudio.game';
$CALLBACK_URL = 'https://www.mygamestudio.com/appcoins';
$BASE_URL = 'https://apichain.catappult.io/transaction/inapp';
$SECRET = 'foobar'; 

function generate_url($value, $currency, $product, $data, $order_reference): string
{
    global $DOMAIN, $CALLBACK_URL, $BASE_URL, $SECRET;
  
    // out_trade_no parameter is used as an example.
        $callback_url = urlencode($CALLBACK_URL . "out_trade_no=1234")

    $url = $BASE_URL . 
        '?value=' . $value . 
        '&currency=' . $currency . 
        '&product=' . $product .
        '&domain=' . $DOMAIN .
        '&data=' . $data .
        '&callback_url=' . $callback_url .
        '&order_reference=' . $order_reference;
   
    $signature = hash_hmac('sha256', $url, $SECRET, false);
   
    return $url . '&signature=' . $signature;
}
 
echo generate_url($_POST['value'], $_POST['currency'], $_POST['product'], $_POST['data'], $_POST['order_reference']);
 
?>
from flask import Flask, request
import json
import hmac
import hashlib
import urllib.parse

app = Flask(__name__)

DOMAIN = "com.mygamestudio.game"
CALLBACK_URL = "https://www.mygamestudio.com/appcoins?"
BASE_URL = "https://apichain.catappult.io/transaction/inapp"
SECRET = b"foobar"

@app.route("/generate_url", methods=['POST'])
def generate_url():
    content = json.loads(request.json())

    # out_trade_no parameter is used as an example.
    callback_url = urllib.parse.quote(f"{CALLBACK_URL}out_trade_no=1234", safe='')
    
    url = "{}/?value={}&currency={}&product={}&domain={}&data={}&callback_url={}&order_reference={}".format(
        content['value'], content['currency'], content['product'], DOMAIN, content['data'], callback_url, content['order_reference']
    )

    signature = hmac.new(SECRET, url.encode("utf-8"), hashlib.sha256).hexdigest()
    return f"{url}&signature={signature}"
// crypto-js needs to be istalled with npm
import hmacSHA256 from 'crypto-js/hmac-sha256';
const express = require('express')
const app = express()
const port = 3000

const DOMAIN = "com.mygamestudio.game";
const CALLBACK_URL = "https://www.mygamestudio.com/appcoins?";
const BASE_URL = "https://apichain.catappult.io/transaction/inapp";
const SECRET = "foobar";

app.post('/create_url', (req, res) => {
  const content = req.body
  
  // out_trade_no parameter is used as an example.
  let callback_url = encodeURIComponent(CALLBACK_URL + "out_trade_no=1234")

  let url = BASE_URL + 
      '?value=' + content['value'] + 
      '&currency=' + content['currency'] + 
      '&product=' + content['product'] +
      '&domain=' + DOMAIN +
      '&data=' + content['data'] +
      '&callback_url=' + callback_url +
      '&order_reference=' + content['order_reference'];

  const signature = hmacSHA256(url, SECRET);

  res.send(url + '&signature=' + signature)
})

await app.listen(port)
// using spring (https://spring.io/)
package com.example;

import java.nio.charset.StandardCharsets;
import java.net.URLEncoder;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@SpringBootApplication public class OspSpringApplication {

  public static void main(String[] args) {
    SpringApplication.run(OspSpringApplication.class, args);
  }
}

@RestController class CatappultController {
  private static final String DOMAIN = "com.mygamestudio.game";
  private static final String CALLBACK_URL = "https://www.mygamestudio.com/appcoins?";
  private static final String BASE_URL = "https://apichain.catappult.io/transaction/inapp";
  private static final String SECRET = "foobar";
  private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();

  @PostMapping("/generate_url") public String generateUrl(HttpServletRequest request) {
    try {
      Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
      SecretKeySpec secret_key =
          new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
      sha256_HMAC.init(secret_key);
      
      // out_trade_no parameter is used as an example.
      String callbackUrl = encodeURL(CALLBACK_URL + "out_trade_no=1234");

      String url = BASE_URL
          + "?value="
          + request.getParameter("value")
          + "&currency="
          + request.getParameter("currency")
          + "&product="
          + request.getParameter("product")
          + "&domain="
          + DOMAIN
          + "&data="
          + request.getParameter("data")
          + "&callback_url="
          + callbackUrl
          + "&order_reference="
          + request.getParameter("order_reference");

      byte[] signature = sha256_HMAC.doFinal(url.getBytes(StandardCharsets.UTF_8));
      return url + "&signature=" + bytesToHex(signature);
    } catch (NoSuchAlgorithmException | InvalidKeyException e) {
      e.printStackTrace();
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
    }
  }

  private static String bytesToHex(byte[] bytes) {
    char[] hexChars = new char[bytes.length * 2];
    for (int j = 0; j < bytes.length; j++) {
      int v = bytes[j] & 0xFF;
      hexChars[j * 2] = HEX_ARRAY[v >>> 4];
      hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
    }
    return new String(hexChars);
  }
  
  private static String encodeURL(String url) {
    try {
      return URLEncoder.encode(url, StandardCharsets.UTF_8.toString());
    } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e.getCause());
    }
  }
}
// using ktor (https://ktor.io/)
package com.example

import io.ktor.application.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.net.URLEncoder;

const val DOMAIN = "com.mygamestudio.game"
const val CALLBACK_URL = "https://www.mygamestudio.com/appcoins?"
const val BASE_URL = "https://apichain.catappult.io/transaction/inapp"
const val SECRET = "foobar"

fun main() {
  embeddedServer(Netty, port = 8000) {
    routing {
      post("/generate_url") {
        val sha256Hmac = Mac.getInstance("HmacSHA256")
        val secretKey = SecretKeySpec(SECRET.toByteArray(), "HmacSHA256")
        sha256Hmac.init(secretKey)
          
        // out_trade_no parameter is used as an example.
        val callbackUrl = URLEncoder.encode(CALLBACK_URL + "out_trade_no=1234")

        val body = call.receiveParameters()

        val url: String = BASE_URL +
            "?value=" + body["value"] +
            "&currency=" + body["currency"] +
            "&product=" + body["product"] +
            "&domain=" + DOMAIN +
            "&data=" + body["data"] +
            "&callback_url=" + callbackUrl +
            "&order_reference=" + body["order_reference"]

        val signature = sha256Hmac.doFinal(url.toByteArray()).toHex()
        call.respondText("$url&signature=$signature")
      }
    }
  }.start(wait = true)
}

fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }

📘

To prevent value tampering one option is to store the value of each SKU server-side, this way the value field is generated based on the chosen product and not on the value sent by the app.

2. Call the URL

Inside the app/game, an intent with the URL should be opened either using the web browser (whenever the AppCoins Wallet is not installed), or via the AppCoins Wallet.

In some cases, the user chooses to open the URL with the browser when the AppCoins Wallet is already installed or even chooses the option to always open the URL with the browser. To avoid this, we suggested adding the following sample to trigger the One Step billing flow.

// Class Variable
var RC_ONE_STEP = 10003
  
// try-catch executed from function that triggers payment
try {
  startOneStepPayment(url);
} catch (e: Exception) {
  e.printStackTrace();
}

/**
 * This method starts the intent with the provided One Step URL to target the
 * AppCoins Wallet. 
 * Note: startActivityForResult was used for simplicity. Consider using registerForActivityResult instead.
 * @param url The url that is generated by following the One Step payment rules
 */
fun startOneStepPayment(url: String) {
  val intent = Intent(Intent.ACTION_VIEW)
  intent.data = Uri.parse(url)
  // If AppCoins Wallet is installed then start the Billing flow
  // Otherwise open the URL with default action to install the Wallet
  if (isWalletInstalled()) {
    intent.setPackage("com.appcoins.wallet")
  }
  startActivityForResult(intent, RC_ONE_STEP)
}
private fun isWalletInstalled(): Boolean {
  val packageManager = applicationContext.packageManager
  val intentForCheck = Intent(Intent.ACTION_VIEW)
  return if (intentForCheck.resolveActivity(packageManager) != null)
    try {
      packageManager.getPackageInfo("com.appcoins.wallet", PackageManager.GET_ACTIVITIES)
      true
    } catch (e: PackageManager.NameNotFoundException) {
      false
    }
  else false
}
// Class Variable
private static int RC_ONE_STEP = 10003;

// try-catch executed from function that triggers payment
try {
  startOneStepPayment(url);
} catch (Exception e) {
  e.printStackTrace();
}

/**
 * This method starts the intent with the provided One Step URL to target the
 * AppCoins Wallet. 
 * Note: startActivityForResult was used for simplicity. Consider using registerForActivityResult instead. 
 * @param url The url that is generated by following the One Step payment rules
 */
public void startOneStepPayment(String url) {
  Intent intent = new Intent(Intent.ACTION_VIEW);
  intent.setData(Uri.parse(url));
  // If AppCoins Wallet is installed then start the Billing flow
  // Otherwise open the URL with default action to install the Wallet
  if (isWalletInstalled()) {
    intent.setPackage("com.appcoins.wallet");
  }
  startActivityForResult(intent, RC_ONE_STEP);
}
private Boolean isWalletInstalled() {
  PackageManager packageManager = getApplicationContext().getPackageManager();
  Intent intentForCheck = new Intent(Intent.ACTION_VIEW);
  if (intentForCheck.resolveActivity(packageManager) != null) {
    try {
      packageManager.getPackageInfo("com.appcoins.wallet", PackageManager.GET_ACTIVITIES);
      return true;
    } catch (PackageManager.NameNotFoundException e) {
      return false;
    }
  }
  return false;
}

Target SDK 30 and over

When the app targets SDK 30 or above you need to add the intent to AndroidManifest as such:

<manifest>
  ...
  <queries>
    <package android:name="com.appcoins.wallet" />
    ...
  </queries>
  ...
</manifest>

3. Set the callback URL

This is the first step to get a notification when the purchase has been processed and validated, for more information see Payment Notification.

The callback URL can comprise, as its query string, any parameter relevant to the transaction that will ensure its well functioning on the developer's side, this callback URL must be added to the creation of the URL. As an example, the given URL contains a transaction ID relevant to the developer.

https://www.mygamestudio.com/v1/appcoins_ipn.php?out_trade_no=2082389608326064

4. Prepare your server

If the developer provides a callback URL and the signature, our purchase service triggers a notification whenever there is a valid purchase. In order to use this service, you must prepare your server to receive our purchase service’s request and validate data integrity.

Our service will call the provided callback URL using the structure specified in Request to callback URL. The following snippets exemplify how the request can be handled.

📘

For a complete example of a server written in Python/Flask that handles this step and the following 2 see appcoins-server-validator

<?php
require_once 'vendor/autoload.php';
use GuzzleHttp\Client;
# File exemplified in following step
require '/validate_data.php';
# Mock file to be implmented which stores the verified transactions
require 'VerifiedTransactions.php';

$json = file_get_contents('php://input');
$notification_transaction = json_decode(json_decode($json)->transaction);

$client = new Client();
$webservice_transaction = json_decode($client->get(
    'https://api.blockchainds.com/broker/8.20200101/transactions/'.$notification_transaction->uid
)->getBody());

// is_valid is defined in the next step
if (is_valid($notification_transaction, $webservice_transaction)) {
    VerifiedTransactions.add($notification_transaction->uid);
}
echo 200
?>
  //This next file can be used as an endpoint to check if transaction was valid
 <?php
# Mock file to be implmented which stores the verified transactions
require_once('VerifiedTransactions.php');

$uid = $_GET['uid'];

$response->valid = VerifiedTransactions.contains($uid);

echo json_encode($response);
?>
import json
import requests
from flask import Flask

app = Flask(__name__)

WEBSERVICE = 'https://api.blockchainds.com/broker/8.20200101/transactions/{}'
verified_transactions = []

# Example for callback_url = 'https://www.mygamestudio.com/v1/appcoins_ipn'
@app.route("/appcoins_ipn", methods=['POST'])
def appcoins_ipn():
  if len(verified_transactions) > 100:
    del verified_transactions[:]

  notification_transaction = json.loads(json.loads(request.json())['transaction'])
  webservice_response = requests.get(
    WEBSERVICE.format(notification_transaction['uid'])
  ).json()

  # is_valid is defined in the next step
  if is_valid(notification_transaction, webservice_response):
    verified_transactions.insert(0, notification_transaction['uid'])

  return 200

@app.route('/verify/<string:uid>')
def verify(uid):
  global verified_transactions

  response = {'verified': uid in verified_transactions}
  return response
const express = require('express')
const app = express()
const port = 3000

WEBSERVICE = 'https://api.blockchainds.com/broker/8.20200101/transactions/'
verified_transactions = []

app.post('/appcoins_ipn', async (req, res) => {
    if (verified_transactions.length > 100) {
        verified_transactions = [];
    }

    notification_transaction = JSON.parse(req.body['transaction']);
    webservice_transaction = await (await fetch(WEBSERVICE + notification_transaction['uid'])).json()
        
    // is_valid is defined in the next step
    if (isValid(notification_transaction, webservice_transaction)) {
        verified_transactions.unshift(notification_transaction['uid'])
    }

    res.sendStatus(200)
})

app.get('/verify/:uid', (req, res) => {
    const uid = req.params['uid'];
    const response = {'verified': verified_transactions.contains(uid)};
    res.send(response);
})

await app.listen(port)

5. Verify data integrity

To verify if the data sent in the transaction parameter was generated by Catappult, you can use our transaction's API where you pass the transaction UID and the returned data must be equal to the transaction data the callback URL received.

The following code examples show the function is_valid/isValid used in the step above.

//Same file as step 4
function is_valid($notification_transaction, $webservice_transaction) {
    return $notification_transaction->uid === $webservice_transaction->uid &&
        $notification_transaction->domain === $webservice_transaction->domain &&
        $notification_transaction->product === $webservice_transaction->product &&
        $notification_transaction->status === $webservice_transaction->status &&
        $notification_transaction->reference === $webservice_transaction->reference &&
        $notification_transaction->price->currency === $webservice_transaction->price->currency &&
        $notification_transaction->price->value === $webservice_transaction->price->value &&
        $notification_transaction->price->appc === $webservice_transaction->price->appc;
}
#Same file as step 4
def is_valid(notification_transaction, webservice_transaction):
  return notification_transaction['uid'] == webservice_transaction['uid'] and \
          notification_transaction['domain'] == webservice_transaction['domain'] and \
          notification_transaction['product'] == webservice_transaction['product'] and \
          notification_transaction['status'] == webservice_transaction['status'] and \
          notification_transaction['reference'] == webservice_transaction['reference'] and \
          notification_transaction['price'] == webservice_transaction['price'] and \
          notification_transaction['price']['currency'] == webservice_transaction['price']['currency'] and \
          notification_transaction['price']['value'] == webservice_transaction['price']['value'] and \
          notification_transaction['price']['appc'] == webservice_transaction['price']['appc']
//Same file as step 4
async function isValid(notification_transaction, webservice_transaction) {
    return notification_transaction['uid'] == webservice_transaction['uid'] &&
    notification_transaction['domain'] == webservice_transaction['domain'] &&
    notification_transaction['product'] == webservice_transaction['product'] &&
    notification_transaction['status'] == webservice_transaction['status'] &&
    notification_transaction['reference'] == webservice_transaction['reference'] &&
    notification_transaction['price'] == webservice_transaction['price'] &&
    notification_transaction['price']['currency'] == webservice_transaction['price']['currency'] &&
    notification_transaction['price']['value'] == webservice_transaction['price']['value'] &&
    notification_transaction['price']['appc'] == webservice_transaction['price']['appc'];
}

6. Verify the transaction from the app

After the purchase is processed, use your server to notify and give the item to the user since this is the safest way.

To do this, the server needs to know the user id, this can be done in 2 ways: make the order reference contain the user id, Ex. user_id.timestamp; or add the user ID as a query parameter in the callback URL.