Skip to content

SDK upgrade guide

The changes are mostly additive, except the breaking changes below. The behaviour changes are listed for each SDK further down. The version numbers are those in each SDK's source; none is published yet.

Breaking changes

These SDK releases are minor bumps on 0.x and break the following. There is no compatibility layer.

  • Go: err.(*ApiException) no longer matches FEATURE_NOT_SUPPORTED. Use errors.As.
  • Java and Kotlin: a switch or when expression over PaymentStatus no longer compiles without the new REVERSED and UNKNOWN constants.
  • Java, Kotlin, Go and Ruby: expired, authorised, blocked, refunded, partially_refunded and reversed were read as pending. They now read as unknown (Java, Kotlin) or are kept as sent (Go, Ruby), so the pending check is false for them.
  • Kotlin request DTOs: copy(gateway = ...) no longer compiles, componentN() returns the String id, and .gateway is nullable, so dto.gateway.value no longer compiles.
  • API: /v1/paystack/plans, /v1/paystack/plans/:code, /v1/paystack/subscriptions, /v1/paystack/subscriptions/:code and its enable and disable actions are removed and now return 404. No SDK called them. Tinker plans are at /v1/merchant/subscriptions/plans. Edit Paystack plans and enable or disable Paystack subscriptions in the Paystack dashboard. Paystack webhooks are unchanged.
  • Dispute webhooks: data now carries only dispute_case_id, normalized_case_status, occurred_at, deadline_at, amount_minor, currency and, on submission events, submission_id, submission_type and submission_status. These fields are removed: provider, provider_dispute_id, provider_event_code, provider_event_id, event_id, event_type, event_source, idempotency_key, execution_status and execution_message. meta.provider is removed too. Use type for the event and dispute_case_id to find the dispute.

For every SDK

  • gateway is any provider id. A new provider needs no SDK release. Ask GET /v1/merchant/payment/capabilities?currency=&country= which providers are connected and what each supports. Each feature is native, emulated, manual or unavailable. Accept feature ids you do not know.
  • completed and success both mean a collected payment. Every SDK reports success. Only completed is mapped. The API also sends authorised, blocked and expired; no SDK maps them, and they stay readable as sent. succeeded, captured and abandoned are never sent and are gone from the spec.
  • Compare against the statuses you handle and treat the rest as in progress. Do not release goods on an unknown status. New statuses such as reversed can appear.
  • Unsupported cancellation returns FEATURE_NOT_SUPPORTED. Older docs named CHECKOUT_CANCELLATION_UNSUPPORTED. It now appears only when Stripe can't cancel that checkout, for example a non-Checkout reference, and its details hold the stored request_id and status.
  • An unknown gateway on initiate now returns INVALID_GATEWAY (HTTP 400, message Invalid gateway), as query already did. It used to return VALIDATION_ERROR. Match on both codes if you handle either.
  • An M-Pesa prompt that times out (result code 1037) now gives status expired instead of failed, still with the payment.failed webhook. A verified late success on any provider now completes an expired payment and sends payment.completed.
  • Dispute evidence for Paystack adds customer_name (required) and customer_phone (only if the payment has none). Paystack gets customer_email and customer_phone from the payment. A missing one is a validation error naming the field, and nothing is sent to Paystack. No SDK change.
  • The events settlement.processed, payment.blocked and payment.created were never sent and are gone from the spec and the docs. Nothing changes on the wire. Each SDK still has a settlement webhook data type; it is unused and a later release removes it.
  • Initiate success is documented as HTTP 200, not 201. The server already sent 200. Generated clients that trust 201 need the new spec.

FEATURE_NOT_SUPPORTED

HTTP 422. Tinker checks before anything reaches the provider, so no money moves and no request is stored. details holds gateway, feature and alternatives, the connected gateways that support the feature. It has no request_id and no status; do not read them. Pick one of alternatives yourself. Tinker never switches gateway for you.

Response
{
  "success": false,
  "error": {
    "code": "FEATURE_NOT_SUPPORTED",
    "message": "M-Pesa doesn't support Apple Pay.",
    "details": { "gateway": "mpesa", "feature": "apple_pay", "alternatives": ["stripe"] }
  }
}

Every SDK throws its own subclass of its existing API error, so current catch blocks keep working.

Cancelled checkouts that can still be paid

Some providers cannot withdraw a checkout. M-Pesa cannot recall an STK prompt, so Tinker declares the checkout expired once a read-only query shows no success. The reason_code is emulated_expiry. The customer can still pay. Do not release goods or sell the item again until the payment is terminal. A late success settles and sets payment_conflict.

Per SDK

Go

Version: 0.4.0 to 0.10.0

  • New: Transactions().Capabilities(currency, country) and types.PESAPAL. Gateway already took any id.
  • *errors.FeatureNotSupportedException embeds *ApiException. errors.As to either type works. A direct assertion err.(*ApiException) no longer matches this error.
  • types.ParsePaymentStatus replaces five copied switches. completed becomes success. An unknown status is kept as sent; it used to become pending. New constants: PARTIALLY_REFUNDED, REFUNDED, REVERSED.
Go example
package main

import (
	"errors"
	"fmt"
	"os"
	"strings"

	tinker "github.com/Tinker-Digital-Ltd/tinker-payments-go-sdk/tinker"
	apierrors "github.com/Tinker-Digital-Ltd/tinker-payments-go-sdk/tinker/errors"
	"github.com/Tinker-Digital-Ltd/tinker-payments-go-sdk/tinker/model/dto"
	"github.com/Tinker-Digital-Ltd/tinker-payments-go-sdk/tinker/types"
)

func check(err error) {
	if err != nil {
		panic(err)
	}
}

func main() {
	client := tinker.NewPayments(os.Getenv("TINKER_PUBLIC_KEY"), os.Getenv("TINKER_SECRET_KEY"), nil, os.Getenv("TINKER_BASE_URL"))
	tx := client.Transactions()

	providers, err := tx.Capabilities("KES", "KE")
	check(err)
	for _, p := range providers {
		if p.Gateway == "futurepay" {
			fmt.Println("capabilities", p.Gateway, "refund="+p.Capabilities["refund"])
		}
	}

	started, err := tx.Initiate(&dto.InitiatePaymentRequestDto{Amount: 100, Currency: "KES", Gateway: "futurepay", MerchantReference: "ORDER-2", ReturnURL: "https://example.com/return"})
	check(err)
	fmt.Println("initiated", started.InitiationData.PaymentReference, started.Status)

	for _, reference := range []string{"PAY-future1", "PAY-done", "PAY-new"} {
		found, err := tx.Query(&dto.QueryPaymentRequestDto{PaymentReference: reference, Gateway: "futurepay"})
		check(err)
		switch found.Status {
		case types.SUCCESS, types.REVERSED, types.FAILED, types.CANCELLED:
			fmt.Println(reference, found.Status)
		default:
			fmt.Println(reference, found.Status, "keep waiting")
		}
	}

	phone, method := "+254712345678", "apple_pay"
	_, err = tx.Initiate(&dto.InitiatePaymentRequestDto{Amount: 100, Currency: "KES", Gateway: types.MPESA, MerchantReference: "ORDER-1", ReturnURL: "https://example.com/return", CustomerPhone: &phone, PaymentMethod: &method})
	var refused *apierrors.FeatureNotSupportedException
	if errors.As(err, &refused) {
		fmt.Println("unsupported", refused.Gateway, refused.Feature, strings.Join(refused.Alternatives, ","))
	}
}

JavaScript

Version: 0.6.0 to 0.7.0

  • New: Gateways constants and transactions().capabilities(currency?, country?).
  • errors.FeatureNotSupportedError extends ApiError, so instanceof ApiError still holds.
  • completed becomes success. Unknown statuses were already kept as sent. status is a plain string.
JavaScript example
const { TinkerPayments, errors } = require('tinker-payments-node-sdk');

const client = new TinkerPayments(process.env.TINKER_PUBLIC_KEY, process.env.TINKER_SECRET_KEY, { baseUrl: process.env.TINKER_BASE_URL });
const tx = client.transactions();

(async () => {
  const { providers } = await tx.capabilities('KES', 'KE');
  for (const p of providers.filter((p) => p.gateway === 'futurepay')) {
    console.log('capabilities', p.gateway, 'refund=' + p.capabilities.refund);
  }

  const started = await tx.initiate({ amount: 100, currency: 'KES', gateway: 'futurepay', merchantReference: 'ORDER-2', returnUrl: 'https://example.com/return' });
  console.log('initiated', started.initiationData.paymentReference, started.status);

  for (const reference of ['PAY-future1', 'PAY-done', 'PAY-new']) {
    const found = await tx.query({ reference, gateway: 'futurepay' });
    const settled = ['success', 'reversed', 'failed', 'cancelled'].includes(found.status);
    console.log(reference, found.status + (settled ? '' : ' keep waiting'));
  }

  try {
    await tx.initiate({ amount: 100, currency: 'KES', gateway: 'mpesa', merchantReference: 'ORDER-1', returnUrl: 'https://example.com/return', customerPhone: '+254712345678', paymentMethod: 'apple_pay' });
  } catch (error) {
    if (!(error instanceof errors.FeatureNotSupportedError)) throw error;
    console.log('unsupported', error.gateway, error.feature, error.alternatives.join(','));
  }
})();

Python

Version: 0.6.0 to 0.7.0

  • New: Gateways constants and transactions().capabilities(currency=None, country=None), which returns a dict with providers.
  • FeatureNotSupportedError subclasses ApiError, so except ApiError still catches it.
  • completed becomes success, so is_successful() is now true for it. Unknown statuses were already kept as sent.
Python example
import os

from tinker import FeatureNotSupportedError, TinkerClient

client = TinkerClient(os.environ["TINKER_PUBLIC_KEY"], os.environ["TINKER_SECRET_KEY"], base_url=os.environ.get("TINKER_BASE_URL"))
tx = client.transactions()

for p in tx.capabilities("KES", "KE")["providers"]:
    if p["gateway"] == "futurepay":
        print("capabilities", p["gateway"], "refund=" + p["capabilities"]["refund"])

started = tx.initiate({"amount": 100, "currency": "KES", "gateway": "futurepay", "merchantReference": "ORDER-2", "returnUrl": "https://example.com/return"})
print("initiated", started.initiation_data["paymentReference"], started.status)

for reference in ["PAY-future1", "PAY-done", "PAY-new"]:
    found = tx.query({"reference": reference, "gateway": "futurepay"})
    settled = found.status in ("success", "reversed", "failed", "cancelled")
    print(reference, found.status + ("" if settled else " keep waiting"))

try:
    tx.initiate({"amount": 100, "currency": "KES", "gateway": "mpesa", "merchantReference": "ORDER-1", "returnUrl": "https://example.com/return", "customerPhone": "+254712345678", "paymentMethod": "apple_pay"})
except FeatureNotSupportedError as error:
    print("unsupported", error.gateway, error.feature, ",".join(error.alternatives))

PHP

Version: no bump. There is no version file; the latest tag is v0.4.0.

  • New: Gateway::PESAPAL and transactions()->capabilities($currency, $country), which returns an array. Gateway|string already took any id.
  • FeatureNotSupportedException extends ApiException, with getGateway(), getFeature() and getAlternatives().
  • status is now PaymentStatus|string. Before, PaymentStatus::from threw ValueError for completed, reversed and any unknown status. Now completed is SUCCESS, reversed is the new REVERSED case, and an unknown status stays a string. Enum comparisons still work. $t->status->value fails on a string; use PaymentStatus::text($t->status).
PHP example
<?php
declare(strict_types=1);

use Tinker\Enum\PaymentStatus;
use Tinker\Exception\FeatureNotSupportedException;
use Tinker\Model\DTO\InitiatePaymentRequest;
use Tinker\Model\DTO\QueryPaymentRequest;
use Tinker\TinkerPayments;

$client = new TinkerPayments(getenv('TINKER_PUBLIC_KEY'), getenv('TINKER_SECRET_KEY'), baseUrl: getenv('TINKER_BASE_URL') ?: null);
$tx = $client->transactions();

foreach ($tx->capabilities('KES', 'KE')['providers'] as $p) {
    if ('futurepay' === $p['gateway']) {
        echo 'capabilities ', $p['gateway'], ' refund=', $p['capabilities']['refund'], "\n";
    }
}

$started = $tx->initiate(new InitiatePaymentRequest(100.0, 'KES', 'futurepay', 'ORDER-2', 'https://example.com/return'));
echo 'initiated ', $started->getInitiationData()->paymentReference, ' ', PaymentStatus::text($started->status), "\n";

foreach (['PAY-future1', 'PAY-done', 'PAY-new'] as $reference) {
    $status = PaymentStatus::text($tx->query(new QueryPaymentRequest($reference, 'futurepay'))->status);
    $settled = in_array($status, ['success', 'reversed', 'failed', 'cancelled'], true);
    echo $reference, ' ', $status, $settled ? '' : ' keep waiting', "\n";
}

try {
    $tx->initiate(new InitiatePaymentRequest(100.0, 'KES', 'mpesa', 'ORDER-1', 'https://example.com/return', customerPhone: '+254712345678', paymentMethod: 'apple_pay'));
} catch (FeatureNotSupportedException $error) {
    echo 'unsupported ', $error->getGateway(), ' ', $error->getFeature(), ' ', implode(',', $error->getAlternatives()), "\n";
}

Ruby

Version: 0.5.0 to 0.6.0

  • New: Gateway::PESAPAL and transactions.capabilities(currency:, country:), which returns a Hash with string keys.
  • Tinker::Exception::FeatureNotSupportedException subclasses ApiException, with gateway, feature and alternatives.
  • PaymentStatus.parse: completed becomes success, reversed is new, and an unknown status is kept as sent. Before, any status without a constant, completed and reversed included, became pending. pending? is now false for an unknown status.
Ruby example
require 'tinker'

client = Tinker::Payments.new(api_public_key: ENV['TINKER_PUBLIC_KEY'], api_secret_key: ENV['TINKER_SECRET_KEY'], base_url: ENV['TINKER_BASE_URL'])
tx = client.transactions

tx.capabilities(currency: 'KES', country: 'KE')['providers'].each do |p|
  puts "capabilities #{p['gateway']} refund=#{p['capabilities']['refund']}" if p['gateway'] == 'futurepay'
end

dto = Tinker::Model::Dto
started = tx.initiate(dto::InitiatePaymentRequestDto.new(amount: 100.0, currency: 'KES', gateway: 'futurepay', merchant_reference: 'ORDER-2', return_url: 'https://example.com/return'))
puts "initiated #{started.initiation_data.payment_reference} #{started.status}"

%w[PAY-future1 PAY-done PAY-new].each do |reference|
  status = tx.query(dto::QueryPaymentRequestDto.new(payment_reference: reference, gateway: 'futurepay')).status
  settled = %w[success reversed failed cancelled].include?(status)
  puts "#{reference} #{status}#{settled ? '' : ' keep waiting'}"
end

begin
  tx.initiate(dto::InitiatePaymentRequestDto.new(amount: 100.0, currency: 'KES', gateway: 'mpesa', merchant_reference: 'ORDER-1', return_url: 'https://example.com/return', customer_phone: '+254712345678', payment_method: 'apple_pay'))
rescue Tinker::Exception::FeatureNotSupportedException => e
  puts "unsupported #{e.gateway} #{e.feature} #{e.alternatives.join(',')}"
end

Java

Version: 0.5.0 to 0.6.0

  • New: Gateway.PESAPAL, Gateway.fromValue, transactions().capabilities(currency, country), and request constructors that take the gateway as a String.
  • getGateway() on the request DTOs returns null for a provider id with no constant. Use getGatewayId() for the raw id. A bare null gateway argument is now ambiguous at compile time; cast it.
  • FeatureNotSupportedException extends ApiException, with getGateway(), getFeature() and getAlternatives().
  • PaymentStatus adds REVERSED and UNKNOWN. completed becomes SUCCESS. An unknown status was PENDING; it is now UNKNOWN, and isPending() is false for it. getStatusValue() on Transaction and the DTOs returns the status as sent. An exhaustive switch needs the two new cases.
Java example
import co.ke.tinker.Payments;
import co.ke.tinker.enums.PaymentStatus;
import co.ke.tinker.exception.FeatureNotSupportedException;
import co.ke.tinker.model.Transaction;
import co.ke.tinker.model.dto.InitiatePaymentRequestDto;
import co.ke.tinker.model.dto.QueryPaymentRequestDto;
import co.ke.tinker.model.lifecycle.ProviderCapability;

public class Example {
    public static void main(String[] args) {
        Payments client = new Payments(System.getenv("TINKER_PUBLIC_KEY"), System.getenv("TINKER_SECRET_KEY"), null, System.getenv("TINKER_BASE_URL"));

        for (ProviderCapability p : client.transactions().capabilities("KES", "KE").getProviders()) {
            if (p.getGateway().equals("futurepay")) {
                System.out.println("capabilities " + p.getGateway() + " refund=" + p.getCapabilities().get("refund"));
            }
        }

        Transaction started = client.transactions().initiate(new InitiatePaymentRequestDto(100.0, "KES", "futurepay", "ORDER-2", "https://example.com/return"));
        System.out.println("initiated " + started.getInitiationData().getPaymentReference() + " " + started.getStatusValue());

        for (String reference : new String[] {"PAY-future1", "PAY-done", "PAY-new"}) {
            Transaction found = client.transactions().query(new QueryPaymentRequestDto(reference, "futurepay"));
            boolean settled = found.getStatus() == PaymentStatus.SUCCESS || found.getStatus() == PaymentStatus.REVERSED
                    || found.getStatus() == PaymentStatus.FAILED || found.getStatus() == PaymentStatus.CANCELLED;
            System.out.println(reference + " " + found.getStatusValue() + (settled ? "" : " keep waiting"));
        }

        try {
            client.transactions().initiate(new InitiatePaymentRequestDto(100.0, "KES", "mpesa", "ORDER-1", "https://example.com/return", "+254712345678", null, null, null, "apple_pay"));
        } catch (FeatureNotSupportedException error) {
            System.out.println("unsupported " + error.getGateway() + " " + error.getFeature() + " " + String.join(",", error.getAlternatives()));
        }
    }
}

Kotlin

Version: 0.4.0 to 0.5.0

  • New: Gateway.PESAPAL and transactions().capabilities(currency, country).
  • The request DTOs hold gatewayId: String. Constructors that take a Gateway still compile. .gateway is now Gateway?, and is null for a provider id with no constant. copy(gateway = ...) and the matching componentN() now use gatewayId.
  • FeatureNotSupportedException has gateway, feature and alternatives. ApiException is now open.
  • PaymentStatus adds REVERSED and UNKNOWN. completed becomes SUCCESS. An unknown status was PENDING; it is now UNKNOWN. rawStatus on Transaction, QueryDataDto and InitiationDataDto is the status as sent. An exhaustive when needs the two new branches.
Kotlin example
import co.ke.tinker.Payments
import co.ke.tinker.enums.PaymentStatus
import co.ke.tinker.exception.FeatureNotSupportedException
import co.ke.tinker.model.dto.InitiatePaymentRequestDto
import co.ke.tinker.model.dto.QueryPaymentRequestDto

fun main() {
    val client = Payments(System.getenv("TINKER_PUBLIC_KEY"), System.getenv("TINKER_SECRET_KEY"), baseUrl = System.getenv("TINKER_BASE_URL"))
    val tx = client.transactions()

    for (p in tx.capabilities("KES", "KE").providers.filter { it.gateway == "futurepay" }) {
        println("capabilities ${p.gateway} refund=${p.capabilities["refund"]}")
    }

    val started = tx.initiate(InitiatePaymentRequestDto(100.0, "KES", "futurepay", "ORDER-2", "https://example.com/return"))
    println("initiated ${started.initiationData?.paymentReference} ${started.rawStatus}")

    for (reference in listOf("PAY-future1", "PAY-done", "PAY-new")) {
        val found = tx.query(QueryPaymentRequestDto(reference, "futurepay"))
        val settled = found.status in listOf(PaymentStatus.SUCCESS, PaymentStatus.REVERSED, PaymentStatus.FAILED, PaymentStatus.CANCELLED)
        println("$reference ${found.rawStatus}${if (settled) "" else " keep waiting"}")
    }

    try {
        tx.initiate(InitiatePaymentRequestDto(100.0, "KES", "mpesa", "ORDER-1", "https://example.com/return", customerPhone = "+254712345678", paymentMethod = "apple_pay"))
    } catch (error: FeatureNotSupportedException) {
        println("unsupported ${error.gateway} ${error.feature} ${error.alternatives.joinToString(",")}")
    }
}

Each example expects TINKER_PUBLIC_KEY and TINKER_SECRET_KEY. Leave TINKER_BASE_URL unset to use the default API. The examples print one line per step; futurepay stands for a provider your SDK has no constant for.

See the error model and SDKs and plugins.