Authentication
UniPay uses OAuth 2.0 client credentials — machine to machine, with no user involved. Your server exchanges its client_id and
client_secret for an access token, then sends the token on every API call.
Get a token
Section titled “Get a token”POST /connect/tokenContent-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=unipay:bills{ "access_token": "eyJhbGciOiJSUzI1NiIs…", "token_type": "Bearer", "expires_in": 1799}- Send the credentials in the form body, as above, or as an
Authorization: Basic base64(client_id:client_secret)header. Either works; pick one. - URL-encode the values — a secret containing
+,&or#breaks otherwise. - Tokens last 30 minutes. No refresh token is issued or needed: ask for a new token instead.
- Every environment publishes its OAuth settings at
/.well-known/openid-configuration.
Then send the token on every call:
POST /api/app/payment-processor/lookupAuthorization: Bearer eyJhbGciOiJSUzI1NiIs…Your organisation is identified from the token. There is no tenant header to send.
Cache the token
Section titled “Cache the token”Request a token once and reuse it until shortly before it expires. Requesting one per call is slow, and the token endpoint is rate-limited to 10 requests a minute.
- Renew when about 60 seconds remain, so requests already in flight don’t carry an expired token.
- Hold one token per process and guard the renewal with a lock, so concurrent requests don’t all renew at once.
- If a call gets 401, drop the cached token, get a new one and retry once. A second 401 means the credentials are wrong or revoked — don’t loop.
- Keep the token in memory, never on disk, and never log it.
# Tokens last 30 minutes: reuse one for every call in that window.TOKEN=$(curl -sS --fail-with-body -X POST "$BASE_URL/connect/token" \ --data-urlencode "grant_type=client_credentials" \ --data-urlencode "client_id=$UNIPAY_CLIENT_ID" \ --data-urlencode "client_secret=$UNIPAY_CLIENT_SECRET" \ --data-urlencode "scope=unipay:bills" | jq -r .access_token)/// <summary>Calls UniPay with a cached access token, renewed 60 seconds before it expires.</summary>public sealed class UniPayClient(HttpClient http, string clientId, string clientSecret, string scope = "unipay:bills"){ private readonly SemaphoreSlim _gate = new(1, 1); private string? _token; private DateTimeOffset _renewAt;
private async Task<string> AccessTokenAsync(CancellationToken ct) { await _gate.WaitAsync(ct); try { if (_token is null || DateTimeOffset.UtcNow >= _renewAt) { using var response = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string> { ["grant_type"] = "client_credentials", ["client_id"] = clientId, ["client_secret"] = clientSecret, ["scope"] = scope, }), ct); response.EnsureSuccessStatusCode(); var token = (await response.Content.ReadFromJsonAsync<TokenResponse>(ct))!; _token = token.access_token; _renewAt = DateTimeOffset.UtcNow.AddSeconds(token.expires_in - 60); } return _token; } finally { _gate.Release(); } }
public async Task<T> PostAsync<T>(string path, object body, CancellationToken ct = default) { for (var attempt = 0; ; attempt++) { using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) }; request.Headers.Authorization = new("Bearer", await AccessTokenAsync(ct)); using var response = await http.SendAsync(request, ct); if (response.StatusCode == HttpStatusCode.Unauthorized && attempt == 0) { _token = null; // revoked or expired early: get a new token, retry once continue; } response.EnsureSuccessStatusCode(); // throws HttpRequestException with StatusCode return (await response.Content.ReadFromJsonAsync<T>(ct))!; } }
private sealed record TokenResponse(string access_token, int expires_in);}/** Calls UniPay with a cached access token, renewed 60 seconds before it expires. */static final class UniPayClient { private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); private final String credentials; private String token; private Instant renewAt = Instant.MIN;
UniPayClient(String clientId, String clientSecret, String scope) { credentials = form(Map.of( "grant_type", "client_credentials", "client_id", clientId, "client_secret", clientSecret, "scope", scope)); }
private synchronized String accessToken() throws IOException, InterruptedException { if (token == null || Instant.now().isAfter(renewAt)) { HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/connect/token")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/x-www-form-urlencoded") .POST(HttpRequest.BodyPublishers.ofString(credentials)) .build(); HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new IOException("Token request failed: HTTP " + response.statusCode()); } JsonNode body = JSON.readTree(response.body()); token = body.get("access_token").asText(); renewAt = Instant.now().plusSeconds(body.get("expires_in").asLong() - 60); } return token; }
JsonNode post(String path, Object body) throws IOException, InterruptedException { String json = JSON.writeValueAsString(body); for (int attempt = 0; ; attempt++) { HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + path)) .timeout(Duration.ofSeconds(30)) .header("Authorization", "Bearer " + accessToken()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 401 && attempt == 0) { synchronized (this) { token = null; // revoked or expired early: get a new token, retry once } continue; } if (response.statusCode() != 200) { throw new HttpStatusException(response.statusCode()); } return JSON.readTree(response.body()); } }
private static String form(Map<String, String> fields) { return fields.entrySet().stream() .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) .collect(Collectors.joining("&")); }}
static final class HttpStatusException extends IOException { final int status;
HttpStatusException(int status) { super("HTTP " + status); this.status = status; }}/** Calls UniPay with a cached access token, renewed 60 seconds before it expires. */class UniPayClient(clientId: String, clientSecret: String, scope: String = "unipay:bills") { private val http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build() private val credentials = mapOf( "grant_type" to "client_credentials", "client_id" to clientId, "client_secret" to clientSecret, "scope" to scope, ).entries.joinToString("&") { (k, v) -> "$k=${URLEncoder.encode(v, Charsets.UTF_8)}" } private var token: String? = null private var renewAt: Instant = Instant.MIN
@Synchronized private fun accessToken(): String { val current = token if (current != null && Instant.now().isBefore(renewAt)) return current val request = HttpRequest.newBuilder(URI.create("$BASE_URL/connect/token")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/x-www-form-urlencoded") .POST(HttpRequest.BodyPublishers.ofString(credentials)) .build() val response = http.send(request, HttpResponse.BodyHandlers.ofString()) if (response.statusCode() != 200) throw IOException("Token request failed: HTTP ${response.statusCode()}") val body = JSON.readTree(response.body()) renewAt = Instant.now().plusSeconds(body["expires_in"].asLong() - 60) return body["access_token"].asText().also { token = it } }
fun post(path: String, body: Any): JsonNode { val json = JSON.writeValueAsString(body) for (attempt in 0..1) { val request = HttpRequest.newBuilder(URI.create("$BASE_URL$path")) .timeout(Duration.ofSeconds(30)) .header("Authorization", "Bearer ${accessToken()}") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build() val response = http.send(request, HttpResponse.BodyHandlers.ofString()) if (response.statusCode() == 401 && attempt == 0) { synchronized(this) { token = null } // revoked or expired early: get a new token, retry once continue } if (response.statusCode() != 200) throw HttpStatusException(response.statusCode()) return JSON.readTree(response.body()) } throw HttpStatusException(401) }}
class HttpStatusException(val status: Int) : IOException("HTTP $status")class UniPayClient: """Calls UniPay with a cached access token, renewed 60 seconds before it expires."""
def __init__(self, client_id: str, client_secret: str, scope: str = "unipay:bills"): self._credentials = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": scope, } self._token = None self._renew_at = 0.0 self._lock = threading.Lock()
def _access_token(self) -> str: with self._lock: if self._token is None or time.monotonic() >= self._renew_at: response = requests.post(f"{BASE_URL}/connect/token", data=self._credentials, timeout=TIMEOUT) response.raise_for_status() body = response.json() self._token = body["access_token"] self._renew_at = time.monotonic() + body["expires_in"] - 60 return self._token
def post(self, path: str, body: dict) -> dict: for attempt in range(2): response = requests.post( f"{BASE_URL}{path}", json=body, headers={"Authorization": f"Bearer {self._access_token()}"}, timeout=TIMEOUT, ) if response.status_code == 401 and attempt == 0: self._token = None # revoked or expired early: get a new token, retry once continue response.raise_for_status() return response.json()class UniPayClient { private readonly credentials: URLSearchParams; private token: string | null = null; private renewAt = 0; private pending: Promise<string> | null = null;
constructor(clientId: string, clientSecret: string, scope = 'unipay:bills') { this.credentials = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope, }); }
/** A cached access token, renewed 60 seconds before it expires. */ private async accessToken(): Promise<string> { if (this.token && Date.now() < this.renewAt) return this.token; // One request for concurrent callers. this.pending ??= (async () => { const response = await fetch(`${BASE_URL}/connect/token`, { method: 'POST', body: this.credentials, signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`Token request failed: HTTP ${response.status}`); const body = (await response.json()) as { access_token: string; expires_in: number }; this.token = body.access_token; this.renewAt = Date.now() + (body.expires_in - 60) * 1000; return this.token; })().finally(() => (this.pending = null)); return this.pending; }
async post<T>(path: string, body: unknown): Promise<T> { for (let attempt = 0; ; attempt++) { const response = await fetch(`${BASE_URL}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${await this.accessToken()}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), }); if (response.status === 401 && attempt === 0) { this.token = null; // revoked or expired early: get a new token, retry once continue; } if (!response.ok) throw new HttpError(response.status); return (await response.json()) as T; } }}
class HttpError extends Error { readonly status: number; constructor(status: number) { super(`HTTP ${status}`); this.status = status; }}type UniPayClient struct { http *http.Client credentials url.Values mu sync.Mutex token string renewAt time.Time}
func NewUniPayClient(clientID, clientSecret, scope string) *UniPayClient { return &UniPayClient{ http: &http.Client{Timeout: 30 * time.Second}, credentials: url.Values{ "grant_type": {"client_credentials"}, "client_id": {clientID}, "client_secret": {clientSecret}, "scope": {scope}, }, }}
// accessToken returns a cached token, renewed 60 seconds before it expires.func (c *UniPayClient) accessToken() (string, error) { c.mu.Lock() defer c.mu.Unlock() if c.token != "" && time.Now().Before(c.renewAt) { return c.token, nil } resp, err := c.http.PostForm(baseURL+"/connect/token", c.credentials) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("token request failed: HTTP %d", resp.StatusCode) } var body struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return "", err } c.token = body.AccessToken c.renewAt = time.Now().Add(time.Duration(body.ExpiresIn-60) * time.Second) return c.token, nil}
type HTTPError struct{ Status int }
func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP %d", e.Status) }
// Post sends body as JSON and decodes the JSON response into out.func (c *UniPayClient) Post(path string, body, out any) error { payload, err := json.Marshal(body) if err != nil { return err } for attempt := 0; ; attempt++ { token, err := c.accessToken() if err != nil { return err } req, _ := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return err } if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { resp.Body.Close() c.mu.Lock() c.token = "" // revoked or expired early: get a new token, retry once c.mu.Unlock() continue } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return &HTTPError{resp.StatusCode} } decoder := json.NewDecoder(resp.Body) decoder.UseNumber() // keep amounts exact return decoder.Decode(out) }}/** Calls UniPay with a cached access token, renewed 60 seconds before it expires. */final class UniPayClient{ private ?string $token = null; private int $renewAt = 0;
public function __construct( private readonly string $baseUrl, private readonly string $clientId, private readonly string $clientSecret, private readonly string $scope = 'unipay:bills', ) { }
private function accessToken(): string { if ($this->token === null || time() >= $this->renewAt) { [$status, $body] = $this->send('/connect/token', http_build_query([ 'grant_type' => 'client_credentials', 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, 'scope' => $this->scope, ]), ['Content-Type: application/x-www-form-urlencoded']); if ($status !== 200) { throw new RuntimeException("Token request failed: HTTP $status"); } $this->token = $body['access_token']; $this->renewAt = time() + $body['expires_in'] - 60; } return $this->token; }
public function post(string $path, array $body): array { $json = json_encode($body, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); for ($attempt = 0; ; $attempt++) { [$status, $result] = $this->send($path, $json, [ 'Authorization: Bearer ' . $this->accessToken(), 'Content-Type: application/json', ]); if ($status === 401 && $attempt === 0) { $this->token = null; // revoked or expired early: get a new token, retry once continue; } if ($status !== 200) { throw new HttpStatusException($status); } return $result; } }
/** @return array{int, array} */ private function send(string $path, string $payload, array $headers): array { $curl = curl_init($this->baseUrl . $path); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => TIMEOUT_SECONDS, ]); $response = curl_exec($curl); if ($response === false) { $error = curl_error($curl); curl_close($curl); throw new NetworkException($error); } $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); return [$status, $response === '' ? [] : json_decode($response, true, 512, JSON_THROW_ON_ERROR)]; }}
final class HttpStatusException extends RuntimeException{ public function __construct(public readonly int $status) { parent::__construct("HTTP $status"); }}
final class NetworkException extends RuntimeException{}Scopes
Section titled “Scopes”Ask only for the scopes your integration needs, as a space-separated list in scope.
| Scope | Grants | Who needs it |
|---|---|---|
unipay:bills |
Processors: look up and pay bills. Providers: publish bills. | Everyone |
unipay:payments |
Read the payments and reversals made against your bills. Needs unipay:bills too. |
Service providers |
unipay:directory |
List the service providers you can pay. | Payment processors, optional |
unipay:directory.settlement |
Adds each provider’s settlement accounts to the directory, account numbers masked. | Processors that settle directly |
unipay:directory.settlement.reveal |
Reveals a settlement account’s full number. Every reveal is audited. | Only processors that initiate transfers |
A call without the scope it needs gets 403 Forbidden with an empty body; the WWW-Authenticate response header says
error="insufficient_access".
Credentials only work on their own side of UniPay: payment-processor credentials can’t call service-provider endpoints, and the reverse.
Such a call gets 403 with an error object whose code is Authorization:PartnerTypeMismatch.
When the token request fails
Section titled “When the token request fails”The token endpoint answers 401 (bad credentials) or 400 (anything else) with a standard OAuth error body — error, error_description
and error_uri:
error |
Meaning |
|---|---|
invalid_client |
Unknown client_id, or wrong client_secret. Check for encoding problems in the secret. |
invalid_scope |
A scope name UniPay doesn’t recognise. |
invalid_request |
A real scope your credential isn’t allowed. Ask for it to be added, or drop it. |
access_denied |
The credential has been disabled or revoked. |
unsupported_grant_type |
grant_type isn’t client_credentials. |
Security checklist
Section titled “Security checklist”- Keep
client_secretin a secrets vault. Never commit it or ship it to a phone, browser or POS device. - Use separate credentials for test and production, and for each system that calls UniPay.
- Rotate secrets at least every 90 days — see Managing credentials.
- Treat access tokens like passwords: keep them in memory, never log them or echo them in errors.
- Call UniPay only over HTTPS, and don’t disable certificate validation.
UniPay API v1 · build e1eb5c49 · Powered by Axis Solutions