"We need to expose our RPG programs as REST APIs." If you work with IBM i systems, you have heard this request. Your business needs to integrate with modern applications, mobile apps, and cloud services โ but your core business logic lives in RPG, COBOL, and CL programs that have run reliably for decades.
The good news? You do not need to rewrite everything. This article explores three practical approaches to REST API integration on IBM i, with real-world implementation details and lessons learned from actual modernization projects.
Modern applications expect REST APIs. Mobile apps require JSON responses. Cloud services communicate through HTTP endpoints. Partner integrations expect standard protocols. Microservices architectures rely on API communication.
Your IBM i system already contains the most valuable asset in your organization โ validated business logic that has evolved over years or decades. REST APIs make that logic accessible to the modern technology ecosystem without requiring a rewrite.
Traditional integration methods each carry significant limitations. Data queues are fast and reliable but IBM i-specific. File transfers are batch-oriented and unsuitable for real-time use. ODBC and JDBC provide database access but bypass business logic. Screen scraping is fragile and difficult to maintain.
| Approach | Complexity | Performance | Best Use Case |
|---|---|---|---|
| Integrated Web Services (IWS) | Low | High | Simple services, IBM-standardized environments |
| Node.js API Layer | Medium | Medium | Modern integration layers, microservices |
| Pure RPG CGI | High | Very High | Lightweight or performance-critical workloads |
IBM's built-in web services framework is included with IBM i. It allows ILE programs to be exposed as REST or SOAP services without any additional runtime or infrastructure.
The workflow is straightforward: create an RPG service program, export procedures, deploy through the IWS server, and access through HTTP endpoints.
**FREE
Ctl-Opt NoMain;
Dcl-Proc getCustomer Export;
Dcl-Pi *N Varchar(2000);
customerId Char(10) Const;
End-Pi;
Dcl-S jsonResponse Varchar(2000);
// Use JSON_OBJECT for clean, structured output
Exec SQL
SELECT JSON_OBJECT(
'customerId' VALUE CUSTNO,
'name' VALUE CUSTNAME,
'address' VALUE CUSTADDR,
'city' VALUE CUSTCITY,
'zip' VALUE CUSTZIP
)
INTO :jsonResponse
FROM CUSTMAST
WHERE CUSTNO = :customerId;
If SQLCODE <> 0;
jsonResponse = '{"error":"Customer not found"}';
EndIf;
Return jsonResponse;
End-Proc;
Node.js runs directly on IBM i and can call RPG programs using XMLSERVICE via itoolkit. This creates a modern API layer while leaving all business logic in RPG โ exactly where it belongs.
const express = require('express');
const { Connection, ProgramCall } = require('itoolkit');
const app = express();
const conn = new Connection({
transport: 'idb',
transportOptions: { database: '*LOCAL' }
});
app.get('/api/customer/:id', async (req, res) => {
try {
const customerId = req.params.id;
// Call RPG program directly
const pgm = new ProgramCall('CUSTINQ', { lib: 'MYLIB' });
pgm.addParam({ type: 'CHAR', length: 10, value: customerId });
pgm.addParam({ type: 'CHAR', length: 2000, io: 'out' });
conn.add(pgm);
const results = await conn.run();
res.json(JSON.parse(results[0].data));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(3000);
**FREE
Dcl-Pi *N;
customerId Char(10) Const;
jsonResponse Char(2000);
End-Pi;
Exec SQL
SELECT JSON_OBJECT(
'customerId' VALUE CUSTNO,
'name' VALUE CUSTNAME,
'address' VALUE CUSTADDR,
'city' VALUE CUSTCITY,
'zip' VALUE CUSTZIP
)
INTO :jsonResponse
FROM CUSTMAST
WHERE CUSTNO = :customerId;
If SQLCODE <> 0;
jsonResponse = '{"error":"Customer not found"}';
EndIf;
*InLR = *On;
Some organizations prefer implementing APIs entirely in RPG. Apache on IBM i supports CGI programs that can generate HTTP responses directly โ no additional runtime required.
**FREE
Ctl-Opt DftActGrp(*No);
Dcl-S requestMethod Varchar(10);
Dcl-S pathInfo Varchar(256);
Dcl-S customerId Char(10);
Dcl-S jsonResponse Varchar(5000);
// Get HTTP request details from environment
requestMethod = %Str(getenv('REQUEST_METHOD'));
pathInfo = %Str(getenv('PATH_INFO'));
// Parse customer ID from URL path
customerId = %Subst(pathInfo: 15: 10);
// Query and build JSON response
Exec SQL
SELECT JSON_OBJECT(
'customerId' VALUE CUSTNO,
'name' VALUE CUSTNAME,
'address' VALUE CUSTADDR,
'city' VALUE CUSTCITY,
'zip' VALUE CUSTZIP
)
INTO :jsonResponse
FROM CUSTMAST
WHERE CUSTNO = :customerId;
If SQLCODE <> 0;
jsonResponse = '{"error":"Customer not found"}';
EndIf;
// Write HTTP response
callp printf('Content-Type: application/json' + x'0a0a');
callp printf(%Trim(jsonResponse));
*InLR = *On;
Regardless of which approach you choose, one architectural principle applies to all three: APIs should never directly manipulate database tables. They should always call service programs or stored procedures.
This pattern provides centralized transaction control, consistent locking behavior, easier security management, and a single point to optimize, monitor, and audit. It also makes future refactoring significantly simpler.
**FREE
Dcl-Proc createOrder Export;
Dcl-Pi *N Varchar(2000);
orderJson Varchar(5000) Const;
End-Pi;
Dcl-S orderId Char(15);
Dcl-S customerId Char(10);
Dcl-S resultJson Varchar(2000);
Dcl-S customerCount Int(10);
Exec SQL SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
Exec SQL START TRANSACTION;
// Parse customer ID from input JSON
customerId = %Subst(orderJson: %Scan('"customerId":"': orderJson) + 14: 10);
// Validate customer exists
Exec SQL
SELECT COUNT(*) INTO :customerCount
FROM CUSTMAST
WHERE CUSTNO = :customerId;
If SQLCODE <> 0 Or customerCount = 0;
Exec SQL ROLLBACK;
Return '{"error":"Customer not found"}';
EndIf;
// Generate order and insert header
orderId = generateOrderId();
Exec SQL
INSERT INTO ORDHDR (ORDNO, CUSTNO, ORDDATE, ORDSTATUS)
VALUES (:orderId, :customerId, CURRENT DATE, 'PENDING');
If SQLCODE <> 0;
Exec SQL ROLLBACK;
Return '{"error":"Failed to create order"}';
EndIf;
Exec SQL COMMIT;
// Return result
Exec SQL
SELECT JSON_OBJECT('orderId' VALUE :orderId, 'status' VALUE 'confirmed')
INTO :resultJson
FROM SYSIBM.SYSDUMMY1;
Return resultJson;
End-Proc;
Two authentication approaches work well for IBM i APIs. JWT tokens are recommended for external-facing APIs; IBM i User Profile validation works well for internal APIs.
const jwt = require('jsonwebtoken');
// Login endpoint โ generates token
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = await validateIBMiUser(username, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign(
{ username: user.username, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
});
// Middleware โ validates token on protected routes
const authenticateJWT = (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Access denied' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(400).json({ error: 'Invalid token' });
}
};
app.use('/api/protected', authenticateJWT);
const { ConnectionPool } = require('itoolkit');
const pool = new ConnectionPool({
transport: 'idb',
transportOptions: { database: '*LOCAL' },
poolSize: 10
});
app.get('/api/customer/:id', async (req, res) => {
const conn = await pool.acquire();
try {
const results = await conn.run();
res.json(results);
} finally {
pool.release(conn); // Always release back to pool
}
});
app.get('/api/customer/:id', async (req, res) => {
const cacheKey = `customer:${req.params.id}`;
// Check cache first โ no IBM i hit at all
const cached = await client.get(cacheKey);
if (cached) return res.json(JSON.parse(cached));
// Cache miss โ fetch from IBM i
const customer = await getCustomerFromIBMi(req.params.id);
// Store for 5 minutes
await client.setEx(cacheKey, 300, JSON.stringify(customer));
res.json(customer);
});
// Submit batch job โ return immediately
app.post('/api/reports/generate', async (req, res) => {
const reportId = generateReportId();
await submitJob(`CALL PGM(GENRPT) PARM('${reportId}')`);
res.status(202).json({
reportId,
status: 'processing',
statusUrl: `/api/reports/${reportId}/status`
});
});
Always handle CCSID conversion when moving data between IBM i (EBCDIC) and JSON (UTF-8). Failure to do so produces garbled output that is difficult to debug.
Always paginate API responses. Never return unbounded result sets โ they will eventually break under production load.
app.get('/api/customers', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const customers = await getCustomers(limit, offset);
res.json({
page, limit,
data: customers,
nextPage: `/api/customers?page=${page + 1}&limit=${limit}`
});
});
// BAD โ exposes internal stack trace
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
// GOOD โ log full error, return safe response
app.use((err, req, res, next) => {
console.error('API Error:', err); // Full detail in logs only
res.status(500).json({ error: 'Internal server error', requestId: req.id });
});
REST API integration on IBM i does not require rewriting decades of business logic. You have three viable paths โ and for most modernization projects, the Node.js approach provides the best balance of flexibility, ecosystem support, and long-term maintainability.
Whichever approach you choose, keep these principles consistent:
Your IBM i platform already powers the core of your business. REST APIs simply make that power available to modern systems.