Authentication
How to authenticate every Scan to Pay API request — Basic Auth, JWT Bearer, and the security model behind them.
Scan to Pay supports two authentication methods. Basic Auth is the default and the right choice for most integrators. JWT Bearer with PKI is an opt-in upgrade for tenants that want certificate-based mutual trust.
Both methods authenticate the same accounts and grant the same access — the difference is how you prove your identity on each request. Under the hood, the API accepts both; if a Bearer token is present and valid, it takes precedence over any Basic Auth credentials in the same request.
Bearer mode is a hard switch. Once support enables JWT Bearer for your account, your Basic Auth password is invalidated. You cannot use both methods simultaneously on the same account — you can only have both available across different accounts. Read the Going from Basic to JWT section before you ask for the switch.
Quick reference
# Basic Auth (default)
curl -u "merchant-25:your-api-password" \
https://qa.scantopay.io/pluto/code/123456789
# JWT Bearer (opt-in)
curl -H "Authorization: Bearer eyJhbGci...<snip>..." \
https://qa.scantopay.io/pluto/code/123456789Every request, regardless of method, must:
- Use HTTPS — plain HTTP is rejected
- Set
Content-Type: application/jsonon requests with a body - Authenticate. The only exception is
/public/**paths (QR image rendering, public callbacks, monitor) which are intentionally unauthenticated
Basic Auth
The default. Every Scan to Pay account gets a username and an API password. You include them on every request as standard HTTP Basic Auth.
Username format
| Account type | Username pattern | Example |
|---|---|---|
| Merchant | merchant-{merchantId} | merchant-25 |
| PSP / Aggregator | psp-{merchantId} | psp-13 |
| STK (USSD operator) | provided by support | varies |
The {merchantId} is shown on the Scan to Pay Portal home page after first login.
Where the password lives
API passwords are not emailed. They're generated in the Scan to Pay Portal:
- Log in to the Scan to Pay Portal — sandbox or production — with the administrator email you supplied during onboarding.
- Open the API tab.
- Generate a new API password.
Generating a new password invalidates the previous one immediately — all in-flight requests using the old password will start failing with 401 Bad Credentials. Treat rotation as a deploy-coordinated event.
Example request
curl -u "merchant-25:your-api-password" \
-X POST https://qa.scantopay.io/pluto/code/create \
-H 'Content-Type: application/json' \
-d '{
"merchantReference": "auth-demo",
"amount": 10.00
}'When to rotate
There's no automatic password expiry. Rotate proactively in three scenarios:
- After a suspected credential leak
- When a team member with API access leaves
- On a calendar cadence appropriate to your security policy (industry norm: every 90 days)
JWT Bearer with PKI
A stronger alternative to Basic Auth. Instead of presenting a long-lived password on every request, you exchange a public/private RSA key pair with Scan to Pay once, then perform a challenge-response flow to obtain a short-lived JWT, which you present on subsequent API requests.
When to use this
Choose JWT Bearer when any of the following apply:
- You're a bank, large fintech, or regulated PSP whose security review requires mutual TLS-equivalent trust
- You want to rotate credentials without coordinating with the Scan to Pay support team each time (you control the private key)
- Your platform already issues JWTs for other integrations and you want a consistent pattern
For everyone else, Basic Auth is fine and much simpler.
Prerequisites (one-time setup)
-
Generate a 4096-bit RSA keypair. Store the private key in your secrets manager. Never share it.
openssl req -nodes -x509 -sha256 -newkey rsa:4096 \ -keyout PrivateKey.key -out PublicKey.crt -days 99999 -
Extract your public key as base64:
openssl x509 -in PublicKey.crt -pubkey -noout \ | grep -v "\-\-\-" | base64 -d | base64 -w0 -
Send your public key to Scan to Pay support ([email protected]). Ask them to:
- Configure the public key against your manager identity
- Switch your auth type from
BasictoBearer - Send you Scan to Pay's public key out of band (you'll need it to verify the server)
-
Verify you've received Scan to Pay's public key through a trusted channel before proceeding.
The challenge–response flow
Once configured, every login does four steps:
-
Generate a client challenge. Generate 64 bytes of random data. Encrypt with Scan to Pay's public key using
RSA/ECB/OAEPWithSHA-1AndMGF1Padding. Base64-encode. URL-encode for query string (+becomes space if you don't).Store a SHA256 hash of the pre-encrypted 64 bytes — you'll use it to verify the server's response.
-
Call the login-challenges endpoint:
curl "https://qa.scantopay.io/authentication/login-challenges?identity=your-identity&clientChallenge=<urlencoded-base64>"Response:
{ "expires": "2026-05-14T16:07:13.020Z", "base64EncodedChallenge": "<server's challenge to you>", "base64EncodedClientChallengeResponse": "<server's proof it has STP private key>" } -
Verify the server. Check that
base64EncodedClientChallengeResponsematches the SHA256 hash you stored in step 1. If they don't match, stop — you may not be talking to Scan to Pay. If they match, decryptbase64EncodedChallengewith your private key, then SHA256-hash the decrypted bytes. -
Post the login request with the challenge response and your password:
curl -X POST https://qa.scantopay.io/authentication/login \ -H 'Content-Type: application/json' \ -d '{ "identity": "your-identity", "password": "your-password", "base64EncodedChallengeHash": "<sha256 of the encrypted server challenge>", "base64EncodedChallengeResponse": "<sha256 of the decrypted server challenge>" }'Response:
{ "headerName": "Authorization", "headerValue": "Bearer eyJhbGci...<snip>...", "sessionId": "c056b2d8-d6d1-4ea6-8752-e87f684b2903", "expires": "2026-05-14T16:30:13.341Z" } -
Use the token on subsequent Scan to Pay API requests:
curl -X POST https://qa.scantopay.io/pluto/code/create \ -H "Authorization: Bearer eyJhbGci...<snip>..." \ -H 'Content-Type: application/json' \ -d '{ "merchantReference": "jwt-demo", "amount": 10.00 }'
Time windows
The challenge issued in step 2 has a short lifetime. Complete the login (step 4) within:
- Sandbox: 600 seconds (10 minutes)
- Production: 60 seconds
Cache the returned JWT in memory and re-run the flow before it expires. The token's expires field tells you when it will be rejected.
What you can call
Scan to Pay enforces path-level access based on which role your account has. Most integrators only need EXTERNAL access.
| Path prefix | Required role | What's there | Audience |
|---|---|---|---|
/public/** | none | QR rendering, monitor, public callbacks | Anyone |
/code/** | EXTERNAL | Create / query / manage QR codes | Merchants, PSPs |
/purchase/** | EXTERNAL | Refunds, reversals, WIG / network purchases, merchant notifications | Merchants, PSPs |
/remote/** | authenticated | Wallet provider Remote API V4 | Banks, wallet providers |
/provision/** | authenticated | Card provisioning into wallets | Issuers |
/stk/** | STK | STK / USSD operator integration | Mobile network operators |
/internal/** | INTERNAL | System-to-system | Not available to external integrators |
If your call returns 403 Forbidden, you're authenticated correctly but your account doesn't have the right role for that path.
Common errors
| Status | Body / header | Likely cause |
|---|---|---|
401 Unauthorized | Bad Credentials | Wrong username or password (Basic Auth) |
401 Unauthorized | Invalid or expired JWT token | Token has timed out — fetch a new one |
401 Unauthorized | Invalid JWT token | Missing Bearer prefix or malformed header. Include the trailing space after Bearer |
401 Unauthorized | JWT Authentication disabled for this account | Your account hasn't been switched to Bearer mode. Email support |
403 Forbidden | — | Authenticated but wrong role for this path. See the table above |
400 Bad Request | — | Auth was fine; the request body or query string is wrong |
Going from Basic to JWT
There's no "use both" mode. Migration is one-way per account:
- Pick a window during which you can tolerate downtime if anything goes wrong.
- Send Scan to Pay support your public key and ask for Bearer mode.
- Support flips the switch. Your existing Basic Auth password stops working at this moment.
- Run the challenge–response flow and start using JWTs.
If you have multiple accounts (e.g. one per environment), migrate non-production first.
Tip: Most integrators run two parallel accounts — one Basic-only for legacy scripts and CI, one Bearer-only for production traffic. DifferentmerchantId, different credentials, same backend logic.
What's next
- Make your first authenticated request → Quickstart
- Switch the test app and try in sandbox → Sandbox and test cards
- Find the right path for what you're trying to do → Choose your integration
- Handle webhook signature verification → Signing and verifying webhooks
Updated 1 day ago
