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 matchesFEATURE_NOT_SUPPORTED. Useerrors.As. - Java and Kotlin: a
switchorwhenexpression overPaymentStatusno longer compiles without the newREVERSEDandUNKNOWNconstants. - Java, Kotlin, Go and Ruby:
expired,authorised,blocked,refunded,partially_refundedandreversedwere 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 theStringid, and.gatewayis nullable, sodto.gateway.valueno longer compiles. - API:
/v1/paystack/plans,/v1/paystack/plans/:code,/v1/paystack/subscriptions,/v1/paystack/subscriptions/:codeand itsenableanddisableactions 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:
datanow carries onlydispute_case_id,normalized_case_status,occurred_at,deadline_at,amount_minor,currencyand, on submission events,submission_id,submission_typeandsubmission_status. These fields are removed:provider,provider_dispute_id,provider_event_code,provider_event_id,event_id,event_type,event_source,idempotency_key,execution_statusandexecution_message.meta.provideris removed too. Usetypefor the event anddispute_case_idto find the dispute.
For every SDK
gatewayis any provider id. A new provider needs no SDK release. AskGET /v1/merchant/payment/capabilities?currency=&country=which providers are connected and what each supports. Each feature isnative,emulated,manualorunavailable. Accept feature ids you do not know.completedandsuccessboth mean a collected payment. Every SDK reportssuccess. Onlycompletedis mapped. The API also sendsauthorised,blockedandexpired; no SDK maps them, and they stay readable as sent.succeeded,capturedandabandonedare 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
reversedcan appear. - Unsupported cancellation returns
FEATURE_NOT_SUPPORTED. Older docs namedCHECKOUT_CANCELLATION_UNSUPPORTED. It now appears only when Stripe can't cancel that checkout, for example a non-Checkout reference, and itsdetailshold the storedrequest_idandstatus. - An unknown
gatewayon initiate now returnsINVALID_GATEWAY(HTTP 400, messageInvalid gateway), as query already did. It used to returnVALIDATION_ERROR. Match on both codes if you handle either. - An M-Pesa prompt that times out (result code 1037) now gives status
expiredinstead offailed, still with thepayment.failedwebhook. A verified late success on any provider now completes anexpiredpayment and sendspayment.completed. - Dispute evidence for Paystack adds
customer_name(required) andcustomer_phone(only if the payment has none). Paystack getscustomer_emailandcustomer_phonefrom 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.blockedandpayment.createdwere 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.
{
"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)andtypes.PESAPAL.Gatewayalready took any id. *errors.FeatureNotSupportedExceptionembeds*ApiException.errors.Asto either type works. A direct assertionerr.(*ApiException)no longer matches this error.types.ParsePaymentStatusreplaces five copied switches.completedbecomessuccess. An unknown status is kept as sent; it used to becomepending. New constants:PARTIALLY_REFUNDED,REFUNDED,REVERSED.
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:
Gatewaysconstants andtransactions().capabilities(currency?, country?). errors.FeatureNotSupportedErrorextendsApiError, soinstanceof ApiErrorstill holds.completedbecomessuccess. Unknown statuses were already kept as sent.statusis a plain string.
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:
Gatewaysconstants andtransactions().capabilities(currency=None, country=None), which returns a dict withproviders. FeatureNotSupportedErrorsubclassesApiError, soexcept ApiErrorstill catches it.completedbecomessuccess, sois_successful()is now true for it. Unknown statuses were already kept as sent.
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::PESAPALandtransactions()->capabilities($currency, $country), which returns an array.Gateway|stringalready took any id. FeatureNotSupportedExceptionextendsApiException, withgetGateway(),getFeature()andgetAlternatives().statusis nowPaymentStatus|string. Before,PaymentStatus::fromthrewValueErrorforcompleted,reversedand any unknown status. NowcompletedisSUCCESS,reversedis the newREVERSEDcase, and an unknown status stays a string. Enum comparisons still work.$t->status->valuefails on a string; usePaymentStatus::text($t->status).
<?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::PESAPALandtransactions.capabilities(currency:, country:), which returns a Hash with string keys. Tinker::Exception::FeatureNotSupportedExceptionsubclassesApiException, withgateway,featureandalternatives.PaymentStatus.parse:completedbecomessuccess,reversedis new, and an unknown status is kept as sent. Before, any status without a constant,completedandreversedincluded, becamepending.pending?is now false for an unknown status.
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 aString. getGateway()on the request DTOs returnsnullfor a provider id with no constant. UsegetGatewayId()for the raw id. A barenullgateway argument is now ambiguous at compile time; cast it.FeatureNotSupportedExceptionextendsApiException, withgetGateway(),getFeature()andgetAlternatives().PaymentStatusaddsREVERSEDandUNKNOWN.completedbecomesSUCCESS. An unknown status wasPENDING; it is nowUNKNOWN, andisPending()is false for it.getStatusValue()onTransactionand the DTOs returns the status as sent. An exhaustiveswitchneeds the two new cases.
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.PESAPALandtransactions().capabilities(currency, country). - The request DTOs hold
gatewayId: String. Constructors that take aGatewaystill compile..gatewayis nowGateway?, and is null for a provider id with no constant.copy(gateway = ...)and the matchingcomponentN()now usegatewayId. FeatureNotSupportedExceptionhasgateway,featureandalternatives.ApiExceptionis nowopen.PaymentStatusaddsREVERSEDandUNKNOWN.completedbecomesSUCCESS. An unknown status wasPENDING; it is nowUNKNOWN.rawStatusonTransaction,QueryDataDtoandInitiationDataDtois the status as sent. An exhaustivewhenneeds the two new branches.
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.