AsensoPay Partner Connect
Partner Connectv1

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.

Pilot

Partner Connect is a pilot for trusted partners. Read Known limits before you build on it.

The flow in four steps

  1. Connect. Your app opens a signed asensopay://connect link.
  2. Consent. AsensoPay checks the link. The user logs in if needed and approves the consent screen.
  3. Return. AsensoPay opens your redirect_uri with state, cid and scopes. You check state, store cid, and show "Connected".
  4. Open. Later, your app opens signed asensopay://open links to go to /balance, /collections or /pay.

Sequence

Partner app AsensoPay User 1 signed connect link 2 login if needed, consent screen 3 approve (or deny) 4 callback: state, cid, scopes 5 shows "Connected" later 6 signed open link: cid, route opens the screen only if disconnected: error=not_connected
Solid arrows are links opened by deeplink. Dashed arrows are returns. Steps 1 to 5 happen once. Step 6 repeats whenever your app wants to open an AsensoPay screen.

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 cert in every link.
  • A private key. You use it to sign links. You never send it anywhere.
Warning

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

ItemExampleNotes
Bundle id / package namecom.acme.rewardsGoes into the certificate.
Requested scopesbalance:read, collections:readAsk only for what you need. See Routes and scopes.
Callback URLhttps://example.com/cbYour redirect_uri. Links must use it exactly: /cb and /cb/ are different.
A contactname and emailFor 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:

FieldTest vector valueMeaning
v1Certificate version.
pidacmePartner identifier.
bidcom.acme.rewardsBundle id / package name.
pubgTl3Dqh9…Js5QYour public key. AsensoPay verifies link signatures with it.
scopesbalance:read, collections:readThe most you can ever be granted.
redirectshttps://example.com/cbThe only redirect_uri values you may use.
exp1900000000Expiry. Certificates are short-lived. The default is 90 days.

How to sign a link

The signature covers every parameter except sig. Follow the steps in order. The example uses the test vector.

  1. 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)
  2. Sort the [key, value] pairs by key, comparing plain code units. Not locale-aware.

    In JavaScript use a < b. Do not use localeCompare.

    cert, nonce, redirect_uri, scopes, state, ts, v
  3. canonical = JSON.stringify of that array of [key, value] pairs.

    An array of two-element arrays. No spaces. All values are strings, so ts is "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.stringify does 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.

  4. 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, so sig is 86 characters. The signature covers the UTF-8 bytes of the canonical string, not a hash of it.

  5. 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-…
Tip

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

Check your implementation
Root public keyiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w
Partner public keygTl3Dqh9F19Wo1Rmw0x-zMuNipG07jeiXfYPW4_Js5Q
Certificate payload{"v":1,"pid":"acme","bid":"com.acme.rewards","pub":"gTl3Dqh9F19Wo1Rmw0x-zMuNipG07jeiXfYPW4_Js5Q","scopes":["balance:read","collections:read"],"redirects":["https://example.com/cb"],"exp":1900000000}
CertificateeyJ2IjoxLCJwaWQiOiJhY21lIiwiYmlkIjoiY29tLmFjbWUucmV3YXJkcyIsInB1YiI6ImdUbDNEcWg5RjE5V28xUm13MHgtek11TmlwRzA3amVpWGZZUFc0X0pzNVEiLCJzY29wZXMiOlsiYmFsYW5jZTpyZWFkIiwiY29sbGVjdGlvbnM6cmVhZCJdLCJyZWRpcmVjdHMiOlsiaHR0cHM6Ly9leGFtcGxlLmNvbS9jYiJdLCJleHAiOjE5MDAwMDAwMDB9.4gkzm7FicVk8fcSCFjQcBUlyzoilxKzf6qQrD7TemNfg1_EAyCryti9uXf43M6ra81WAe-6-AN6bUopZkRaiBQ
Connect parametersv=1 ts=1800000000 nonce=n-0001 state=abc123 redirect_uri=https://example.com/cb scopes=balance:read,collections:read
Canonical string (517 characters)[["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"]]
Expected sigyQOpfCdG-qFiYaRBv35Z0E1_lT5e5_-u5uGPDzCMaF0K6-If31Fm8mP2Y3k1ngcS5-PopxYjBm4oDuCTaouWDQ
The Ed25519 seeds are 32 bytes of 0x01 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:

  1. Your canonical string is exactly 517 characters and equals the one above.
  2. Your sig equals the expected sig, 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-PopxYjBm4oDuCTaouWDQ

Check 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

    Copy and adapt, not a dependency

    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',
    });

    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

    CaseQueryWhat you do
    Allowed?state=…&cid=…&scopes=a,bCheck state. Store cid and scopes. Show Connected.
    Denied?state=…&error=access_deniedCheck state. Show Denied.
    Not connected (open link only)?error=not_connectedClear the stored connection. Show Not connected.

    scopes in the allowed callback is the granted set. Use it to decide which buttons to show.

    Warning

    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:

    • state matches 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.

    RouteScope neededNotes
    /balancebalance:read
    /collectionscollections:read
    /paypayments:createAsensoPay 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.

    Tip

    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.

    FrameworkCheck installedOpen linkReceive callback
    Capacitor / IonicAppLauncher.canOpenUrlAppLauncher.openUrlApp.addListener('appUrlOpen')
    React NativeLinking.canOpenURLLinking.openURLLinking.addEventListener('url'), Linking.getInitialURL()
    FluttercanLaunchUrl (url_launcher)launchUrl with LaunchMode.externalApplicationA 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

    Not connected Waiting Connected Denied Failed tap Connect allowed, state matches access_denied 10 min passed, or state does not match open link returns error=not_connected: clear the stored connection
    From Denied and Failed, the user can tap Connect again. That starts a new Waiting state with a new state value.
    StateWhenShow
    Not connectedNo stored connection.A "Connect AsensoPay" button. If AsensoPay is not installed, a "Get AsensoPay" button instead.
    WaitingYou opened a connect link and have not had a callback."Waiting for AsensoPay…" and a Cancel button. Cancel deletes the pending state.
    ConnectedAn allowed callback with a valid state."Connected" and the buttons for the granted scopes.
    FailedThe pending request expired after 10 minutes, or the callback had a state you do not recognize."Couldn't connect." and a "Try again" button.
    Deniederror=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" needs collections:read. "Pay" needs payments: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.

    1. Target, scheme and version.
    2. Certificate signature and expiry.
    3. Partner not revoked.
    4. Caller package matches the certificate's bundle id (Android only, not enforced yet).
    5. Link signature.
    6. ts within ±120 seconds.
    7. Nonce not seen before.
    8. connect: redirect_uri allowed, state present, scopes overlap.
    9. open: connection exists with this cid, route known, scope allowed.

    How to debug a rejected link

    1. Run the test vector through your code. If the canonical string or sig differs, fix signing first.
    2. Log your canonical string and its length. Never log the secret key.
    3. Check ts is in seconds and the device clock is correct.
    4. Check redirect_uri character by character against your certificate.
    5. Use the link-checking tool. It is to be provided and is not available yet. This page will describe it when it ships.

    Symptoms

    SymptomLikely causeFix
    Always "link couldn't be opened"Signature does not match.Reproduce the test vector. Compare canonical strings.
    Test vector matches, real links failReal 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 othersPhone clock more than 2 minutes off.Turn on automatic date and time.
    First tap works, the retry failsNonce reused.Generate a new nonce for every link, including retries.
    Connect works, you never get the callbackCallback 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 FailedPending state was lost, expired, or already used.Persist pending states. Delete a state only when you use it.
    open returns error=not_connectedThe user is not connected in AsensoPay.Clear the stored connection. Let the user connect again.
    open link rejectedWrong 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 nothingiOS: scheme missing from LSApplicationQueriesSchemes. Android 11+: no <queries>.Add them. See Native setup.
    Everything worked, then stoppedCertificate expired (default 90 days).Ask us for a new certificate.
    Stopped for all users at onceThe 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.

    CodeCheck
    bad_target1. Target, scheme and version.
    bad_version1. Target, scheme and version.
    bad_requestThe link is not a well-formed request.
    bad_cert2. Certificate signature.
    cert_expired2. Certificate expiry.
    revoked3. Partner revoked.
    bad_caller4. Caller package does not match the certificate (Android, not enforced yet).
    bad_signature5. Link signature.
    stale6. ts outside ±120 seconds.
    replay7. Nonce already seen.
    bad_redirect8. redirect_uri not allowed.
    missing_state8. state not present.
    no_scopes8. Requested scopes do not overlap the certificate's.
    not_connected9. No connection with this cid.
    unknown_route9. Route not known.
    scope_denied9. Scope not allowed for the route.

    Common mistakes

    • Signing percent-encoded values. Sign https://example.com/cb, not https%3A%2F%2Fexample.com%2Fcb. Encode only after signing.
    • Base64 with padding, or + and /. Use base64url, no padding. A correct sig is 86 characters. If yours is 88 and ends in ==, you have padding.
    • Sorting by locale. Compare code units: a < b in JavaScript, sortedBy in Kotlin, utf16 comparison in Swift.
    • ts in milliseconds. Date.now() gives 1800000000000. You need 1800000000.
    • A redirect_uri that differs from the certificate by even a trailing slash. https://example.com/cb/ is not https://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.

    Warning
    • 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. The not_connected callback has no state.
    • 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.

    VersionStatusChanges
    v1Current (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.
    AsensoPay Partner Connect · protocol v1 · pilot