AsensoPay Partner Connect
Link your app to AsensoPay with signed deeplinks. You sign the links yourself. There is no SDK.
For mobile developers at partner companies: Capacitor/Ionic, React Native, Flutter, native iOS and Android.
Overview
A partner app opens the AsensoPay app by deeplink. The user logs in if needed and approves a consent screen. AsensoPay returns to the partner app, which shows "Connected". Later, the partner can deeplink into specific AsensoPay screens.
There is no backend. AsensoPay verifies every link offline against a root public key embedded in the app.
Partner Connect is a pilot for trusted partners. Read Known limits before you build on it.
The flow in four steps
- Connect. Your app opens a signed
asensopay://connectlink. - Consent. AsensoPay checks the link. The user logs in if needed and approves the consent screen.
- Return. AsensoPay opens your
redirect_uriwithstate,cidandscopes. You checkstate, storecid, and show "Connected". - Open. Later, your app opens signed
asensopay://openlinks to go to/balance,/collectionsor/pay.
Sequence
Before you start
You cannot test against AsensoPay until we have issued credentials and registered your app.
What you receive from us
A JSON file with two values:
- A certificate. We create and sign it. You send it unchanged as
certin every link. - A private key. You use it to sign links. You never send it anywhere.
The private key is secret. Never put it in git. Never write it to logs, crash reports or analytics. Keep it out of source files: load it from build-time secrets or secure storage. If it has been committed or logged, treat it as leaked and contact us.
You do not create or sign the certificate. You only sign links.
What you send us
| Item | Example | Notes |
|---|---|---|
| Bundle id / package name | com.acme.rewards | Goes into the certificate. |
| Requested scopes | balance:read, collections:read | Ask only for what you need. See Routes and scopes. |
| Callback URL | https://example.com/cb | Your redirect_uri. Links must use it exactly: /cb and /cb/ are different. |
| A contact | name and email | For support and certificate renewal. |
What is inside a certificate
You pass the certificate through as a string. You do not need to parse it. These are the fields in the test vector certificate, so you know what your credentials describe:
| Field | Test vector value | Meaning |
|---|---|---|
v | 1 | Certificate version. |
pid | acme | Partner identifier. |
bid | com.acme.rewards | Bundle id / package name. |
pub | gTl3Dqh9…Js5Q | Your public key. AsensoPay verifies link signatures with it. |
scopes | balance:read, collections:read | The most you can ever be granted. |
redirects | https://example.com/cb | The only redirect_uri values you may use. |
exp | 1900000000 | Expiry. Certificates are short-lived. The default is 90 days. |
Link formats
There are two links: connect and open. Only the parameters listed here are defined in v1.
Schemes and hosts
| App | Scheme |
|---|---|
| AsensoPay | asensopay:// |
| OmniPay Business | omnipaybusiness:// |
Both use the same hosts: connect and open. Examples on this page use asensopay://.
connect
asensopay://connect?v=1&cert=…&state=…&redirect_uri=…&scopes=a,b&ts=…&nonce=…&sig=…| Parameter | Description | Example |
|---|---|---|
v | Protocol version. Always 1. | 1 |
cert | The certificate, as issued. | eyJ2Ijox… |
state | Random value you generate. You check it on the callback. | abc123 |
redirect_uri | Where AsensoPay returns. Must match one entry in the certificate exactly. | https://example.com/cb |
scopes | Comma-separated scopes you request. The granted set is the requested scopes ∩ the certificate's scopes. | balance:read,collections:read |
ts | Unix time in seconds, not milliseconds. | 1800000000 |
nonce | Random. Single use. At most 128 characters. | n-0001 |
sig | Signature. See How to sign a link. | yQOpfCdG-… |
Example of the granted set: the certificate allows balance:read and collections:read. You request balance:read,payments:create. The granted set is balance:read.
open
asensopay://open?v=1&cert=…&cid=…&route=/balance&redirect_uri=…&ts=…&nonce=…&sig=…| Parameter | Description | Example |
|---|---|---|
v, cert, ts, nonce, sig | Same as connect. | |
cid | The connection id you received on connect. | (from the callback) |
route | One of the routes in Routes and scopes. | /balance |
redirect_uri | Include it. It lets AsensoPay tell you if the user has disconnected. Must match the certificate exactly. | https://example.com/cb |
A link cannot carry amounts, recipients or any other data. /pay only opens the screen. The user enters everything inside AsensoPay.
How to sign a link
The signature covers every parameter except sig. Follow the steps in order. The example uses the test vector.
- Collect every parameter except
sig, as decoded plain strings.Use the real values, not URL-encoded ones.
v = 1 cert = eyJ2IjoxLCJwaWQiOiJhY21l… ts = 1800000000 nonce = n-0001 state = abc123 redirect_uri = https://example.com/cb (not https%3A%2F%2Fexample.com%2Fcb) scopes = balance:read,collections:read (not balance%3Aread%2Ccollections%3Aread) - Sort the [key, value] pairs by key, comparing plain code units. Not locale-aware.
In JavaScript use
a < b. Do not uselocaleCompare.cert, nonce, redirect_uri, scopes, state, ts, v - canonical =
JSON.stringifyof that array of [key, value] pairs.An array of two-element arrays. No spaces. All values are strings, so
tsis"1800000000", with quotes.[["cert","<cert>"],["nonce","n-0001"],["redirect_uri","https://example.com/cb"],["scopes","balance:read,collections:read"],["state","abc123"],["ts","1800000000"],["v","1"]]Other languages must produce the same bytes as
JSON.stringify.JSON.stringifydoes not escape/. It escapes only",\and control characters. Some JSON serializers write/as\/, which breaks the signature. The Kotlin and Swift samples build the string by hand for this reason. - sig = base64url(Ed25519 detached signature of UTF-8(canonical), using the partner secret key).
Base64url without padding, using
-and_. A detached Ed25519 signature is 64 bytes, sosigis 86 characters. The signature covers the UTF-8 bytes of the canonical string, not a hash of it. - Append
sig, and percent-encode every value (encodeURIComponent).The order of parameters in the URL is not part of the signature.
asensopay://connect?v=1&cert=eyJ2Ijox…&ts=1800000000&nonce=n-0001&state=abc123&redirect_uri=https%3A%2F%2Fexample.com%2Fcb&scopes=balance%3Aread%2Ccollections%3Aread&sig=yQOpfCdG-…
The certificate is created and signed by us. You only sign links. Prove your steps against the test vector before you touch the app.
Test vector
Use this to prove your signing matches before you test in the app. Ed25519 is deterministic, so a correct implementation reproduces the exact same signature.
Check your implementation
iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wgTl3Dqh9F19Wo1Rmw0x-zMuNipG07jeiXfYPW4_Js5Q{"v":1,"pid":"acme","bid":"com.acme.rewards","pub":"gTl3Dqh9F19Wo1Rmw0x-zMuNipG07jeiXfYPW4_Js5Q","scopes":["balance:read","collections:read"],"redirects":["https://example.com/cb"],"exp":1900000000}eyJ2IjoxLCJwaWQiOiJhY21lIiwiYmlkIjoiY29tLmFjbWUucmV3YXJkcyIsInB1YiI6ImdUbDNEcWg5RjE5V28xUm13MHgtek11TmlwRzA3amVpWGZZUFc0X0pzNVEiLCJzY29wZXMiOlsiYmFsYW5jZTpyZWFkIiwiY29sbGVjdGlvbnM6cmVhZCJdLCJyZWRpcmVjdHMiOlsiaHR0cHM6Ly9leGFtcGxlLmNvbS9jYiJdLCJleHAiOjE5MDAwMDAwMDB9.4gkzm7FicVk8fcSCFjQcBUlyzoilxKzf6qQrD7TemNfg1_EAyCryti9uXf43M6ra81WAe-6-AN6bUopZkRaiBQv=1
ts=1800000000
nonce=n-0001
state=abc123
redirect_uri=https://example.com/cb
scopes=balance:read,collections:read[["cert","eyJ2IjoxLCJwaWQiOiJhY21lIiwiYmlkIjoiY29tLmFjbWUucmV3YXJkcyIsInB1YiI6ImdUbDNEcWg5RjE5V28xUm13MHgtek11TmlwRzA3amVpWGZZUFc0X0pzNVEiLCJzY29wZXMiOlsiYmFsYW5jZTpyZWFkIiwiY29sbGVjdGlvbnM6cmVhZCJdLCJyZWRpcmVjdHMiOlsiaHR0cHM6Ly9leGFtcGxlLmNvbS9jYiJdLCJleHAiOjE5MDAwMDAwMDB9.4gkzm7FicVk8fcSCFjQcBUlyzoilxKzf6qQrD7TemNfg1_EAyCryti9uXf43M6ra81WAe-6-AN6bUopZkRaiBQ"],["nonce","n-0001"],["redirect_uri","https://example.com/cb"],["scopes","balance:read,collections:read"],["state","abc123"],["ts","1800000000"],["v","1"]]yQOpfCdG-qFiYaRBv35Z0E1_lT5e5_-u5uGPDzCMaF0K6-If31Fm8mP2Y3k1ngcS5-PopxYjBm4oDuCTaouWDQ0x01 for the root and 32 bytes of 0x02 for the partner. These are public test keys. Never use them for anything real. The cert value in the canonical string is the certificate above.Check in this order:
- Your canonical string is exactly 517 characters and equals the one above.
- Your
sigequals the expectedsig, character for character.
If the canonical string differs, the signature will differ. Fix the string first. Find the first differing character. Typical causes are spaces, \/ instead of /, percent-encoded values, or a different key order.
The TypeScript below reproduces the vector.
import nacl from 'tweetnacl';
// b64u: copy it from the reference code.
const CERT = '<the certificate from the test vector>';
// Public test key. Seed = 32 bytes of 0x02.
const partner = nacl.sign.keyPair.fromSeed(new Uint8Array(32).fill(2));
const params: Record<string, string> = {
v: '1', ts: '1800000000', nonce: 'n-0001', state: 'abc123',
redirect_uri: 'https://example.com/cb',
scopes: 'balance:read,collections:read',
cert: CERT,
};
const pairs = Object.entries(params).sort(([a],[b]) => a < b ? -1 : a > b ? 1 : 0);
const canonical = JSON.stringify(pairs);
console.log(canonical.length); // 517
console.log(b64u(nacl.sign.detached(new TextEncoder().encode(canonical), partner.secretKey)));
// yQOpfCdG-qFiYaRBv35Z0E1_lT5e5_-u5uGPDzCMaF0K6-If31Fm8mP2Y3k1ngcS5-PopxYjBm4oDuCTaouWDQCheck your signature in the browser
This panel runs only in your browser. It makes no network calls. It has no field for a secret key. Do not paste one.
Canonical string and signature checker
Signature verification uses your browser's built-in Ed25519 support. Older browsers do not have it. The canonical string and test vector checks work everywhere.
Reference code
There is no AsensoPay SDK. The TypeScript is the reference. The Kotlin and Swift versions are translations that have not been tested. Run them against the test vector before you rely on them.
Uses tweetnacl. It needs a secure random source. In React Native, add a crypto.getRandomValues polyfill. tweetnacl expects a 64-byte secret key.
import nacl from 'tweetnacl';
const b64u = (b: Uint8Array) => btoa(String.fromCharCode(...b)).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
const fromB64u = (s: string) => Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
function signedUrl(scheme: string, target: 'connect'|'open', cfg: {cert: string; secretKey: string}, extra: Record<string,string>) {
const params: Record<string,string> = {
v: '1', cert: cfg.cert,
ts: String(Math.floor(Date.now()/1000)),
nonce: b64u(nacl.randomBytes(12)),
...extra,
};
const pairs = Object.entries(params).sort(([a],[b]) => a < b ? -1 : a > b ? 1 : 0);
const sig = nacl.sign.detached(new TextEncoder().encode(JSON.stringify(pairs)), fromB64u(cfg.secretKey));
const query = Object.entries({...params, sig: b64u(sig)}).map(([k,v]) => `${k}=${encodeURIComponent(v)}`).join('&');
return `${scheme}://${target}?${query}`;
}Usage
// cert and secretKey come from the JSON file we issued.
// Load them from secure storage or a build-time secret. Never commit or log them.
const cfg = { cert: CERT_FROM_YOUR_FILE, secretKey: SECRET_KEY_FROM_YOUR_FILE };
// Connect
const state = b64u(nacl.randomBytes(16)); // remember it, with the time
const connectUrl = signedUrl('asensopay', 'connect', cfg, {
state,
redirect_uri: 'https://example.com/cb', // exactly as in your certificate
scopes: 'balance:read,collections:read',
});
// Open (after you have a cid)
const openUrl = signedUrl('asensopay', 'open', cfg, {
cid, // from the allowed callback
route: '/balance',
redirect_uri: 'https://example.com/cb',
});Uses BouncyCastle. libsodium also works.
// Copy and adapt. Not tested. Needs org.bouncycastle:bcprov-jdk18on
// (libsodium works too: it also produces standard Ed25519 detached signatures).
import android.util.Base64
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.net.URLEncoder
import java.security.SecureRandom
private val B64U = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
fun b64u(bytes: ByteArray): String = Base64.encodeToString(bytes, B64U)
fun fromB64u(s: String): ByteArray = Base64.decode(s, B64U)
// Same output as JSON.stringify for one string. Does not escape "/".
private fun jsonString(s: String): String = buildString {
append('"')
for (c in s) when {
c == '"' -> append("\\\"")
c == '\\' -> append("\\\\")
c == '\b' -> append("\\b")
c == '\u000C' -> append("\\f")
c == '\n' -> append("\\n")
c == '\r' -> append("\\r")
c == '\t' -> append("\\t")
c < ' ' -> append("\\u%04x".format(c.code))
else -> append(c)
}
append('"')
}
fun signedUrl(
scheme: String, target: String, // target: "connect" or "open"
cert: String, secretKey: String,
extra: Map<String, String>,
): String {
val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) }
val params: Map<String, String> = linkedMapOf(
"v" to "1",
"cert" to cert,
"ts" to (System.currentTimeMillis() / 1000).toString(), // seconds
"nonce" to b64u(nonce),
) + extra
// String ordering in Kotlin compares UTF-16 code units, as required.
val pairs = params.entries.sortedBy { it.key }
val canonical = pairs.joinToString(",", "[", "]") {
"[${jsonString(it.key)},${jsonString(it.value)}]"
}
// Works for a 64-byte tweetnacl secret key or a bare 32-byte seed.
val seed = fromB64u(secretKey).copyOfRange(0, 32)
val signer = Ed25519Signer().apply { init(true, Ed25519PrivateKeyParameters(seed, 0)) }
val msg = canonical.toByteArray(Charsets.UTF_8)
signer.update(msg, 0, msg.size)
val sig = signer.generateSignature()
val query = (params + ("sig" to b64u(sig))).entries.joinToString("&") {
"${it.key}=${URLEncoder.encode(it.value, "UTF-8").replace("+", "%20")}"
}
return "$scheme://$target?$query"
}
// Usage
// val url = signedUrl("asensopay", "connect", cert, secretKey, mapOf(
// "state" to state,
// "redirect_uri" to "https://example.com/cb",
// "scopes" to "balance:read,collections:read",
// ))Uses CryptoKit. Confirm that your build reproduces the test vector signature.
// Copy and adapt. Not tested. Needs iOS 13+ (CryptoKit).
import Foundation
import CryptoKit
enum LinkError: Error { case random, badKey, badUrl }
func b64u(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
func fromB64u(_ s: String) -> Data? {
var t = s.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while t.count % 4 != 0 { t += "=" }
return Data(base64Encoded: t)
}
// Same output as JSON.stringify for one string. Does not escape "/".
// (JSONSerialization escapes "/" by default, so build the string by hand.)
private func jsonString(_ s: String) -> String {
var out = "\""
for u in s.unicodeScalars {
switch u {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\u{08}": out += "\\b"
case "\u{0C}": out += "\\f"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
default:
if u.value < 0x20 { out += String(format: "\\u%04x", u.value) }
else { out.unicodeScalars.append(u) }
}
}
return out + "\""
}
// encodeURIComponent leaves these characters as they are.
private let uriComponentSafe = CharacterSet(charactersIn:
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()")
func signedUrl(scheme: String, target: String, // target: "connect" or "open"
cert: String, secretKey: String,
extra: [String: String]) throws -> URL {
var nonceBytes = [UInt8](repeating: 0, count: 12)
guard SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes) == errSecSuccess
else { throw LinkError.random }
var params: [String: String] = [
"v": "1",
"cert": cert,
"ts": String(Int(Date().timeIntervalSince1970)), // seconds
"nonce": b64u(Data(nonceBytes)),
]
for (k, v) in extra { params[k] = v }
// Compare UTF-16 code units, not Swift's String ordering.
let pairs = params.sorted {
Array($0.key.utf16).lexicographicallyPrecedes(Array($1.key.utf16))
}
let canonical = "[" + pairs
.map { "[\(jsonString($0.key)),\(jsonString($0.value))]" }
.joined(separator: ",") + "]"
// Works for a 64-byte tweetnacl secret key or a bare 32-byte seed.
guard let key = fromB64u(secretKey), key.count >= 32 else { throw LinkError.badKey }
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: key.prefix(32))
let sig = try privateKey.signature(for: Data(canonical.utf8))
params["sig"] = b64u(sig)
let query = params.map { k, v in
"\(k)=\(v.addingPercentEncoding(withAllowedCharacters: uriComponentSafe) ?? "")"
}.joined(separator: "&")
guard let url = URL(string: "\(scheme)://\(target)?\(query)") else { throw LinkError.badUrl }
return url
}
// Usage
// let url = try signedUrl(scheme: "asensopay", target: "connect",
// cert: cert, secretKey: secretKey,
// extra: ["state": state,
// "redirect_uri": "https://example.com/cb",
// "scopes": "balance:read,collections:read"])The secret key in your JSON file is a base64url string. The TypeScript passes it to tweetnacl as is. The Kotlin and Swift versions use its first 32 bytes as the seed. That works for a 64-byte tweetnacl key (32-byte seed followed by the public key) and for a bare 32-byte seed.
Callbacks
AsensoPay returns to your redirect_uri with query parameters.
What AsensoPay sends
| Case | Query | What you do |
|---|---|---|
| Allowed | ?state=…&cid=…&scopes=a,b | Check state. Store cid and scopes. Show Connected. |
| Denied | ?state=…&error=access_denied | Check state. Show Denied. |
Not connected (open link only) | ?error=not_connected | Clear the stored connection. Show Not connected. |
scopes in the allowed callback is the granted set. Use it to decide which buttons to show.
The callback is not signed. Anything that can open your redirect_uri can send one. Rely on the one-time state.
Verify state
state is single use and expires after 10 minutes. Treat a callback as valid only if all of these are true:
statematches a pending request you created.- You created it less than 10 minutes ago.
- You have not used it before. Delete it the first time you see it, even if the callback is an error.
Store pending states on disk. The OS can end your app while the user is in AsensoPay.
The not_connected callback has no state to check. A forged one can make you clear a valid connection, so the user would connect again.
type Cfg = { cert: string; secretKey: string };
const PENDING_MS = 10 * 60 * 1000;
// Persist this (not only in memory). The OS can kill your app while the user is in AsensoPay.
const pending = new Map<string, number>(); // state -> created at (ms)
function startConnect(cfg: Cfg, redirectUri: string, scopes: string): string {
const state = b64u(nacl.randomBytes(16));
pending.set(state, Date.now());
return signedUrl('asensopay', 'connect', cfg, { state, redirect_uri: redirectUri, scopes });
}
type Result = 'connected' | 'denied' | 'not-connected' | 'failed' | 'ignored';
function onCallback(callbackUrl: string): Result {
const q = new URL(callbackUrl).searchParams;
const state = q.get('state');
const error = q.get('error');
// Open link, user is no longer connected. This callback has no state.
if (error === 'not_connected' && !state) { clearStoredConnection(); return 'not-connected'; }
if (!state) return 'ignored';
const created = pending.get(state);
pending.delete(state); // single use, even if a check below fails
if (created === undefined || Date.now() - created > PENDING_MS) return 'failed';
if (error === 'access_denied') return 'denied';
const cid = q.get('cid');
const scopes = (q.get('scopes') ?? '').split(',').filter(Boolean);
if (!cid || scopes.length === 0) return 'failed';
saveConnection({ cid, scopes }); // your storage
return 'connected';
}Routes and scopes
An open link carries one route. The connection must have the scope the route needs.
| Route | Scope needed | Notes |
|---|---|---|
/balance | balance:read | |
/collections | collections:read | |
/pay | payments:create | AsensoPay asks for biometric or device PIN first. The link cannot carry an amount or a recipient. |
These are the only routes and scopes in v1. A route outside this list is rejected.
Request only the scopes you will use. The granted set can only be smaller than the certificate's scopes, never larger.
Native setup
You need two things. Your app must receive the callback. Your app must be allowed to ask whether AsensoPay is installed.
The examples use a custom scheme, yourapp://callback. Replace it with the callback URL you gave us. If your callback is an https link, it must open your app through the platform's own link handling.
iOS
LSApplicationQueriesSchemes lets canOpenURL see the AsensoPay scheme. Without it, canOpenURL returns false even when the app is installed.
<!-- Info.plist -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>yourapp</string> <!-- your callback scheme -->
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>asensopay</string>
<string>omnipaybusiness</string> <!-- only if you target OmniPay Business -->
</array>// SwiftUI
.onOpenURL { url in handleCallback(url) }
// UIKit without scenes. With scenes, use scene(_:openURLContexts:).
func application(_ app: UIApplication, open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
handleCallback(url)
return true
}
func isAsensoPayInstalled() -> Bool {
guard let url = URL(string: "asensopay://") else { return false }
return UIApplication.shared.canOpenURL(url)
}Android
On Android 11 and later, a <queries> entry is needed to check whether AsensoPay is installed.
<!-- AndroidManifest.xml -->
<manifest ...>
<!-- Android 11+: needed to check whether AsensoPay is installed -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="asensopay" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="omnipaybusiness" /> <!-- only if you target OmniPay Business -->
</intent>
</queries>
<application ...>
<activity android:name=".MainActivity"
android:launchMode="singleTask"
android:exported="true">
<!-- Receives the callback -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="yourapp" android:host="callback" />
</intent-filter>
</activity>
</application>
</manifest>// Warm start. For a cold start, read intent.data in onCreate as well.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let { handleCallback(it) }
}
fun isInstalled(context: Context, scheme: String): Boolean {
val probe = Intent(Intent.ACTION_VIEW, Uri.parse("$scheme://connect"))
return probe.resolveActivity(context.packageManager) != null
}AsensoPay is meant to check the calling package against the bid in your certificate. That check is not enforced yet. Make sure the package name of your release build matches the certificate anyway.
Cross-platform frameworks
The manifest and plist entries above still apply. The framework APIs sit on top of them.
| Framework | Check installed | Open link | Receive callback |
|---|---|---|---|
| Capacitor / Ionic | AppLauncher.canOpenUrl | AppLauncher.openUrl | App.addListener('appUrlOpen') |
| React Native | Linking.canOpenURL | Linking.openURL | Linking.addEventListener('url'), Linking.getInitialURL() |
| Flutter | canLaunchUrl (url_launcher) | launchUrl with LaunchMode.externalApplication | A deep link package such as app_links |
import { App } from '@capacitor/app';
import { AppLauncher } from '@capacitor/app-launcher';
// Installed?
const { value: installed } = await AppLauncher.canOpenUrl({ url: 'asensopay://' });
// Open a signed link
if (installed) await AppLauncher.openUrl({ url: connectUrl });
// Receive the callback
App.addListener('appUrlOpen', ({ url }) => onCallback(url));UX guidance
Show the user where they are in the connection. There are five states.
The five states
state value.| State | When | Show |
|---|---|---|
| Not connected | No stored connection. | A "Connect AsensoPay" button. If AsensoPay is not installed, a "Get AsensoPay" button instead. |
| Waiting | You opened a connect link and have not had a callback. | "Waiting for AsensoPay…" and a Cancel button. Cancel deletes the pending state. |
| Connected | An allowed callback with a valid state. | "Connected" and the buttons for the granted scopes. |
| Failed | The pending request expired after 10 minutes, or the callback had a state you do not recognize. | "Couldn't connect." and a "Try again" button. |
| Denied | error=access_denied with a valid state. | "AsensoPay access was declined." and a "Connect again" button. |
Rules
- Not installed. Check first. If AsensoPay is missing, show "Get AsensoPay" and point it at the app's store listing. See Native setup for the checks.
- 10-minute pending expiry. A pending connect lasts 10 minutes. After that, the state is Failed. Example: the user opens AsensoPay at 10:00 and returns at 10:12. Their callback is no longer valid. Show "Try again".
- You cannot see why a link failed. If AsensoPay shows "link couldn't be opened", you get no callback. The only sign on your side is that Waiting expires. See Troubleshooting.
- Contextual buttons. Show a button only if its scope was granted. "View balance" needs
balance:read. "View collections" needscollections:read. "Pay" needspayments:create. Do not show a button that would be rejected. - Pay. Tell the user AsensoPay will ask for biometrics or the device PIN.
- Disconnecting. v1 has no disconnect link. Clearing your stored connection removes it on your side only.
Troubleshooting
The user only sees a generic "link couldn't be opened". AsensoPay does not tell the caller why a link was rejected. You debug on your side.
How AsensoPay validates a link
AsensoPay checks in this order. The first failure rejects the link.
- Target, scheme and version.
- Certificate signature and expiry.
- Partner not revoked.
- Caller package matches the certificate's bundle id (Android only, not enforced yet).
- Link signature.
tswithin ±120 seconds.- Nonce not seen before.
connect:redirect_uriallowed,statepresent, scopes overlap.open: connection exists with thiscid, route known, scope allowed.
How to debug a rejected link
- Run the test vector through your code. If the canonical string or
sigdiffers, fix signing first. - Log your canonical string and its length. Never log the secret key.
- Check
tsis in seconds and the device clock is correct. - Check
redirect_uricharacter by character against your certificate. - Use the link-checking tool. It is to be provided and is not available yet. This page will describe it when it ships.
Symptoms
| Symptom | Likely cause | Fix |
|---|---|---|
| Always "link couldn't be opened" | Signature does not match. | Reproduce the test vector. Compare canonical strings. |
| Test vector matches, real links fail | Real cert altered, redirect_uri differs from the certificate, or ts in milliseconds. | Send cert unchanged. Copy redirect_uri from the certificate. Use seconds. |
| Works on some phones, fails on others | Phone clock more than 2 minutes off. | Turn on automatic date and time. |
| First tap works, the retry fails | Nonce reused. | Generate a new nonce for every link, including retries. |
| Connect works, you never get the callback | Callback scheme or intent filter not registered, or your app was ended and the callback was lost. | Check Native setup. Read the launch URL on cold start. |
| Callback arrives but you show Failed | Pending state was lost, expired, or already used. | Persist pending states. Delete a state only when you use it. |
open returns error=not_connected | The user is not connected in AsensoPay. | Clear the stored connection. Let the user connect again. |
open link rejected | Wrong cid, a route not in the list, or a scope you were not granted. | Use the cid and scopes from the callback. Use a listed route. |
| Connect button does nothing | iOS: scheme missing from LSApplicationQueriesSchemes. Android 11+: no <queries>. | Add them. See Native setup. |
| Everything worked, then stopped | Certificate expired (default 90 days). | Ask us for a new certificate. |
| Stopped for all users at once | The partner was revoked. | Contact us. |
Reason codes
These are for AsensoPay support. They are not shown to partners at runtime. Give us the time of the attempt and your partner id, and we can look up the code.
| Code | Check |
|---|---|
bad_target | 1. Target, scheme and version. |
bad_version | 1. Target, scheme and version. |
bad_request | The link is not a well-formed request. |
bad_cert | 2. Certificate signature. |
cert_expired | 2. Certificate expiry. |
revoked | 3. Partner revoked. |
bad_caller | 4. Caller package does not match the certificate (Android, not enforced yet). |
bad_signature | 5. Link signature. |
stale | 6. ts outside ±120 seconds. |
replay | 7. Nonce already seen. |
bad_redirect | 8. redirect_uri not allowed. |
missing_state | 8. state not present. |
no_scopes | 8. Requested scopes do not overlap the certificate's. |
not_connected | 9. No connection with this cid. |
unknown_route | 9. Route not known. |
scope_denied | 9. Scope not allowed for the route. |
Common mistakes
- Signing percent-encoded values. Sign
https://example.com/cb, nothttps%3A%2F%2Fexample.com%2Fcb. Encode only after signing. - Base64 with padding, or
+and/. Use base64url, no padding. A correctsigis 86 characters. If yours is 88 and ends in==, you have padding. - Sorting by locale. Compare code units:
a < bin JavaScript,sortedByin Kotlin,utf16comparison in Swift. tsin milliseconds.Date.now()gives 1800000000000. You need 1800000000.- A
redirect_urithat differs from the certificate by even a trailing slash.https://example.com/cb/is nothttps://example.com/cb. - Reusing a nonce. Every link needs a new one.
- A phone clock more than 2 minutes off. The window is ±120 seconds.
- Committing the secret key. Keep it out of git, logs and crash reports.
Known limits
These limits are real. Plan around them.
- Your private key can be extracted. It lives in your app. Anyone who extracts it can sign links as you. To limit the damage, certificates are short-lived (default 90 days), scopes are narrow, and payment routes need biometrics or a PIN.
- Custom URL schemes can be claimed by other apps. On iOS, AsensoPay cannot identify the app that opened a link. We plan to move to Universal Links and App Links.
- The callback is not signed. You rely on the one-time
state. Thenot_connectedcallback has nostate. - Revocation is slow. It happens when a certificate expires or when a revocation list ships in an app update.
- The Android caller check is not enforced yet. The package name in the certificate is not compared to the caller today.
- If the user's AsensoPay session has expired, they log in once before the page opens.
- You get no reason when a link is rejected. See Troubleshooting.
- This is a pilot for trusted partners. Details can change between protocol versions.
Changelog and protocol version
The protocol version is 1. Every link carries v=1. A link with another value is rejected.
| Version | Status | Changes |
|---|---|---|
v1 | Current (pilot) | Initial protocol. connect and open links. Routes /balance, /collections, /pay. Callbacks for allowed, denied and not connected. Links signed with Ed25519 and a certificate issued by AsensoPay. |