Introduction
Building a Flutter app that talks to an Odoo ERP server sounds straightforward β until you hit the reality. Odoo's JSON-RPC 2.0 API has its own session cookie lifecycle, a non-standard domain filter syntax, an error response format that looks like a success (HTTP 200 with an error payload), and a timezone field that silently corrupts datetime values if you ignore it.
Most developers writing their first Odoo client end up with a tangle of raw http or dio calls, hand-rolled session management, and a growing collection of try/catch blocks that inspect raw strings to figure out what went wrong. It works β until it doesn't.
odoo_rest_ov is a Dart package built by the Octivex team to solve this problem properly. It wraps the Odoo JSON-RPC 2.0 API in a typed, ergonomic client with first-class Flutter support. This article walks through every major feature with real code, so you can evaluate whether it fits your project.
Installation
Add the package to your pubspec.yaml:
dependencies:
odoo_rest_ov: ^0.1.0Then run:
dart pub getThe package depends on dio for HTTP, cookie_jar for cookie storage, and dio_cookie_manager to wire them together. All three are pulled in automatically β no extra configuration needed for a basic setup.
Connecting to an Odoo Server
Creating a client takes one line:
import 'package:odoo_rest_ov/odoo_rest_ov.dart';
final client = OdooClient(OdooClientOptions(
baseUrl: 'https://mycompany.odoo.com',
database: 'mydb',
));OdooClientOptions accepts additional parameters for fine-tuning behaviour β including a session change callback, custom timeout values, and API key injection. The client itself is a long-lived object; create it once and reuse it across your app.
Authentication & Session Management
Username / Password
final session = await client.authenticate('admin', 'admin');
print(session.name); // "Administrator"
print(session.timezone); // "Asia/Riyadh" β auto-detected from Odoo settings
print(session.uid); // 2The session object carries everything Odoo returns after a successful login: the user's display name, UID, timezone, language, company ID, and user type. The timezone is read directly from the user's Odoo preferences, which avoids the common bug where datetime values appear shifted because the client and server disagree on the timezone.
API Key (Odoo 14+)
For server-to-server or CI/CD scenarios where storing a password is unacceptable:
client.setApiKey('your-odoo-api-key-here');
// Use ORM methods directly β no authenticate() call neededListening for Session Changes
In a real Flutter app, a session expiry should redirect the user to the login screen. Wire that up via the session stream:
client.sessionStream.listen((session) {
if (session == null) {
// Session expired or user logged out
Navigator.pushReplacementNamed(context, '/login');
}
});Or pass an onSessionChanged callback at construction time:
OdooClientOptions(
baseUrl: 'https://mycompany.odoo.com',
database: 'mydb',
onSessionChanged: (session) {
if (session == null) _navigateToLogin();
},
);Checking and Refreshing a Session
final isValid = await client.checkSession(); // non-throwing boolean
if (!isValid) {
await client.authenticate('admin', 'admin');
}
// Or refresh session data without re-authenticating
final updatedSession = await client.refreshSession();Logout
await client.logout();
client.close(); // release resources when doneORM Methods
Every common Odoo ORM operation is available as a typed method. The signatures mirror the Python ORM closely, so Odoo developers will feel at home immediately.
searchRead
final partners = await client.searchRead(
'res.partner',
[['is_company', '=', true]],
fields: ['name', 'email', 'phone', 'country_id'],
limit: 20,
offset: 0,
order: 'name asc',
);search
Returns a list of matching record IDs without fetching field data:
final ids = await client.search(
'res.partner',
[['customer_rank', '>', 0]],
limit: 100,
);read
Fetch specific fields for a known list of IDs:
final records = await client.read(
'res.partner',
[1, 2, 3],
fields: ['name', 'email'],
);searchCount
final total = await client.searchCount(
'res.partner',
[['active', '=', true]],
);
print('Total active partners: $total');create
final newId = await client.create('res.partner', {
'name': 'Acme Corp',
'email': 'info@acme.com',
'is_company': true,
});
print('Created partner with ID: $newId');createMulti
Create several records in one round-trip:
final newIds = await client.createMulti('res.partner', [
{'name': 'Partner A', 'email': 'a@example.com'},
{'name': 'Partner B', 'email': 'b@example.com'},
]);write
await client.write('res.partner', [newId], {
'phone': '+966 55 000 0000',
'website': 'https://acme.com',
});unlink
await client.unlink('res.partner', [newId]);fieldsGet
Inspect a model's field definitions at runtime β useful for building dynamic forms:
final fields = await client.fieldsGet(
'res.partner',
attributes: ['string', 'type', 'required', 'selection'],
);
// fields is a Map<String, dynamic> keyed by field namenameSearch
final results = await client.nameSearch('res.partner', 'Acme');
// Returns List<[int, String]> β id and display name pairscallMethod
Call any model method that isn't covered by the ORM shortcuts:
final result = await client.callMethod(
'account.move',
'action_post',
args: [[invoiceId]],
);Domain Builder
Raw Odoo domains are lists of tuples β readable enough in Python, but awkward in Dart:
// Raw β works, but fragile
final domain = [
['is_company', '=', true],
['customer_rank', '>', 0],
];The OdooDomain builder gives you a fluent, type-safe alternative:
// AND (default)
final domain = OdooDomain()
.where('is_company').equals(true)
.where('customer_rank').greaterThan(0)
.build();
// OR
final orDomain = OdooDomain()
.or()
.where('email').ilike('%@gmail.com')
.where('email').ilike('%@yahoo.com')
.build();
// NOT
final notDomain = OdooDomain()
.not()
.where('active').equals(false)
.build();
// Membership check
final inDomain = OdooDomain()
.where('country_id').isIn([184, 113, 20])
.build();The full set of operators matches every operator Odoo supports: equals, notEquals, greaterThan, greaterOrEqual, lessThan, lessOrEqual, like, ilike, notLike, notIlike, isIn, notIn, childOf, parentOf, isSet, and isNotSet.
Raw lists still work if you prefer or need backward compatibility β the ORM methods accept either format.
Record Helpers
Records returned from searchRead and read are OdooRecord objects with extension methods that handle the common relational field patterns:
final partner = partners.first;
// Primitive access
partner.id; // int
partner.name; // String
partner['email']; // dynamic β any field by name
// Many2one fields come back as [id, name] β helpers extract each part
partner.many2oneId('country_id'); // int? β 184
partner.many2oneName('country_id'); // String? β "Saudi Arabia"
// Many2many / One2many fields come back as List<int>
partner.x2manyIds('tag_ids'); // [3, 7, 12]These helpers prevent the boilerplate of casting and null-checking [dynamic, dynamic] tuples that Odoo returns for relational fields.
Error Handling
This is where raw Odoo clients tend to fall apart. Odoo returns all errors as HTTP 200 responses with a JSON body containing an error key. Without a wrapper, you're parsing strings and guessing at error types.
odoo_rest_ov maps Odoo's error types to a typed exception hierarchy:
OdooException (base)
βββ OdooAccessDeniedException β invalid credentials or locked account
βββ OdooSessionExpiredException β session cookie has expired
βββ OdooAccessErrorException β permission denied on a record or model
βββ OdooValidationException β field validation failed
βββ OdooMissingErrorException β record not found (deleted or wrong ID)
βββ OdooUserErrorException β business logic rejection
βββ OdooNetworkException β connectivity / timeout
βββ OdooProtocolException β malformed responseEvery exception exposes two messages:
messageβ the raw Odoo error, suitable for logginguserMessageβ a clean, human-readable string safe to show in a dialog or snackbar
try {
await client.write('res.partner', [999999], {'name': 'Ghost'});
} on OdooMissingErrorException catch (e) {
// Show this in your UI β no internal details leak
showDialog(context: context, builder: (_) => AlertDialog(
title: const Text('Error'),
content: Text(e.userMessage), // "Record does not exist or has been deleted."
));
// Log the raw error separately
logger.error(e.message);
} on OdooAccessErrorException catch (e) {
showSnackBar(e.userMessage); // "You do not have permission to perform this action."
} on OdooNetworkException catch (e) {
showSnackBar(e.userMessage); // "Network error. Please check your connection."
} on OdooException catch (e) {
// Catch-all for any other Odoo error
showSnackBar(e.userMessage);
}The separation between message and userMessage matters in production. Internal Odoo errors often contain model names, field names, and SQL fragments that are meaningless to end users and a potential information leak. With typed exceptions, you never accidentally expose those strings in your UI.
Controller Calls
Not everything in Odoo goes through the ORM. Custom modules expose HTTP controllers β both JSON-RPC-wrapped endpoints and plain REST endpoints. The callController method handles both:
// JSON-RPC endpoint (Odoo's standard format)
final response = await client.callController(
'/web/webclient/version_info',
params: {},
);
print(response.data); // decoded result
print(response.isSuccess); // true
print(response.statusCode); // 200
// REST-style GET endpoint (no JSON-RPC wrapper)
final rest = await client.callController(
'/api/v1/products',
method: 'GET',
params: {'limit': '10', 'offset': '0'},
isJsonRpc: false,
);
// Access result as list or map
final products = rest.dataAsList;Authentication cookies are forwarded automatically with every request β no manual header management needed.
Reports and Binary Fields
Download a PDF Report
final pdfBytes = await client.getReport(
'account.report_invoice',
[invoiceId],
);
// Save to file, open with a PDF viewer, or upload somewhere
await File('/tmp/invoice_$invoiceId.pdf').writeAsBytes(pdfBytes);Upload an Attachment
final imageBytes = await File('/tmp/logo.png').readAsBytes();
await client.uploadBinary(
'res.partner',
partnerId,
'image_1920',
imageBytes,
filename: 'logo.png',
);Download an Attachment
final bytes = await client.downloadBinary(
'res.partner',
partnerId,
'image_1920',
);Flutter-Specific Setup: Persistent Sessions
On mobile, you want login sessions to survive app restarts. The package ships a OdooFlutter helper that wires up a PersistCookieJar backed by the app's documents directory:
import 'package:odoo_rest_ov/odoo_rest_ov_flutter.dart';
import 'package:path_provider/path_provider.dart';
Future<OdooClient> createOdooClient() async {
final dir = await getApplicationDocumentsDirectory();
return OdooFlutter.createClient(
baseUrl: 'https://mycompany.odoo.com',
database: 'mydb',
documentsPath: dir.path,
);
}The client returned is a standard OdooClient β the only difference is that the cookie jar persists to disk. If the user has a valid session from a previous app launch, the next checkSession() call will return true and no re-authentication is required.
On web, the browser manages cookies automatically with withCredentials: true β no extra setup is needed and OdooFlutter is not required.
User Type Detection
After authentication, you can branch UI logic based on who is logged in:
final session = await client.authenticate('admin', 'admin');
// Enum-based check
switch (session.userType) {
case OdooUserType.internal:
showFullDashboard();
break;
case OdooUserType.portal:
showPortalView();
break;
case OdooUserType.public:
showGuestView();
break;
}
// Boolean shortcuts
session.isInternalUser; // true for employees
session.isPortalUser; // true for portal customers
session.isPublic; // true for anonymous users
session.isAdmin; // true if user has admin access
session.isSystem; // true if user has system-level accessReal-World Example: Partner List Screen
Putting it all together β a Flutter screen that fetches and displays a paginated list of company partners:
class PartnerListScreen extends StatefulWidget {
const PartnerListScreen({super.key});
@override
State<PartnerListScreen> createState() => _PartnerListScreenState();
}
class _PartnerListScreenState extends State<PartnerListScreen> {
final _client = getIt<OdooClient>(); // from your DI container
List<OdooRecord> _partners = [];
bool _loading = true;
String? _error;
static const _fields = ['name', 'email', 'phone', 'country_id'];
@override
void initState() {
super.initState();
_fetchPartners();
}
Future<void> _fetchPartners() async {
setState(() { _loading = true; _error = null; });
try {
final domain = OdooDomain()
.where('is_company').equals(true)
.where('active').equals(true)
.build();
final records = await _client.searchRead(
'res.partner',
domain,
fields: _fields,
limit: 50,
order: 'name asc',
);
setState(() { _partners = records; _loading = false; });
} on OdooSessionExpiredException {
setState(() { _error = 'Session expired. Please log in again.'; _loading = false; });
} on OdooException catch (e) {
setState(() { _error = e.userMessage; _loading = false; });
}
}
@override
Widget build(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_error != null) return Center(child: Text(_error!));
return ListView.builder(
itemCount: _partners.length,
itemBuilder: (context, index) {
final p = _partners[index];
return ListTile(
title: Text(p.name),
subtitle: Text(p['email'] ?? ''),
trailing: Text(p.many2oneName('country_id') ?? ''),
);
},
);
}
}The code reads cleanly because the complexity is pushed into the package: domain construction, HTTP transport, cookie management, error mapping, and relational field access are all handled transparently.
Platform Support
| Platform | Status | Notes |
|---|---|---|
| Android | Supported | Use OdooFlutter for persistent sessions |
| iOS | Supported | Use OdooFlutter for persistent sessions |
| Web | Supported | Browser manages cookies automatically |
| macOS | Supported | Standard OdooClient |
| Linux | Supported | Standard OdooClient |
| Windows | Supported | Standard OdooClient |
Conclusion
odoo_rest_ov handles the parts of Odoo integration that are genuinely painful to get right on your own: the JSON-RPC error envelope, the relational field type coercion, the session cookie lifecycle, and the timezone mismatch. The typed exception hierarchy means your UI code never has to parse raw Odoo error strings, and the domain builder reduces a common source of runtime bugs.
The package is MIT-licensed, published on pub.dev, and open-source on GitHub. If you run into an issue or want to contribute, head to the repository.
Links:
