Authentication
Overview
The FyPay API uses OAuth 2.0 authentication (clientId + clientSecret) combined with an X.509 client certificate (mTLS). The certificate is presented in the TLS handshake of the token generation call; all other routes use only the Bearer token.
Why mTLS?
mTLS (mutual TLS) provides stronger security than plain credentials:
- Mutual authentication: Both client and server authenticate each other in the TLS handshake
- Non-repudiation: Certificates linked to the account ensure traceability
- Protection against credential theft: Even if
clientId/clientSecretleak, an attacker would also need the certificate and its private key
Prerequisites
Before you start, you will need:
A certificate in PEM format, RSA 2048 key, valid for 2 years and linked to your account, together with its private key. In the examples below, the files are named client.crt (certificate) and client.key (private key).
Generate your clientId and clientSecret in the FyPay Dashboard, under Settings → Credentials. The clientSecret is shown only once: copy it and store it in a secrets vault.
Configure your HTTP client to present the certificate and private key in the TLS handshake (your language's native TLS mechanism).
The certificate must be linked to your account. Certificates that are not linked, or linked to another account, are rejected even if technically valid.
The certificate is delivered in PEM only; there is no .pfx file. If your stack requires PKCS#12 (for example, some Windows/.NET scenarios), convert it locally:
openssl pkcs12 -export -in client.crt -inkey client.key -out client.pfxAuthentication Endpoint
POST /api/auth/token
Generates a JWT access token. The token lifetime, in seconds, is returned in the expires_in field of the response.
This is the only route that requires the client certificate. It must be presented in the TLS handshake (mTLS), not in an HTTP header.
Request
curl -X POST https://api.fypay.me/api/auth/token \
--cert client.crt \
--key client.key \
-H "Content-Type: application/json" \
-d '{
"clientId": "6967c0a0-3566-48d0-8c13-717038b52279",
"clientSecret": "your-client-secret"
}'| Field | Type | Required | Description |
|---|---|---|---|
clientId | string (UUID v4) | Yes | Identifier of the credential generated in the dashboard |
clientSecret | string | Yes | Credential secret, shown only once in the dashboard |
Response (201 Created)
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 600,
"scope": "email profile"
}| Field | Description |
|---|---|
access_token | JWT to be sent in Authorization: Bearer <token> |
token_type | Always Bearer |
expires_in | Token lifetime in seconds. Use this value to schedule renewal |
scope | Token scope |
Practical Example: Node.js
Installation
npm install undiciComplete Code
const fs = require('fs');
const { Agent, request } = require('undici');
const mtlsAgent = new Agent({
connect: {
cert: fs.readFileSync('./client.crt'),
key: fs.readFileSync('./client.key'),
},
});
async function getToken() {
const { statusCode, body } = await request('https://api.fypay.me/api/auth/token', {
method: 'POST',
dispatcher: mtlsAgent,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: process.env.FYPAY_CLIENT_ID,
clientSecret: process.env.FYPAY_CLIENT_SECRET,
}),
});
const data = await body.json();
if (statusCode !== 201) {
throw new Error(`Error obtaining token: ${data.code} ${data.message}`);
}
console.log('Token obtained successfully!');
console.log('Expires in:', data.expires_in, 'seconds');
return data;
}
getToken().catch(console.error);With the native https module, the equivalent is new https.Agent({ cert, key }) passed in the request's agent option.
Practical Example: Python
Installation
pip install requestsComplete Code
import os
import requests
url = 'https://api.fypay.me/api/auth/token'
payload = {
'clientId': os.environ.get('FYPAY_CLIENT_ID'),
'clientSecret': os.environ.get('FYPAY_CLIENT_SECRET'),
}
try:
response = requests.post(
url,
json=payload,
cert=('client.crt', 'client.key'),
timeout=30,
)
response.raise_for_status()
data = response.json()
print('Token obtained successfully!')
print(f"Expires in: {data['expires_in']} seconds")
except requests.exceptions.RequestException as e:
print(f'Error obtaining token: {e}')
if e.response is not None:
print(f'Response: {e.response.text}')Other languages
In all of them, the certificate and key are loaded through the HTTP client's native TLS mechanism:
| Language | Mechanism |
|---|---|
| PHP (cURL) | CURLOPT_SSLCERT = client.crt and CURLOPT_SSLKEY = client.key |
| C# (.NET) | HttpClientHandler.ClientCertificates.Add(X509Certificate2.CreateFromPemFile("client.crt", "client.key")) |
| Go | tls.LoadX509KeyPair("client.crt", "client.key") in tls.Config.Certificates |
| Java | Convert to PKCS#12 with openssl pkcs12 -export and load it as a KeyStore in the SSLContext |
Using the Token
After obtaining the token, include it in the Authorization header of all other requests. These routes do not require a client certificate:
curl -X GET https://api.fypay.me/api/balance \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Token Renewal
Do not assume a fixed token lifetime. Read expires_in in every response and renew the token before it expires.
Recommended Strategy
const fs = require('fs');
const { Agent, request } = require('undici');
class TokenManager {
constructor(clientId, clientSecret, certPath, keyPath) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.agent = new Agent({
connect: {
cert: fs.readFileSync(certPath),
key: fs.readFileSync(keyPath),
},
});
this.token = null;
this.expiresAt = 0;
}
async getValidToken() {
const safetyMarginMs = 30000;
if (this.token && Date.now() < this.expiresAt - safetyMarginMs) {
return this.token;
}
return this.refreshToken();
}
async refreshToken() {
const { statusCode, body } = await request('https://api.fypay.me/api/auth/token', {
method: 'POST',
dispatcher: this.agent,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: this.clientId,
clientSecret: this.clientSecret,
}),
});
const data = await body.json();
if (statusCode !== 201) {
throw new Error(`${data.code}: ${data.message}`);
}
this.token = data.access_token;
this.expiresAt = Date.now() + data.expires_in * 1000;
return this.token;
}
}
const tokenManager = new TokenManager(
process.env.FYPAY_CLIENT_ID,
process.env.FYPAY_CLIENT_SECRET,
'./client.crt',
'./client.key'
);
const token = await tokenManager.getValidToken();Certificate Validation
The system performs the following validations on the certificate presented in the handshake:
- mTLS handshake: The certificate must have been issued by FyPay and you must hold the matching private key
- Validity: The certificate must be within its validity period (2 years from issuance)
- Registration: The certificate's SHA-256 fingerprint must be registered and active
- Account linkage: The certificate must be linked to the same account as the credential (
clientId)
Certificates that are not linked, or linked to another account, will be rejected even if technically valid.
Error responses
All errors returned by POST /api/auth/token follow the same format:
{
"statusCode": 400,
"timestamp": "2026-04-24T16:59:25.577Z",
"path": "/api/auth/token",
"method": "POST",
"code": "PUB_CERT_HEADER_MISSING",
"message": "Client certificate header is missing",
"userMessage": "Client certificate is required.",
"details": {
"hint": "Present your client certificate during the TLS handshake (e.g. curl --cert client.crt --key client.key)."
},
"errorId": "81421e7a5afbd9cf25212f51dac08da1"
}code: stable machine-readable identifier. Branch your handling on this field.message: short technical description (in English, meant for logs).userMessage: end-user facing description.details.hint: practical guidance for developers integrating the API.errorId: unique request identifier. Include it when contacting support.
Error code reference
| HTTP | Code | Meaning |
|---|---|---|
| 400 | PUB_CERT_HEADER_MISSING | No client certificate was presented in the TLS handshake |
| 400 | PUB_CERT_MALFORMED_PEM | Certificate in invalid PEM format |
| 400 | PUB_REQUEST_BODY_INVALID | Invalid JSON body |
| 401 | PUB_CERT_NOT_VERIFIED | The certificate presented in the handshake was rejected |
| 401 | PUB_CERT_NOT_YET_VALID | The certificate's validity start date is in the future |
| 401 | PUB_CERT_EXPIRED | The certificate has expired |
| 401 | PUB_CERT_NOT_REGISTERED | The certificate is not registered or has been revoked |
| 401 | PUB_INVALID_CREDENTIALS | Wrong clientId or clientSecret |
| 403 | PUB_CERT_NOT_AUTHORIZED_FOR_ACCOUNT | The certificate is not linked to the clientId account |
| 403 | PUB_ACCOUNT_INACTIVE | Correct credentials, but the account is not active |
| 502 | PUB_AUTH_UPSTREAM_ERROR | Unexpected error in the authentication service |
| 503 | PUB_AUTH_UPSTREAM_UNAVAILABLE | Authentication service temporarily unavailable |
PUB_CERT_HEADER_MISSING (400)
No client certificate was presented in the TLS handshake.
How to fix: configure your HTTP client to send the certificate and private key in the TLS connection (for example, curl --cert client.crt --key client.key). If there is a proxy or gateway between your application and FyPay that terminates TLS, the connection to api.fypay.me must come from a client that presents the certificate.
PUB_CERT_MALFORMED_PEM (400)
Certificate in invalid PEM format.
PUB_REQUEST_BODY_INVALID (400)
The JSON body did not match the schema (missing field, wrong type, or clientId is not a UUID v4). Check details.violations to identify the offending field(s) and send Content-Type: application/json.
PUB_CERT_NOT_VERIFIED (401)
The certificate presented in the TLS handshake was rejected.
How to fix: use the certificate issued by FyPay for your account and its matching private key. Certificates from other certificate authorities are not accepted.
PUB_CERT_NOT_YET_VALID (401)
The certificate's notBefore date is in the future.
How to fix: synchronize the system clock via NTP. If the date really is in the future, wait or request a new certificate.
PUB_CERT_EXPIRED (401)
The certificate's notAfter date has passed. Certificates are valid for 2 years from issuance.
How to fix: request a new certificate from support and replace it in your integration. Plan the replacement before expiration.
PUB_CERT_NOT_REGISTERED (401)
The certificate's SHA-256 fingerprint is not in the registry: it was never registered or has been revoked.
How to fix: compute the local fingerprint with openssl x509 -noout -fingerprint -sha256 -in client.crt and send it to support together with the errorId from the response.
PUB_INVALID_CREDENTIALS (401)
The clientId is not recognized, or the clientSecret does not match. The platform deliberately does not distinguish these cases (enumeration resistance).
How to fix: check both clientId and clientSecret. Since the clientSecret is shown only once, if you lost it generate a new credential under Settings → Credentials.
PUB_CERT_NOT_AUTHORIZED_FOR_ACCOUNT (403)
The certificate is valid and registered, but it is not linked to the account that owns the given clientId.
How to fix: make sure you are using the certificate/clientId pair from the same account.
PUB_ACCOUNT_INACTIVE (403)
The credentials are correct, but the account is not active. Retrying will not succeed.
How to fix: contact support with the errorId from the response.
PUB_AUTH_UPSTREAM_UNAVAILABLE (503)
The authentication service is temporarily unreachable.
How to fix: retry with exponential backoff (for example, 1s, 2s, 4s). If the problem persists for more than one minute, contact support.
PUB_AUTH_UPSTREAM_ERROR (502)
The authentication service returned an unexpected error.
How to fix: contact support with the errorId.
Troubleshooting
"I get PUB_CERT_HEADER_MISSING even though I send the certificate"
The certificate must go in the TLS handshake, not in an HTTP header. Make sure your HTTP client is configured with certificate and key (for example, --cert/--key in curl, cert=(crt, key) in Python requests) and that no intermediate proxy terminates TLS before reaching FyPay.
"I occasionally get PUB_CERT_NOT_YET_VALID"
The system clock is behind the certificate's notBefore date. Configure NTP and keep the host clock within a few seconds of UTC. Containers may inherit host clock drift; check with date -u inside the container.
"I get PUB_CERT_NOT_REGISTERED after replacing the certificate"
Compute the new certificate's fingerprint (openssl x509 -noout -fingerprint -sha256 -in client.crt) and confirm with support that it is registered and linked to your account before switching over.