Few things are more frustrating in backend development than an authentication flow that works perfectly in development but fails sporadically in production. If you are integrating the Payoneer API, you have likely encountered the dreaded invalid_grant error during the refresh_token grant exchange.
This error usually results in a hard crash of the integration, requiring manual intervention to re-authenticate the user. This article dissects the root cause of this error—specifically within the context of Payoneer’s strict token rotation policies—and provides production-ready solutions in Node.js and PHP.
The Root Cause: Token Rotation and Race Conditions
To fix the error, you must understand exactly why Payoneer rejects the request. The invalid_grant error code, defined in RFC 6749, is a catch-all for invalid authentication credentials. However, in the context of a refresh flow, it almost always points to Token Rotation violations.
How Payoneer Implements Rotation
Payoneer utilizes aggressive Refresh Token Rotation. This adds security but introduces complexity:
- You exchange a
refresh_token(Token A) for a new Access Token and a newrefresh_token(Token B). - Crucial: The moment Token A is used, it is revoked. It can never be used again.
- If you attempt to use Token A again, Payoneer returns
invalid_grant.
The Concurrency Problem
The error typically occurs in high-throughput applications due to a race condition:
- Request 1 fires. The app detects the Access Token is expired.
- Request 1 initiates the refresh flow using
Refresh Token A. - Request 2 fires milliseconds later. It also sees the Access Token is expired.
- Request 2 initiates the refresh flow using the same
Refresh Token A. - Request 1 succeeds.
Refresh Token Ais revoked;Refresh Token Bis issued. - Request 2 reaches the Payoneer server with
Refresh Token A. - Payoneer rejects Request 2 with
invalid_grant.
Because Request 2 failed, your application logic might erroneously assume the user's consent is lost and wipe the credentials from the database.
Solution Strategy: The Mutex Pattern
To solve this, we cannot allow parallel refresh requests. We must implement a Mutex (Mutual Exclusion) pattern.
The logic changes to:
- Check if the token is expired.
- Check if a refresh is already in progress.
- If a refresh is in progress, wait for it to finish and use the result.
- If not, lock the process, perform the refresh, update the storage, and release the lock.
Node.js Implementation: Promise Sharing
In a Node.js environment (single-threaded event loop), we can solve this in-memory using Promise sharing. We will use axios with interceptors to handle this automatically.
The Code
This implementation uses a singleton class to manage the token state. It queues incoming requests while a refresh is pending.
import axios from 'axios';
import { EventEmitter } from 'events';
// Configuration constants
const PAYONEER_AUTH_URL = 'https://login.payoneer.com/api/v2/oauth2/token';
const CLIENT_ID = process.env.PAYONEER_CLIENT_ID;
const CLIENT_SECRET = process.env.PAYONEER_CLIENT_SECRET;
class PayoneerAuthManager {
constructor() {
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = 0;
// Limits the refresh logic to one execution at a time
this.refreshPromise = null;
}
// Mimic database retrieval
async loadTokens() {
// TODO: Replace with actual DB call
this.accessToken = 'loaded_access_token';
this.refreshToken = 'loaded_refresh_token';
this.expiresAt = Date.now() + 3600 * 1000;
}
// Mimic database save
async saveTokens(data) {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.expiresAt = Date.now() + (data.expires_in * 1000);
// TODO: Write to DB here
console.log('Tokens rotated and saved.');
}
isTokenExpired() {
// Add a 60-second buffer to prevent edge-case failures due to network latency
return Date.now() >= (this.expiresAt - 60000);
}
async getValidToken() {
if (!this.isTokenExpired()) {
return this.accessToken;
}
// If a refresh is already running, return the existing promise
if (this.refreshPromise) {
console.log('Refresh already in progress. Waiting...');
await this.refreshPromise;
return this.accessToken;
}
// Start a new refresh process
this.refreshPromise = (async () => {
try {
console.log('Initiating token refresh...');
const response = await axios.post(PAYONEER_AUTH_URL, new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
}), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
await this.saveTokens(response.data);
} catch (error) {
console.error('Refresh failed', error.response?.data || error.message);
throw error; // Propagate error to caller
} finally {
// Reset the promise/lock so future refreshes can happen
this.refreshPromise = null;
}
})();
await this.refreshPromise;
return this.accessToken;
}
}
// Usage Example
const authManager = new PayoneerAuthManager();
const apiClient = axios.create({ baseURL: 'https://api.payoneer.com/v4' });
// Request Interceptor
apiClient.interceptors.request.use(async (config) => {
// Ensure we have a valid token before every request
const token = await authManager.getValidToken();
config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Simulating concurrent requests
(async () => {
await authManager.loadTokens();
// Fire 3 requests simultaneously. Only ONE network call to refresh will happen.
Promise.all([
apiClient.get('/accounts'),
apiClient.get('/balances'),
apiClient.get('/programs')
]).then(() => console.log('All requests completed successfully'))
.catch(err => console.error('Request batch failed', err.message));
})();
Why This Works
By checking if (this.refreshPromise), we ensure that even if 50 requests hit the interceptor simultaneously, the first one creates the promise, and the other 49 subscribe to it. They all resolve efficiently once the single refresh operation completes.
PHP Implementation: Database Locking
PHP runs in a multi-process environment (PHP-FPM). You cannot share memory variables (like the promise above) between requests easily. If two different users hit your API endpoint at the same time, two separate PHP processes will spawn.
To solve this in PHP, we need Database Locking or a distributed lock (like Redis). Below is a robust implementation using a database-driven approach, which is safer for typical LAMP/LEMP stacks.
The Logic
- Check DB for token expiry.
- If expired, attempt to acquire a "Refresh Lock".
- If lock acquired: call Payoneer, update tokens, release lock.
- If lock denied (another process is refreshing): wait 1 second and re-read the new token from the DB.
The Code
<?php
namespace App\Services;
use GuzzleHttp\Client;
use PDO;
use Exception;
class PayoneerTokenService {
private PDO $pdo;
private Client $httpClient;
private string $clientId;
private string $clientSecret;
public function __construct(PDO $pdo, string $id, string $secret) {
$this->pdo = $pdo;
$this->httpClient = new Client(['base_uri' => 'https://login.payoneer.com/']);
$this->clientId = $id;
$this->clientSecret = $secret;
}
public function getAccessToken(int $userId): string {
$stmt = $this->pdo->prepare("SELECT * FROM payoneer_tokens WHERE user_id = ?");
$stmt->execute([$userId]);
$tokenData = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$tokenData) {
throw new Exception("No Payoneer connection found.");
}
// Check if expired (with 60s buffer)
$expiresAt = strtotime($tokenData['expires_at']);
if (time() < ($expiresAt - 60)) {
return $tokenData['access_token'];
}
return $this->rotateTokenSafe($userId, $tokenData['refresh_token']);
}
private function rotateTokenSafe(int $userId, string $currentRefreshToken): string {
// 1. Attempt to lock. We use a transaction or a specific lock file.
// ideally, use Redis: $redis->set('lock:payoneer:'.$userId, 1, ['nx', 'ex' => 10]);
// Here, we simulate a DB lock using a STATUS check.
$this->pdo->beginTransaction();
// SELECT FOR UPDATE locks the row prevents race conditions in SQL
$stmt = $this->pdo->prepare("SELECT refresh_token, is_refreshing FROM payoneer_tokens WHERE user_id = ? FOR UPDATE");
$stmt->execute([$userId]);
$freshData = $stmt->fetch(PDO::FETCH_ASSOC);
// Scenario: Another process finished refreshing while we were waiting for the lock
if ($freshData['refresh_token'] !== $currentRefreshToken) {
$this->pdo->commit();
// The token changed! Return the new access token (requires fetching updated row)
return $this->fetchNewestAccessToken($userId);
}
// Mark as refreshing
$upd = $this->pdo->prepare("UPDATE payoneer_tokens SET is_refreshing = 1 WHERE user_id = ?");
$upd->execute([$userId]);
$this->pdo->commit(); // Release row lock, but we signaled we are working via 'is_refreshing'
try {
// 2. Perform external API Call
$response = $this->httpClient->post('api/v2/oauth2/token', [
'auth' => [$this->clientId, $this->clientSecret],
'form_params' => [
'grant_type' => 'refresh_token',
'refresh_token' => $currentRefreshToken
]
]);
$data = json_decode($response->getBody(), true);
// 3. Save new tokens
$newExpires = date('Y-m-d H:i:s', time() + $data['expires_in']);
$save = $this->pdo->prepare("
UPDATE payoneer_tokens
SET access_token = ?, refresh_token = ?, expires_at = ?, is_refreshing = 0
WHERE user_id = ?
");
$save->execute([$data['access_token'], $data['refresh_token'], $newExpires, $userId]);
return $data['access_token'];
} catch (Exception $e) {
// Reset refreshing flag on failure so we can retry
$this->pdo->prepare("UPDATE payoneer_tokens SET is_refreshing = 0 WHERE user_id = ?")->execute([$userId]);
throw $e;
}
}
private function fetchNewestAccessToken($userId) {
$stmt = $this->pdo->prepare("SELECT access_token FROM payoneer_tokens WHERE user_id = ?");
$stmt->execute([$userId]);
return $stmt->fetchColumn();
}
}
Deep Dive: Handling Clock Skew
A subtle reason for invalid_grant or unauthorized errors is Clock Skew.
Your server's time might differ from Payoneer's server time by a few seconds. If you attempt to use a token exactly at the second it expires, or if you attempt to refresh it exactly when you think it expired but Payoneer thinks it has 2 seconds left, you enter undefined behavior territory.
Best Practice: always subtract a buffer (e.g., 60 seconds) when calculating expiration logic.
// Good practice
const isExpired = Date.now() >= (expiresAt - 60000);
// Bad practice
const isExpired = Date.now() >= expiresAt;
By refreshing early, you ensure that the token used for the request is definitely valid, and you avoid edge cases where network latency pushes a valid token over the expiry edge during transit.
Common Pitfalls
1. Hardcoding the Redirect URI
Payoneer validates the redirect_uri strictly. While less common in refresh flows, ensure your environment variables match exactly what is registered in the Payoneer developer console.
2. Ignoring HTTP 429
If your retry logic is too aggressive when invalid_grant occurs, you may hit rate limits. Always implement an exponential backoff strategy. If a refresh fails with a non-recoverable error (like invalid_grant), do not retry. Stop immediately and flag the user for re-authentication.
Conclusion
The Payoneer invalid_grant error is rarely a bug in the Payoneer API; it is a symptom of improper state management in the consuming application. Because refresh tokens are one-time-use, your application must strictly serialize refresh requests.
By implementing the Mutex pattern in Node.js or Row Locking in PHP, you guarantee that only one refresh request travels to Payoneer at a time, ensuring a stable and resilient payment integration.