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.
The domain must be the package name and it must be certified in Catappult even for testing purposes.

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.

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%3A%2F%2Fwww.mygamestudio.com%2Fappcoins%3F';
$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;

    $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
import json
import hmac
import hashlib

app = Flask(__name__)

DOMAIN = "com.mygamestudio.game"
CALLBACK_URL = "https%3A%2F%2Fwww.mygamestudio.com%2Fappcoins%3F"
BASE_URL = "https://apichain.catappult.io/transaction/inapp"
SECRET = b"foobar"

@app.route("/generate_url", methods=['POST'])
def generate_url():
    global DOMAIN, CALLBACK_URL, BASE_URL, SECRET

    content = json.loads(request.json())

    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%3A%2F%2Fwww.mygamestudio.com%2Fappcoins%3F";
const BASE_URL = "https://apichain.catappult.io/transaction/inapp";
const SECRET = "foobar";

app.post('/create_url', (req, res) => {
  const content = req.body

  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.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%3A%2F%2Fwww.mygamestudio.com%2Fappcoins%3F";
  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);

      String url = BASE_URL
          + "?value="
          + request.getParameter("value")
          + "&currency="
          + request.getParameter("currency")
          + "&product="
          + request.getParameter("product")
          + "&domain="
          + DOMAIN
          + "&data="
          + request.getParameter("data")
          + "&callback_url="
          + CALLBACK_URL
          + "&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);
  }
}
// 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

const val DOMAIN = "com.mygamestudio.game"
const val CALLBACK_URL = "https%3A%2F%2Fwww.mygamestudio.com%2Fappcoins%3F"
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)

        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=" + CALLBACK_URL +
            "&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.

val RC_ONE_STEP = 10003

var intent = buildTargetIntent(url);
try {
    startActivityForResult(intent, RC_ONE_STEP);
} catch (e: Exception) {
    e.printStackTrace();
}

/**
* This method generates the intent with the provided One Step URL to target the
* AppCoins Wallet.
* @param url The url that generated by following the One Step payment rules
* 
* @return The intent used to call the wallet 
*/
private fun buildTargetIntent(url: String): Intent {
    var intent = Intent(Intent.ACTION_VIEW)
    intent.data = Uri.parse(url)

    // Check if there is an application that can process the AppCoins Billing
    // flow
    var packageManager = applicationContext.packageManager
    var appsList: List<ResolveInfo> = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
    for (app in appsList) {
        if (app.activityInfo.packageName == "cm.aptoide.pt") {
            // If there's aptoide installed always choose Aptoide as default to open 
            // url
            intent.setPackage(app.activityInfo.packageName)
            break  
        } else if (app.activityInfo.packageName == "com.appcoins.wallet") {
            // If Aptoide is not installed and wallet is installed then choose Wallet
            // as default to open url
            intent.setPackage(app.activityInfo.packageName)
        }
    }
    return intent
}
private static int RC_ONE_STEP = 10003

Intent intent = buildTargetIntent(url);
try {
    startActivityForResult(intent, RC_ONE_STEP);
} catch (Exception e) {
    e.printStackTrace();
}
/**
* This method generates the intent with the provided One Step URL to target the
* AppCoins Wallet.
* @param url The url that generated by following the One Step payment rules
* 
* @return The intent used to call the wallet 
*/
private Intent buildTargetIntent(String url) {
  Intent intent = new Intent(Intent.ACTION_VIEW);
  intent.setData(Uri.parse(url));

  // Check if there is an application that can process the AppCoins Billing
  // flow
  PackageManager packageManager = getApplicationContext().getPackageManager();
  List<ResolveInfo> appsList = packageManager
            .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
  for (ResolveInfo app : appsList) {
    if (app.activityInfo.packageName.equals("cm.aptoide.pt")) {
      // If there's aptoide installed always choose Aptoide as default to open 
      // url
      intent.setPackage(app.activityInfo.packageName);
      break;
    } else if (app.activityInfo.packageName.equals("com.appcoins.wallet")) {
      // If Aptoide is not installed and wallet is installed then choose Wallet
      // as default to open url
      intent.setPackage(app.activityInfo.packageName);
    }
  }
  return intent;
}

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>
        <intent>
            <action android:name="android.intent.action.VIEW"/>
        </intent>
    ...
    </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());

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():
  global WEBSERVICE, verified_transactions

  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()

  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()

    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.