Device-Only Setup
For merchants without a POS integration, payments can be accepted directly on the terminal by manually entering amounts. No API or SDK setup is required. Transactions are visible in your Xendit Dashboard.
Choose your integration path and process your first in-person payment
Pick the fastest route for your business now. You can switch to a deeper integration path later.
For merchants without a POS integration, payments can be accepted directly on the terminal by manually entering amounts. No API or SDK setup is required. Transactions are visible in your Xendit Dashboard.
Select the integration approach that best fits your technical requirements.
Host-to-Host (H2H) — Terminal API Recommended for most merchants | Client-to-Client (C2C) Terminal C2C API or Terminal C2C SDK |
|---|---|
| Your backend calls Xendit’s cloud HTTP API. Managed orchestration with minimal custom code. | Your POS talks to terminals over the local network via HTTP REST commands or native SDK methods. |
| Uses Terminal API sessions for payments, callbacks, and reconciliation. | Sends PAY / CANCEL / SETTLEMENT commands directly — via Terminal C2C API (HTTP) or Terminal C2C SDK (native). |
| Xendit manages device orchestration and infrastructure. | Your team owns device connectivity, error handling, and security. |
Your backend calls Xendit’s cloud API via HTTP
Your POS communicates directly with terminals over the local network
Choose the terminal providers you plan to integrate. You can select more than one.



Select at least one provider to continue.
terminal_id from your registered deviceterminal_id value (e.g., SIM001) — no registration needed4040404 as terminal_id to test invalid terminal error handlingCreate environment file
.env file in your project root:TERMINAL_API_KEY=your_dev_terminal_api_key
TERMINAL_BASE_URL=https://terminal-dev.xendit.co
CALLBACK_URL=http://localhost:3000/terminal/callbacks
TERMINAL_ID=TERM001
.env to your .gitignore file.Install dependencies
npm install node-fetch express dotenv
Set up webhook server
import express from 'express';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
app.use(express.json());
app.post('/terminal/callbacks', (req, res) => {
console.log('Event:', req.body?.event, req.body?.data?.status);
console.log('Full payload:', JSON.stringify(req.body, null, 2));
res.sendStatus(200);
});
app.listen(3000, () => {
console.log('Webhook server running on http://localhost:3000');
console.log('Use ngrok to expose this server for testing');
});
npm install -g ngrok && ngrok http 3000) to expose your local server publicly for webhook testing.Use any terminal ID
terminal_id value. The system will automatically simulate payment responses.TERMINAL_ID=SIM001 # Any value works for simulation
Test different scenarios
| Amount | Scenario | Expected Result |
|---|---|---|
400508 | Payment declined | Session created, transaction fails |
400509 | Channel unavailable | Session creation fails |
400711 | User cancellation | Session created, then canceled |
4040404 (as terminal_id) | Invalid terminal | Session creation fails |
| Any other amount | Success flow | Payment completes successfully |
10000 to test the success flow, then try the special amounts to test error handling.Run simulation test
import fetch from 'node-fetch';
import dotenv from 'dotenv';
dotenv.config();
async function testSimulation() {
const auth = 'Basic ' + Buffer.from(`${process.env.TERMINAL_API_KEY}:`).toString('base64');
// Test successful payment simulation
const successTest = await fetch(`${process.env.TERMINAL_BASE_URL}/v1/terminal/sessions`, {
method: 'POST',
headers: {
'Authorization': auth,
'Content-Type': 'application/json',
'Idempotency-key': `sim-success-${Date.now()}`,
},
body: JSON.stringify({
session_type: 'PAY',
mode: 'TERMINAL',
currency: 'IDR',
amount: 10000, // Normal amount = success
country: 'ID',
channel_properties: { terminal_id: 'SIM001' },
}),
});
const successData = await successTest.json();
console.log('✅ Success simulation:', successData.status);
// Test payment decline simulation
const declineTest = await fetch(`${process.env.TERMINAL_BASE_URL}/v1/terminal/sessions`, {
method: 'POST',
headers: {
'Authorization': auth,
'Content-Type': 'application/json',
'Idempotency-key': `sim-decline-${Date.now()}`,
},
body: JSON.stringify({
session_type: 'PAY',
mode: 'TERMINAL',
currency: 'IDR',
amount: 400508, // Special amount = decline
country: 'ID',
channel_properties: { terminal_id: 'SIM001' },
}),
});
const declineData = await declineTest.json();
console.log('⚠️ Decline simulation:', declineData.status);
}
testSimulation();
Verify terminal registration
Test API connectivity
import fetch from 'node-fetch';
import dotenv from 'dotenv';
dotenv.config();
async function testTerminal() {
const auth = 'Basic ' + Buffer.from(`${process.env.TERMINAL_API_KEY}:`).toString('base64');
const res = await fetch(`${process.env.TERMINAL_BASE_URL}/v1/terminal/sessions`, {
method: 'POST',
headers: {
'Authorization': auth,
'Content-Type': 'application/json',
'Idempotency-key': `test-${Date.now()}`,
},
body: JSON.stringify({
session_type: 'PAY',
mode: 'TERMINAL',
currency: 'IDR',
amount: 1000, // Small test amount
country: 'ID',
channel_properties: { terminal_id: process.env.TERMINAL_ID },
}),
});
const data = await res.json();
if (res.ok) {
console.log('✅ Terminal connection successful');
console.log('Session ID:', data.payment_session_id);
console.log('Status:', data.status);
} else {
console.log('❌ Error:', data.error_code, data.message);
}
}
testTerminal();
TERMINAL_NOT_FOUND error, your terminal may not be registered. Contact Xendit support to register it for test mode.curl -X POST 'https://terminal-dev.xendit.co/v1/terminal/sessions' \
-u 'YOUR_TERMINAL_API_KEY:' \
-H 'Content-Type: application/json' \
-H 'Idempotency-key: sim-success-'"$(date +%s)" \
-d '{
"session_type": "PAY",
"mode": "TERMINAL",
"currency": "IDR",
"amount": 10000,
"country": "ID",
"channel_properties": { "terminal_id": "SIM001" }
}'
ACTIVE status, then automatically transitions to COMPLETEDACTIVE status, then transitions to FAILEDcurl -X POST 'https://terminal-dev.xendit.co/v1/terminal/sessions' \
-u 'YOUR_TERMINAL_API_KEY:' \
-H 'Content-Type: application/json' \
-H 'Idempotency-key: payment-'"$(date +%s)" \
-d '{
"session_type": "PAY",
"mode": "TERMINAL",
"currency": "IDR",
"amount": 10000,
"country": "ID",
"channel_properties": { "terminal_id": "TERM001" }
}'
terminal_id matches your device configuration.terminal_session.completed, terminal_session.canceled, terminal_payment.succeeded, and others. See the Callbacks documentation for complete event types.curl -X GET 'https://terminal-dev.xendit.co/v1/terminal/payments/PAYMENT_ID' \
-u 'YOUR_TERMINAL_API_KEY:'
SUCCEEDED and amounts match your session.curl -X POST 'https://terminal-dev.xendit.co/v1/terminal/payments/PAYMENT_ID/void' \
-u 'YOUR_TERMINAL_API_KEY:'
401 Unauthorized
Simulation not working as expected
400508 (decline), 400509 (channel unavailable), 400711 (cancellation)terminal_id works for simulation - you don’t need a real terminal registeredTerminal not prompting (Physical terminals only)
terminal_id matches your device configuration exactlyWebhook not receiving callbacks
Environment variables not loading
.env file is in the correct locationdotenv.config() before using environment variables.env file is not committed to version controlReady to transition from simulation to physical terminal
https://terminal.xendit.coTerminal C2C API (HTTP) or Terminal C2C SDK (native methods)
Terminal C2C API HTTP REST commands | Terminal C2C SDK Native SDK method calls |
|---|---|
| Send REST commands (PAY, CANCEL, SETTLEMENT) to the Gateway App over the local network. | Call native Android/iOS SDK methods to talk to terminals directly from your app. |
| Best for multi-store fleets, web POS, or thick-client systems that call backend services. | Ideal for kiosks, custom POS apps, or deployments that need offline resilience and custom UX. |
| Works from any language or platform that can make HTTP requests. | Requires mobile engineers but unlocks granular device telemetry and fallback logic. |
Install and configure the Gateway App
Confirm terminal device information
Integrate using the C2C API
curl -X POST "http://{GATEWAY_IP}:8189/commands/pay" \
-H "Content-Type: application/json" \
-H "X-API-KEY: CLIENT_API_KEY" \
-H "provider: BRI" \
-H "simulation: true" \
-d '{
"terminal_id": "10362517",
"order_id": "ORDER123",
"request_amount": 10000,
"payment_method": "QRIS"
}'
simulation: true returns mock responses so you can test without a physical device. Remove the header when exercising real hardware; the value defaults to false.{
"order_id": "ORDER123",
"terminal_id": "10362517",
"terminal_reference": "1750407594445|1750407594",
"payment_method": "QRIS",
"amount": 10000,
"currency": "IDR",
"transaction_date": "2025-06-20T15:19:54+07:00",
"status": "SUCCESS"
}
Gateway App not responding
401 or 403 errors from C2C API
Terminal does not prompt for payment
Add provider dependencies
dependencies {
// Terminal C2C SDK
implementation("co.xendit.terminal:c2c-android:<latest_version>")
// Core Terminal SDK (required)
implementation("co.xendit.terminal:core-android:<latest_version>")
implementation("co.xendit.terminal:id-bri-android:<latest_version>")
}
# Add these provider frameworks using Xcode (General > Frameworks, Libraries, and Embedded Content).
# See /sdk/c2c/ios-sdk#installation for installation steps.
- Add TerminalBRI.xcframework via Xcode (General > Frameworks, Libraries, and Embedded Content). Follow /sdk/c2c/ios-sdk#installation.
Install and set up the C2C SDK
import co.xendit.terminal.c2c.TerminalC2C
import co.xendit.terminal.core.TerminalApp
class App : Application() {
override fun onCreate() {
super.onCreate()
TerminalApp.initialize(
context = this,
clientKey = CLIENT_KEY,
mode = TerminalMode.LIVE // or TerminalMode.INTEGRATION
)
TerminalC2C.addProvider(TerminalBRI)
}
}
import TerminalC2C
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
TerminalApp.shared.initialize(
clientKey: CLIENT_KEY,
mode: TerminalMode.live // or TerminalMode.integration
)
TerminalC2C.shared.addProvider(provider: TerminalBRI.shared)
return true
}
Configure the terminal device in your app
val terminalDevice = TerminalDevice.create(
id = "TID001",
ipAddress = "192.168.1.100",
provider = "BRI"
)
TerminalC2C.setTerminalDevice(terminalDevice)
let terminalDevice = TerminalDevice.companion.create(
id: "TID001",
ipAddress: "192.168.1.100",
provider: "BRI"
)
TerminalC2C.shared.setTerminalDevice(terminalDevice)
Run a test transaction
import co.xendit.terminal.c2c.TerminalC2C
import co.xendit.terminal.c2c.data.Payment
import co.xendit.terminal.c2c.data.TerminalException
import co.xendit.terminal.id.bri.BRIPaymentMethod // Provided by the BRI provider dependency
// Create payment data
val payment = Payment(
orderID = "ORDER_123",
amount = 10000.0,
paymentMethod = BRIPaymentMethod.contactless
)
// Execute payment (suspend function)
try {
val result = TerminalC2C.createPayment(
payment = payment,
isSimulation = true // Remove or set to false when using a physical terminal
)
println("Payment successful: ${'$'}{result.status}")
} catch (e: TerminalException) {
println("Payment failed: ${'$'}{e.message}")
} catch (e: Exception) {
println("Error: ${'$'}{e.message}")
}
import TerminalC2C
// Create payment data
let payment = Payment(
orderID: "ORDER_123",
amount: 10000.0,
paymentMethod: BRIPaymentMethod.contactless
)
// Execute payment
Task {
do {
let result = try await TerminalC2C.shared.createPayment(
payment: payment,
isSimulation: true // Remove or set to false when using a physical terminal
)
print("Payment successful: \(result.status)")
} catch let error as TerminalException {
print("Payment failed: \(error.message)")
} catch {
print("Error: \(error.localizedDescription)")
}
}
TerminalC2C.isSimulation = true globally before issuing requests or pass isSimulation = true to individual calls as shown above. Remove these simulation flags for real terminal runs.SDK initialization fails
Terminal remains offline
Payment command returns errors