# Introduction
Integrate deposit and withdrawal payment functionality into your applications with the Mangir Payment API.
Secure single-use tokens with 12-hour validity
Payment Channel (Iframe) and Direct API
Signed notifications with signature verification
5 automatic retry attempts for failed callbacks
Integration Flow
- Obtain API credentials (Public Key and Secret Key) from your merchant dashboard
- Authenticate to receive a JWT token
- Use the token to create deposit or withdrawal transactions
- Handle callback notifications for transaction status updates
# Authentication
All API endpoints require JWT token authentication obtained with your merchant credentials.
Request Body
{
"publicKey": "your-public-key",
"secretKey": "your-secret-key"
}
Success Response
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Error Response
{
"success": false,
"error": {
"code": "AUTH_001",
"message": "Invalid credentials"
}
}
Token Rules
- 12-hour validity: Tokens expire 12 hours after issuance.
- Single-use after success: Tokens are consumed only after a successful
Sendoperation. - Failed operations don't consume tokens: Validation errors, insufficient balance, etc. leave the token valid.
- Server-side only: Store tokens securely on your server; never expose them to client-side code.
public class AuthenticationService
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
public AuthenticationService(HttpClient httpClient, string baseUrl)
{
_httpClient = httpClient;
_baseUrl = baseUrl;
}
public async Task<AuthResponse> AuthenticateAsync(string publicKey, string secretKey)
{
var request = new { publicKey, secretKey };
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync($"{_baseUrl}/api/Account/Authenticate", content);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
var error = JsonSerializer.Deserialize<ErrorResponse>(body);
throw new ApiException(error?.Error?.Code ?? "UNKNOWN", error?.Error?.Message ?? "Authentication failed");
}
var result = JsonSerializer.Deserialize<AuthResponse>(body);
if (result == null || !result.Success)
throw new ApiException("AUTH_FAILED", "Authentication response was unsuccessful");
return result;
}
}
// Usage:
var httpClient = new HttpClient();
var authService = new AuthenticationService(httpClient, "https://payo.mangir.biz");
var auth = await authService.AuthenticateAsync("your-public-key", "your-secret-key");
httpClient.DefaultRequestHeaders.Add("token", auth.Token);
<?php
function authenticate(string $publicKey, string $secretKey): string
{
$ch = curl_init('https://payo.mangir.biz/api/Account/Authenticate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'publicKey' => $publicKey,
'secretKey' => $secretKey
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("Authentication failed: HTTP $httpCode");
}
$data = json_decode($response, true);
if (!$data['success']) {
throw new Exception("Auth error: " . ($data['error']['message'] ?? 'Unknown'));
}
return $data['token'];
}
// Usage:
$token = authenticate('your-public-key', 'your-secret-key');
?>
# Deposit API
Two integration methods are available. Choose the one that best fits your application architecture.
GET /RandomBankAccountsPOST /Deposit/SendPayment Channel Integration
Redirect your user to our hosted payment page. We handle the entire payment UI including bank selection, confirmation, and waiting screen.
Payment URL Format
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | REQUIRED | JWT token from authentication |
fullName | string | REQUIRED | Customer's full name (max 50 characters) |
username | string | REQUIRED | Customer's username or identifier (max 50 characters) |
amount | decimal | REQUIRED | Deposit amount |
merchantOrderId | string | Optional | Your internal order ID for tracking (max 100 characters) |
Integration Flow
- Your server authenticates and gets a JWT token
- Your server builds the payment URL with customer details
- Customer is redirected to the payment page (or iframe is loaded)
- Customer selects a bank account from available options
- Customer makes the bank transfer and confirms on the payment page
- Our staff reviews and approves/rejects the transaction
- A callback is sent to your server with the result
public async Task<string> GetPaymentUrlAsync(
string publicKey, string secretKey,
string fullName, string username, decimal amount, string merchantOrderId = null)
{
var auth = await _authService.AuthenticateAsync(publicKey, secretKey);
var queryParams = new Dictionary<string, string>
{
{ "token", auth.Token },
{ "fullName", Uri.EscapeDataString(fullName) },
{ "username", Uri.EscapeDataString(username) },
{ "amount", amount.ToString("F2", CultureInfo.InvariantCulture) }
};
if (!string.IsNullOrEmpty(merchantOrderId))
queryParams.Add("merchantOrderId", Uri.EscapeDataString(merchantOrderId));
var qs = string.Join("&", queryParams.Select(p => $"{p.Key}={p.Value}"));
return $"https://pay.mangir.biz/Deposit?{qs}";
}
// Usage in Controller:
var paymentUrl = await GetPaymentUrlAsync(publicKey, secretKey, "John Doe", "johndoe", 1000m, "ORDER-123");
return Redirect(paymentUrl);
// or return View with iframe: <iframe src="@paymentUrl" width="100%" height="600"></iframe>
<?php
$token = authenticate('your-public-key', 'your-secret-key');
$params = http_build_query([
'token' => $token,
'fullName' => 'John Doe',
'username' => 'johndoe',
'amount' => number_format(1000, 2, '.', ''),
'merchantOrderId' => 'ORDER-123'
]);
$paymentUrl = "https://pay.mangir.biz/Deposit?{$params}";
// Option 1: Redirect
header("Location: $paymentUrl");
// Option 2: Iframe
// echo '<iframe src="' . htmlspecialchars($paymentUrl) . '" width="100%" height="600"></iframe>';
?>
Direct API Integration
Build your own payment UI and call our API directly from your backend. Full control over the user experience.
Authenticate with your credentials. See the Authentication section for details.
Headers
| Header | Value | Required |
|---|---|---|
token | JWT token from Step 1 | REQUIRED |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
depositAmount | decimal | REQUIRED | The deposit amount to filter eligible bank accounts |
bankId | guid | Optional | Filter by a specific bank ID |
Response
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"bankId": "7a2e4c89-1234-5678-abcd-ef0123456789",
"bankName": "Sample Bank",
"accountHolder": "Account Holder Name",
"iban": "TR330006100519786457841326",
"minDeposit": 100.00,
"maxDeposit": 10000.00
}
Headers
| Header | Value | Required |
|---|---|---|
token | JWT token from Step 1 | REQUIRED |
Request Body
{
"userName": "johndoe",
"fullName": "John Doe",
"amount": 1000.00,
"bankAccountId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"notes": "Optional notes",
"merchantOrderId": "MERCH-001"
}
Field Validations
| Field | Type | Required | Max Length | Description |
|---|---|---|---|---|
userName | string | REQUIRED | 50 | Customer's username or identifier |
fullName | string | REQUIRED | 50 | Customer's full name |
amount | decimal | REQUIRED | - | Must be within bank account min/max limits |
bankAccountId | guid | REQUIRED | - | Bank account ID from Step 2 |
notes | string | Optional | 500 | Optional notes for the transaction |
merchantOrderId | string | Optional | 100 | Your internal order ID |
Success Response
{
"success": true,
"message": "Deposit transaction initiated successfully",
"transactionId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"bankAccountId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"orderNo": "12345678",
"merchantOrderId": "MERCH-001",
"callbackUrl": "https://your-site.com/callback",
"bankInfo": {
"bankName": "Sample Bank",
"accountHolder": "Account Holder Name",
"iban": "TR330006100519786457841326",
"minAmount": 100.00,
"maxAmount": 10000.00
}
}
public class DirectDepositService
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly string _publicKey;
private readonly string _secretKey;
public DirectDepositService(HttpClient httpClient, string baseUrl, string publicKey, string secretKey)
{
_httpClient = httpClient;
_baseUrl = baseUrl;
_publicKey = publicKey;
_secretKey = secretKey;
}
public async Task<string> GetTokenAsync()
{
var request = new { publicKey = _publicKey, secretKey = _secretKey };
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync($"{_baseUrl}/api/Account/Authenticate", content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<JsonElement>(body);
return result.GetProperty("token").GetString();
}
public async Task<BankAccountInfo> GetBankAccountAsync(string token, decimal amount)
{
var request = new HttpRequestMessage(HttpMethod.Get,
$"{_baseUrl}/api/Deposit/RandomBankAccounts?depositAmount={amount}");
request.Headers.Add("token", token);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<BankAccountInfo>(body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
public async Task<DepositResponse> CreateDepositAsync(string token, DepositRequest depositRequest)
{
var json = JsonSerializer.Serialize(depositRequest);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/Deposit/Send");
request.Headers.Add("token", token);
request.Content = content;
var response = await _httpClient.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<DepositResponse>(body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
// Complete Flow
public async Task<DepositResponse> ProcessDepositAsync(
string userName, string fullName, decimal amount, string merchantOrderId = null)
{
var token = await GetTokenAsync();
var bankAccount = await GetBankAccountAsync(token, amount);
if (bankAccount == null)
throw new Exception("No available bank account for the requested amount");
return await CreateDepositAsync(token, new DepositRequest
{
UserName = userName,
FullName = fullName,
Amount = amount,
BankAccountId = bankAccount.Id,
MerchantOrderId = merchantOrderId
});
}
}
// Usage:
var service = new DirectDepositService(new HttpClient(), "https://payo.mangir.biz", "your-public-key", "your-secret-key");
var result = await service.ProcessDepositAsync("johndoe", "John Doe", 1000m, "ORDER-001");
if (result.Success)
Console.WriteLine($"Order No: {result.OrderNo}, IBAN: {result.BankInfo.Iban}");
<?php
class DirectDepositService
{
private string $baseUrl = 'https://payo.mangir.biz';
private string $publicKey;
private string $secretKey;
public function __construct(string $publicKey, string $secretKey)
{
$this->publicKey = $publicKey;
$this->secretKey = $secretKey;
}
public function getToken(): string
{
$response = $this->request('POST', '/api/Account/Authenticate', [
'publicKey' => $this->publicKey,
'secretKey' => $this->secretKey
]);
return $response['token'];
}
public function getBankAccount(string $token, float $amount): array
{
return $this->request('GET', "/api/Deposit/RandomBankAccounts?depositAmount=$amount", null, ['token: ' . $token]);
}
public function createDeposit(string $token, array $data): array
{
return $this->request('POST', '/api/Deposit/Send', $data, ['token: ' . $token]);
}
public function processDeposit(string $userName, string $fullName, float $amount, ?string $merchantOrderId = null): array
{
$token = $this->getToken();
$bank = $this->getBankAccount($token, $amount);
return $this->createDeposit($token, [
'userName' => $userName, 'fullName' => $fullName,
'amount' => $amount, 'bankAccountId' => $bank['id'],
'merchantOrderId' => $merchantOrderId
]);
}
private function request(string $method, string $path, ?array $data = null, array $headers = []): array
{
$ch = curl_init($this->baseUrl . $path);
$allHeaders = array_merge(['Content-Type: application/json'], $headers);
$opts = [CURLOPT_HTTPHEADER => $allHeaders, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30];
if ($method === 'POST') { $opts[CURLOPT_POST] = true; if ($data) $opts[CURLOPT_POSTFIELDS] = json_encode($data); }
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true) ?? [];
}
}
// Usage:
$service = new DirectDepositService('your-public-key', 'your-secret-key');
$result = $service->processDeposit('johndoe', 'John Doe', 1000.00, 'ORDER-001');
if ($result['success']) {
echo "Order: " . $result['orderNo'] . " | IBAN: " . $result['bankInfo']['iban'];
}
?>
- All API calls must be made from your backend server, never from client-side JavaScript.
RandomBankAccountsdoes not consume the token. You can retry multiple times.Senddoes consume the token on success. Authenticate again for the next transaction.- Always validate the deposit amount falls within the bank's
minDeposit/maxDepositrange. - Store the
orderNofor tracking and reconciliation.
# Withdraw API
Create withdrawal transactions for your customers. Withdrawals are processed by staff and sent to the customer's bank account.
Step 1: Get Bank List
| Header | Value | Required |
|---|---|---|
token | JWT token | REQUIRED |
Response
[
{
"id": "7a2e4c89-1234-5678-abcd-ef0123456789",
"bankName": "Ziraat Bankasi",
"bankCode": "0010"
},
{
"id": "8b3f5d90-2345-6789-bcde-f01234567890",
"bankName": "Garanti BBVA",
"bankCode": "0062"
}
]
Step 2: Create Withdrawal
| Header | Value | Required |
|---|---|---|
token | JWT token | REQUIRED |
Request Body
{
"userName": "johndoe",
"fullName": "John Doe",
"iban": "TR330006100519786457841326",
"amount": 500.00,
"bankId": "7a2e4c89-1234-5678-abcd-ef0123456789",
"notes": "Optional notes",
"merchantOrderId": "WITHDRAW-001"
}
Field Validations
| Field | Type | Required | Max | Description |
|---|---|---|---|---|
userName | string | REQUIRED | 50 | Customer's username |
fullName | string | REQUIRED | 50 | Must match bank account holder |
iban | string | REQUIRED | 34 | Turkish format: TR + 24 digits |
amount | decimal | REQUIRED | - | Must not exceed merchant balance |
bankId | guid | REQUIRED | - | Bank ID from bank list |
notes | string | Optional | 500 | Optional notes |
merchantOrderId | string | Optional | 100 | Your internal order reference |
Success Response
{
"success": true,
"message": "Withdrawal request created successfully",
"orderNo": "87654321"
}
Error Response
{
"success": false,
"error": {
"code": "BAL_001",
"message": "Insufficient balance"
}
}
public class WithdrawService
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
public WithdrawService(HttpClient httpClient, string baseUrl)
{
_httpClient = httpClient;
_baseUrl = baseUrl;
}
public async Task<List<BankListItem>> GetBanksAsync(string token)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/api/Withdraw/Banks");
request.Headers.Add("token", token);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<BankListItem>>(body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
public async Task<WithdrawResponse> CreateWithdrawalAsync(string token, WithdrawRequest req)
{
var json = JsonSerializer.Serialize(req);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/Withdraw/Send");
request.Headers.Add("token", token);
request.Content = content;
var response = await _httpClient.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<WithdrawResponse>(body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
}
<?php
// Get bank list
function getBanks(string $token): array {
$ch = curl_init('https://payo.mangir.biz/api/Withdraw/Banks');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'token: ' . $token],
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch); curl_close($ch);
return json_decode($response, true) ?? [];
}
// Create withdrawal
function createWithdrawal(string $token, array $data): array {
$ch = curl_init('https://payo.mangir.biz/api/Withdraw/Send');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'token: ' . $token],
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch); curl_close($ch);
return json_decode($response, true) ?? [];
}
// Usage:
$banks = getBanks($token);
$result = createWithdrawal($token, [
'userName' => 'johndoe', 'fullName' => 'John Doe',
'iban' => 'TR330006100519786457841326', 'amount' => 500.00,
'bankId' => $banks[0]['id'], 'merchantOrderId' => 'WITHDRAW-001'
]);
?>
# Callback System
HTTP POST notifications sent to your server when transaction status changes.
Status Codes
| Code | Status | Description |
|---|---|---|
0 | Pending | Transaction is waiting to be processed |
1 | Reserved | Transaction has been picked up by staff |
2 | Completed | Transaction has been approved and completed |
3 | Rejected | Transaction has been rejected |
Transaction Types
| Code | Type |
|---|---|
1 | Deposit |
2 | Withdrawal |
Callback Payload
{
"orderNo": "12345678",
"merchantOrderId": "MERCH-001",
"status": 2,
"transactionType": 1,
"amount": 1000.00,
"message": "Transaction approved"
}
Callback Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Mangir-Signature | HMAC-SHA256 signature for verification |
X-Mangir-Timestamp | Unix timestamp when callback was generated |
Implementation Requirements
- Your callback endpoint must accept POST requests
- Return HTTP 200 OK to acknowledge receipt
- Process the callback within 30 seconds
- Verify the
X-Mangir-Signatureheader for security - Implement idempotency: handle duplicate callbacks gracefully
- Non-200 status codes trigger automatic retry
[ApiController, Route("api/[controller]")]
public class CallbackController : ControllerBase
{
private readonly string _secretKey;
public CallbackController(IConfiguration config)
{
_secretKey = config["MangirApi:SecretKey"];
}
[HttpPost]
public async Task<IActionResult> HandleCallback([FromBody] CallbackRequest request)
{
var signature = Request.Headers["X-Mangir-Signature"].FirstOrDefault();
var timestamp = Request.Headers["X-Mangir-Timestamp"].FirstOrDefault();
if (!VerifySignature(request, signature, timestamp))
return Unauthorized("Invalid signature");
switch (request.Status)
{
case 2: // Completed
await ProcessCompletedTransaction(request);
break;
case 3: // Rejected
await ProcessRejectedTransaction(request);
break;
}
return Ok(new { success = true });
}
private bool VerifySignature(CallbackRequest request, string signature, string timestamp)
{
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp)) return false;
var signatureService = new SignatureService(_secretKey);
return signatureService.VerifyCallback(request, signature, timestamp);
}
}
<?php
$rawBody = file_get_contents('php://input');
$data = json_decode($rawBody, true);
$signature = $_SERVER['HTTP_X_MANGIR_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_MANGIR_TIMESTAMP'] ?? '';
// Verify signature
if (!verifySignature($data, $signature, $timestamp, 'your-secret-key')) {
http_response_code(401);
die(json_encode(['error' => 'Invalid signature']));
}
// Process
switch ($data['status'] ?? 0) {
case 2: // Completed
// markTransactionComplete($data['orderNo'], $data['amount']);
break;
case 3: // Rejected
// markTransactionRejected($data['orderNo'], $data['message']);
break;
}
http_response_code(200);
echo json_encode(['success' => true]);
?>
Retry Mechanism
If your endpoint does not return HTTP 200, we retry automatically:
| Attempt | Delay | Total Elapsed |
|---|---|---|
| 1st (initial) | Immediate | 0 minutes |
| 2nd retry | 1 minute | 1 minute |
| 3rd retry | 5 minutes | 6 minutes |
| 4th retry | 15 minutes | 21 minutes |
| 5th retry (final) | 30 minutes | 51 minutes |
Testing Callbacks
{
"merchantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"merchantOrderId": "TEST-001"
}
- Test callbacks use the prefix
TEST_in the orderNo field - Test callbacks do not create actual transactions
- Use this to verify your endpoint works before going live
# Callback Security
HMAC-SHA256 signature verification to ensure callback authenticity and prevent tampering.
Signature Headers
| Header | Description |
|---|---|
X-Mangir-Signature | Base64-encoded HMAC-SHA256 signature |
X-Mangir-Timestamp | Unix timestamp (seconds since epoch) |
Verification Steps
- Sort all callback JSON fields alphabetically by key name
- Join them as
key1=value1&key2=value2&... - Append the timestamp:
dataString|timestamp - Compute HMAC-SHA256 using your Secret Key
- Base64 encode the result
- Compare with the
X-Mangir-Signatureheader
Step-by-Step Example
Given callback data:
{
"orderNo": "12345678",
"merchantOrderId": "MERCH-001",
"status": 2,
"transactionType": 1,
"amount": 1000.00,
"message": "Transaction approved"
}
Timestamp: 1704067200 | Secret Key: your-secret-key
1. Sort alphabetically by key
amount, merchantOrderId, message, orderNo, status, transactionType2. Join as key=value pairs
amount=1000.00&merchantOrderId=MERCH-001&message=Transaction approved&orderNo=12345678&status=2&transactionType=13. Append timestamp with pipe separator
amount=1000.00&merchantOrderId=MERCH-001&message=Transaction approved&orderNo=12345678&status=2&transactionType=1|17040672004-5. Compute HMAC-SHA256 and Base64 encode
X-Mangir-Signature: {base64-encoded-hmac-result}public class SignatureService
{
private readonly string _secretKey;
public SignatureService(string secretKey) { _secretKey = secretKey; }
public string GenerateSignature(Dictionary<string, string> data, string timestamp)
{
var sortedPairs = data.OrderBy(kvp => kvp.Key).Select(kvp => $"{kvp.Key}={kvp.Value}");
var dataString = string.Join("&", sortedPairs);
var signatureBase = $"{dataString}|{timestamp}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secretKey));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signatureBase));
return Convert.ToBase64String(hash);
}
public bool VerifyCallback(CallbackRequest request, string signature, string timestamp)
{
var data = new Dictionary<string, string>
{
{ "orderNo", request.OrderNo },
{ "merchantOrderId", request.MerchantOrderId ?? "" },
{ "status", request.Status.ToString() },
{ "transactionType", request.TransactionType.ToString() },
{ "amount", request.Amount.ToString("F2", CultureInfo.InvariantCulture) },
{ "message", request.Message ?? "" }
};
var expected = GenerateSignature(data, timestamp);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));
}
}
<?php
function verifySignature(array $data, string $signature, string $timestamp, string $secretKey): bool
{
ksort($data);
$pairs = [];
foreach ($data as $key => $value) { $pairs[] = "$key=$value"; }
$dataString = implode('&', $pairs);
$signatureBase = "$dataString|$timestamp";
$expected = base64_encode(hash_hmac('sha256', $signatureBase, $secretKey, true));
return hash_equals($expected, $signature);
}
?>
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
public class SignatureService {
private final String secretKey;
public SignatureService(String secretKey) { this.secretKey = secretKey; }
public String generateSignature(Map<String, String> data, String timestamp) throws Exception {
String dataString = data.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining("&"));
String signatureBase = dataString + "|" + timestamp;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(signatureBase.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
}
public boolean verifyCallback(Map<String, String> data, String signature, String timestamp) throws Exception {
String expected = generateSignature(data, timestamp);
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8));
}
}
Common Mistakes
- Not sorting alphabetically: Keys must be in ASCII alphabetical order
- Number formatting: Use dot (.) as decimal separator with 2 decimal places (
1000.00not1000) - Null values: Treat null/missing fields as empty strings (
"") - Encoding: Use UTF-8 throughout
- Timestamp validation: Reject callbacks older than 5 minutes to prevent replay attacks
- String comparison: Always use constant-time comparison (
hash_equalsin PHP,CryptographicOperations.FixedTimeEqualsin C#)
# Security Recommendations
Best practices for a secure integration.
- HTTPS Only: All API communication must use HTTPS
- Credential Storage: Store API keys in environment variables or a secure vault
- IP Whitelisting: Configure allowed IPs in your merchant dashboard
- Token Security: Never expose JWT tokens in client-side code, URLs, or logs
- Input Validation: Validate all inputs before sending to the API
- Callback Verification: Always verify HMAC-SHA256 signatures
- Idempotency: Handle duplicate callbacks gracefully
- Rate Limiting: Implement client-side rate limiting
- Logging: Log API interactions but never log sensitive data
- Error Handling: Don't expose internal error details to end users
Retry Strategy
| Error Type | Should Retry? | Codes |
|---|---|---|
| System errors | Yes | SYS_xxx |
| Validation errors | No | VAL_xxx |
| Bank errors | Conditional | BANK_xxx |
public static HttpClient CreateSecureClient()
{
var handler = new HttpClientHandler
{
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
System.Security.Authentication.SslProtocols.Tls13
};
var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}
# Error Codes
Consistent error format with machine-readable codes and human-readable messages.
Error Response Format
{
"success": false,
"error": {
"code": "AUTH_001",
"message": "Invalid credentials"
}
}
Authentication Errors
| Code | Message | Description |
|---|---|---|
AUTH_001 | Invalid credentials | Public key or secret key is incorrect |
AUTH_002 | Token expired | JWT token has expired (12-hour validity) |
AUTH_003 | Token already used | Single-use token has been consumed |
VAL_002 | Missing token | Token header not provided |
Validation Errors
| Code | Message | Description |
|---|---|---|
VAL_001 | Validation failed | One or more request fields are invalid |
VAL_003 | Invalid amount | Amount is outside acceptable range |
VAL_004 | Invalid IBAN | IBAN format is invalid (withdraw only) |
VAL_005 | Field too long | String field exceeds maximum length |
VAL_006 | Required field missing | A required field is null or empty |
Bank / Balance Errors
| Code | Message | Description |
|---|---|---|
BANK_001 | No available bank account | No bank account available for the requested amount |
BANK_002 | Bank account not found | Specified bank account ID does not exist |
BANK_003 | Bank account inactive | Specified bank account is currently inactive |
BANK_004 | Invalid bank | Specified bank ID does not exist (withdraw only) |
BAL_001 | Insufficient balance | Merchant balance insufficient for withdrawal |
Transaction / System Errors
| Code | Message | Description |
|---|---|---|
TRANS_001 | Transaction creation failed | Internal error while creating transaction |
SEC_001 | IP not allowed | Request from non-whitelisted IP |
SEC_002 | Account suspended | Merchant account has been suspended |
SYS_001 | Internal server error | Unexpected error — retry the request |
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
public ErrorHandlingMiddleware(RequestDelegate next) { _next = next; }
public async Task InvokeAsync(HttpContext context)
{
try { await _next(context); }
catch (ApiException ex)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = ex.Code switch
{
"AUTH_001" or "AUTH_002" or "AUTH_003" or "VAL_002" => 401,
"SEC_001" or "SEC_002" => 403,
_ when ex.Code.StartsWith("VAL_") => 400,
_ when ex.Code.StartsWith("BANK_") || ex.Code.StartsWith("BAL_") => 400,
_ => 500
};
await context.Response.WriteAsJsonAsync(new {
success = false, error = new { code = ex.Code, message = ex.Message }
});
}
catch (Exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new {
success = false, error = new { code = "SYS_001", message = "An internal server error occurred." }
});
}
}
}
# MerchantOrderId Guide
Attach your internal order identifier to transactions for easy correlation and reconciliation.
merchantOrderIdis optional but highly recommended- Maximum length: 100 characters
- Returned in all callback notifications
- Returned in transaction creation responses
- Use for reconciliation and debugging
Usage in Payment Channel (URL Parameter)
https://pay.mangir.biz/Deposit?token={token}&fullName=John+Doe&username=johndoe&amount=1000&merchantOrderId=ORDER-2026-001
Usage in Direct API (JSON Body)
{
"userName": "johndoe",
"fullName": "John Doe",
"amount": 1000.00,
"bankAccountId": "...",
"merchantOrderId": "ORDER-2026-001"
}
In Callback Response
{
"orderNo": "12345678",
"merchantOrderId": "ORDER-2026-001",
"status": 2,
"transactionType": 1,
"amount": 1000.00,
"message": "Transaction approved"
}
public class TransactionTracker
{
private readonly ConcurrentDictionary<string, TransactionRecord> _transactions = new();
public string CreateOrder(decimal amount, string customerId)
{
var merchantOrderId = $"ORD-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid().ToString("N")[..8]}";
_transactions[merchantOrderId] = new TransactionRecord
{
MerchantOrderId = merchantOrderId,
Amount = amount,
CustomerId = customerId,
Status = "Pending",
CreatedAt = DateTime.UtcNow
};
return merchantOrderId;
}
public void UpdateFromCallback(string merchantOrderId, int status, string orderNo)
{
if (_transactions.TryGetValue(merchantOrderId, out var record))
{
record.OrderNo = orderNo;
record.Status = status == 2 ? "Completed" : status == 3 ? "Rejected" : "Processing";
record.UpdatedAt = DateTime.UtcNow;
}
}
}
public class TransactionRecord
{
public string MerchantOrderId { get; set; }
public string OrderNo { get; set; }
public decimal Amount { get; set; }
public string CustomerId { get; set; }
public string Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
# Version History
- Added Direct API as an officially supported integration method
- Restructured documentation with tabbed Deposit section
- Improved code examples for both Payment Channel and Direct API
- Enhanced security documentation
- Added
merchantOrderIdsupport across all endpoints - MerchantOrderId returned in callback notifications
- Updated API response models
- New deposit system with Payment Channel integration
- Extended token validity to 12 hours
- Improved bank account selection algorithm
- New callback signature verification system
- Introduced single-use JWT tokens
- Callback retry mechanism (5 attempts)
- HMAC-SHA256 callback signatures
- Added Withdraw API
- Bank list endpoint
- IBAN validation
- Initial release
- Basic deposit API
- Simple authentication
