Norbix Dart SDK
One package, two importable libraries:
| Import | What it talks to | Default URL |
|---|---|---|
package:norbix/norbix_api.dart |
Project-scoped Norbix API | https://api.norbix.ai |
package:norbix/norbix_hub.dart |
Account-scoped Norbix Hub | https://hub.norbix.ai |
A single shared core (HTTP transport, config, typed errors) lives under
lib/src/core/ and is re-exported from both entry-point libraries — you
never need to import it directly.
Install
dart pub add norbix
Import the gateway you use:
import 'package:norbix/norbix_api.dart'; // project-scoped API
import 'package:norbix/norbix_hub.dart'; // account-scoped Hub
If you need both in the same file, import one with a prefix to avoid
name collisions on resources that exist in both gateways (e.g. auth,
apiKeys):
import 'package:norbix/norbix_api.dart';
import 'package:norbix/norbix_hub.dart' as hub;
final api = NorbixApi();
final h = hub.NorbixHub();
Resource-style API (no namespace nesting)
Resources are direct properties on the client:
final api = NorbixApi();
await api.users.getUsers();
await api.auth.authenticate();
await api.apiKeys.getApiKeys();
final hub = NorbixHub();
await hub.projects.getProjects();
await hub.database.getDatabaseSchemas();
await hub.emailNotifications.createEmailTemplate(body: {...});
There is no client.api.xxx or client.hub.xxx middle layer.
Configuration
Defaults to the public *.norbix.ai hosts. Override the URL when you
self-host:
// Self-hosted at your company domain
final api = NorbixApi(
config: NorbixConfig(
baseUrl: 'https://api.norbix.isidos.lt',
apiKey: 'nbx_...',
),
);
// Local development
final dev = NorbixHub(
config: NorbixConfig(baseUrl: 'http://localhost:5000'),
);
Or load everything from environment variables — no boilerplate, no
String.fromEnvironment compile-time defines:
final api = NorbixApi.fromEnv(); // reads NORBIX_API_*
final hub = NorbixHub.fromEnv(); // reads NORBIX_HUB_*
Regions
Pin a client to a Norbix region (a region code such as nb-eu-germany).
There is no default region: when none is set, no region header is
sent and requests go to the plain host — existing and self-hosted setups
are unaffected.
// 1) At construction
final hub = NorbixHub(region: 'nb-eu-germany');
final api = NorbixApi(region: 'nb-eu-germany');
// 2) From environment variables
final hub = NorbixHub.fromEnv(); // reads NORBIX_HUB_REGION
final api = NorbixApi.fromEnv(); // reads NORBIX_API_REGION
// 3) At runtime
hub.setRegion('nb-us-east'); // subsequent requests target nb-us-east
print(hub.region); // 'nb-us-east'
hub.setRegion(null); // clear — no header, default host
(NorbixConfig.fromEnv also takes a regionVar: name, default
NORBIX_REGION; the per-host factories above set it for you.)
Every request with a resolved region carries the nb-region header.
When the client's base URL is one of the SDK defaults
(https://api.norbix.ai, https://hub.norbix.ai —
kNorbixRegionalDefaultBaseUrls), the client region also routes the
request to the regional host:
https://hub.norbix.ai + region nb-eu-germany → https://nb-eu-germany.hub.norbix.ai
A custom base URL (self-hosted, localhost) is never rewritten — the
region then only adds the nb-region header.
The two region-aware endpoints also accept a per-call region: argument
that overrides the client region for that single request. A per-call
override sets the header only; the URL is not recomposed:
await hub.accounts.getAccountRegions(region: 'nb-us-east');
Regions endpoints
Regions live on the existing accounts and projects resources — there
is no separate hub.regions module:
// GET /{version}/account/regions — regions available to the account.
// Response: {'items': [{'id': 'nb-eu-germany', 'continent': ..., 'name': ...}, ...]}
// 'id' is the region code; 'continent' and 'name' are optional.
final regions = await hub.accounts.getAccountRegions();
// PATCH /{version}/account/projects/{projectId}/settings/regions
// Body takes 'primaryRegion' (a region code) and/or 'additionalRegions'
// (a list of region codes). The response is empty.
await hub.projects.updateProjectRegions(
projectId: 'p1',
body: {
'primaryRegion': 'nb-eu-germany',
'additionalRegions': ['nb-us-east'],
},
);
hub.projects.createProject also accepts optional primaryRegion /
additionalRegions keys in its body to place a new project in
specific regions at creation time.
Errors
try {
await api.files.getFileInfo(integrationId, path: 'a/b.txt');
} on NorbixError catch (e) {
// httpStatus / errorCode are the names every Norbix SDK uses.
// status / code are the same values, kept for older code.
print('${e.httpStatus} ${e.errorCode}: ${e.message}');
for (final item in e.errors) {
print('${item.errorCode} ${item.fieldName}: ${item.message}');
}
print(e.body); // the answer exactly as it arrived
}
message and errorCode are the gateway's own. The gateway puts them inside
responseStatus.errors[], so the SDK reads that list first, takes the first
entry for the message and the code, and keeps every entry in errors. Only
when the body has no responseStatus are the top-level message and
errorCode read. Request failed (HTTP N) with the code NORBIX_HTTP_ERROR
is the last fallback, used when the body says nothing — a 500 page that is not
JSON, say.
Breaking change — a refused call now throws
The gateway answers a business refusal (an unknown id, a rule that says no)
with HTTP 200 and responseStatus.isSuccess = false. The SDK used to hand
that answer back as a normal value, so code carried on as if the call had
worked. It now throws a NorbixError with httpStatus 200 and the gateway's
message and error code.
If your code checked result['responseStatus']['isSuccess'] itself, move that
check into a try / catch. Endpoints that answer with raw bytes rather than a
document (sendBytes — file download, the public file link) are not JSON and
are unchanged.
Working with terms
A taxonomy is a named tree of terms (labels). A term can have one parent (a clean hierarchy) or several parents (the same item under many categories). Pick the call that matches what you want:
| I want to… | Call | Returns |
|---|---|---|
| Get a taxonomy's terms as a flat list | findTerms |
a paginated list of terms |
| Get only the children of one term | findTermsChildren |
a list of child terms (direct + multi-parent) |
| Get a taxonomy's terms as a ready-made tree | findTermTree |
a tree of nested term nodes |
| Get the taxonomy structure (e.g. Countries → Cities) | findTaxonomyTree |
a tree of taxonomy nodes |
The examples below all use one example services taxonomy shaped like this:
Indoors
└─ Air conditioning
└─ Wall-mounted
Outdoors
└─ Solar panels
List a taxonomy's terms (flat)
Goal: show every term of services in a simple list, in display order.
final res = await hub.database.findTerms(taxonomyName: 'services');
{
"list": {
"items": [
{ "id": "term_indoors", "taxonomyName": "services", "parentId": null, "order": 1, "name": "Indoors" },
{ "id": "term_air_con", "taxonomyName": "services", "parentId": "term_indoors", "order": 1, "name": "Air conditioning" },
{ "id": "term_wall", "taxonomyName": "services", "parentId": "term_air_con", "order": 1, "name": "Wall-mounted" },
{ "id": "term_outdoors", "taxonomyName": "services", "parentId": null, "order": 2, "name": "Outdoors" },
{ "id": "term_solar", "taxonomyName": "services", "parentId": "term_outdoors","order": 1, "name": "Solar panels" }
],
"hasMore": false, "hasPrevious": false, "startingAfter": null, "endingBefore": null
},
"responseStatus": { "isSuccess": true }
}
The list is flat — every term is one row, with its parentId telling you where it sits. The nesting is not built for you here (use findTermTree for that).
List only top-level terms (filtered)
Goal: show just the roots (no parent) — for the first level of a menu.
final res = await hub.database.findTerms(
taxonomyName: 'services',
query: {'filter': '{ "parentId": null }'},
);
{
"list": {
"items": [
{ "id": "term_indoors", "taxonomyName": "services", "parentId": null, "order": 1, "name": "Indoors" },
{ "id": "term_outdoors", "taxonomyName": "services", "parentId": null, "order": 2, "name": "Outdoors" }
],
"hasMore": false, "hasPrevious": false, "startingAfter": null, "endingBefore": null
},
"responseStatus": { "isSuccess": true }
}
filter is an optional MongoDB filter, ANDed with the taxonomy. Use it to fetch one level at a time (lazy tree loading) or to find terms by any field.
Get a term's children
Goal: the user expanded Indoors — load what is directly under it.
final res = await hub.database.findTermsChildren(
taxonomyName: 'services',
parentId: 'term_indoors',
);
{
"list": {
"items": [
{
"id": "term_air_con",
"taxonomyName": "services",
"parentId": "term_indoors",
"order": 1,
"name": "Air conditioning",
"multiParents": [
{ "taxonomyId": "tax_service_types", "parentId": "term_indoors", "name": "Indoors" },
{ "taxonomyId": "tax_service_types", "parentId": "term_energy_efficient", "name": "Energy efficient" }
]
}
],
"hasMore": false, "hasPrevious": false
},
"responseStatus": { "isSuccess": true }
}
This returns both direct children (their parentId is term_indoors) and multi-parent children (terms that list term_indoors in multiParents). Parent names are already resolved, so no second lookup.
Multi-parent: one product in several categories
Goal: in a products taxonomy, a Relaxing massage oil belongs to For couples, Gift ideas, and Body care. Listing the children of any of those categories returns it.
final res = await hub.database.findTermsChildren(
taxonomyName: 'products',
parentId: 'term_gift_ideas',
);
{
"list": {
"items": [
{
"id": "term_relaxing_oil",
"taxonomyName": "products",
"name": "Relaxing massage oil",
"multiParents": [
{ "taxonomyId": "tax_categories", "parentId": "term_for_couples", "name": "For couples" },
{ "taxonomyId": "tax_categories", "parentId": "term_gift_ideas", "name": "Gift ideas" },
{ "taxonomyId": "tax_categories", "parentId": "term_body_care", "name": "Body care" }
]
}
],
"hasMore": false, "hasPrevious": false
},
"responseStatus": { "isSuccess": true }
}
One product, three category links — no duplicate listings. The same product would also come back from the children of term_for_couples and term_body_care.
Get the whole term tree in one call
Goal: render the full services tree at once, already nested.
final res = await hub.database.findTermTree(taxonomyName: 'services');
{
"tree": [
{
"id": "term_indoors",
"name": "Indoors",
"order": 1,
"children": [
{
"id": "term_air_con",
"name": "Air conditioning",
"order": 1,
"children": [
{ "id": "term_wall", "name": "Wall-mounted", "order": 1, "children": null }
]
}
]
},
{
"id": "term_outdoors",
"name": "Outdoors",
"order": 2,
"children": [
{ "id": "term_solar", "name": "Solar panels", "order": 1, "children": null }
]
}
],
"responseStatus": { "isSuccess": true }
}
Roots are in tree; each node carries its own children; a leaf has children: null. The tree arrives ready to render — no client-side tree building.
Get only a sub-tree, capped by depth
Goal: start from Indoors and go at most 2 levels deep.
final res = await hub.database.findTermTree(
taxonomyName: 'services',
query: {'rootTermId': 'term_indoors', 'depth': 2},
);
{
"tree": [
{
"id": "term_indoors",
"name": "Indoors",
"order": 1,
"children": [
{ "id": "term_air_con", "name": "Air conditioning", "order": 1, "children": null }
]
}
],
"responseStatus": { "isSuccess": true }
}
With depth 2 you get Indoors (level 1) and Air conditioning (level 2); Wall-mounted (level 3) is cut off, so Air conditioning shows children: null.
Get the taxonomy structure tree — without terms
Goal: see how taxonomies relate to each other (e.g. a Cities taxonomy whose parent is Countries), structure only.
final res = await hub.database.findTaxonomyTree();
{
"tree": [
{
"viewId": "txn_countries",
"taxonomyName": "Countries",
"taxonomySlug": "countries",
"parentId": null,
"children": [
{ "viewId": "txn_cities", "taxonomyName": "Cities", "taxonomySlug": "cities", "parentId": "txn_countries", "children": null, "terms": null }
],
"terms": null
}
],
"responseStatus": { "isSuccess": true }
}
This is the taxonomy tree, not the term tree: nodes are taxonomies. Every terms is null because we did not ask for terms.
Get the taxonomy structure tree — with terms
Goal: same structure, but also pull each taxonomy's terms in the same call.
final res = await hub.database.findTaxonomyTree(
query: {'includeTerms': true},
);
{
"tree": [
{
"viewId": "txn_countries",
"taxonomyName": "Countries",
"taxonomySlug": "countries",
"parentId": null,
"terms": [
{ "id": "term_lt", "name": "Lithuania", "order": 1, "children": null },
{ "id": "term_lv", "name": "Latvia", "order": 2, "children": null }
],
"children": [
{
"viewId": "txn_cities",
"taxonomyName": "Cities",
"taxonomySlug": "cities",
"parentId": "txn_countries",
"terms": [
{ "id": "term_vilnius", "name": "Vilnius", "order": 1, "children": null },
{ "id": "term_kaunas", "name": "Kaunas", "order": 2, "children": null }
],
"children": null
}
]
}
],
"responseStatus": { "isSuccess": true }
}
Now each taxonomy node's terms holds that taxonomy's full term tree (same shape as findTermTree) — Countries carries its countries, Cities carries its cities.
Every term-reading call also accepts an optional
databaseIntegrationIdto target a non-default database.
Files
Public file links
A file or a whole folder can be made readable by anyone holding its link. Publishing is a Hub action and needs your key; reading the link needs nothing at all.
final hub = NorbixHub(config: NorbixConfig(baseUrl: 'https://hub.norbix.ai', apiKey: 'k'));
// Publish one file, or a whole folder prefix (one record, however many
// files sit under it, at any depth — the root cannot be published).
await hub.files.makeFilePublic(
body: {'filesIntegrationId': 'nbin_1', 'path': 'docs/invoice.pdf'},
);
await hub.files.makeFolderPublic(
body: {'filesIntegrationId': 'nbin_1', 'path': 'docs'},
);
// Take it back. makeFilePrivate is refused while a folder above the file
// is public — switch the folder off instead.
await hub.files.makeFilePrivate(
body: {'filesIntegrationId': 'nbin_1', 'path': 'docs/invoice.pdf'},
);
await hub.files.makeFolderPrivate(
body: {'filesIntegrationId': 'nbin_1', 'path': 'docs'},
);
Reading a published file is the one call in this SDK that goes out with no
credentials — the link has to work in an e-mail, in an <img src>, or in a
browser on a stranger's phone. It answers with the raw bytes:
final api = NorbixApi();
final bytes = await api.files.getPublicFile(
publicId: 'nbpf_abc',
name: '2026/q1/report.pdf', // slashes stay slashes for a folder link
);
Every miss — unknown id, wrong name, made private again, file gone — is the
same plain 404, on purpose: a more precise answer would tell a stranger that
the file exists.
Testing an integration before you save it
hub.files.testFilesIntegration tries the credentials against the storage
provider and answers whether they work. Nothing is saved — use it before
saveFilesIntegration to tell a bad key from a bad bucket.
Testing a saved integration from the API
api.files.testFilesIntegration runs a live probe against an integration that
is already saved: it uploads a small file, reads it, lists the folder and
deletes the file again. It answers one entry per step. Because it writes to the
storage, the API key needs the files:create permission.
final api = NorbixApi(config: NorbixConfig(baseUrl: 'https://api.norbix.ai', apiKey: 'k'));
final res = await api.files.testFilesIntegration(filesIntegrationId: 'nbin_1')
as Map<String, dynamic>;
for (final step in res['items'] as List) {
print('${step['operation']}: ${step['result']}'); // UploadFile: OK, GetFile: OK, ...
}
Repo layout
norbix-dart/
├── lib/
│ ├── norbix_api.dart # API entry point (re-exports core + api client + resources)
│ ├── norbix_hub.dart # Hub entry point (re-exports core + hub client + resources)
│ └── src/
│ ├── core/ # handwritten: transport, config, errors
│ ├── api/ # GENERATED — gitignored
│ └── hub/ # GENERATED — gitignored
├── test/
│ ├── _fake_driver.dart
│ ├── core/ # core tests
│ ├── api/ # API client tests
│ └── hub/ # Hub client tests
├── tool/
│ └── generate_resources.py # codegen for resource modules (dev-task, never runs in CI)
├── pubspec.yaml # single `norbix` package
└── Makefile # gen / lint / test
Development
dart pub get # install deps
make gen # regenerate resource modules (dev-only)
make test # run all tests
make lint # dart analyze
CI never runs make gen. The generated files under lib/src/api/ and
lib/src/hub/ are gitignored. The dev runs the gen script locally and
ships the SDK with the generated artifacts produced from the canonical
route files.
Releases
Versioned with Conventional Commits + semantic-release:
feat:— minorfix:— patchfeat!/BREAKING CHANGE:— major
Channels: main → stable, next → -rc.*, beta → -beta.*.
On a push to main, release.yml runs analyze + tests, and semantic-release
creates the vX.Y.Z tag and GitHub Release. It then starts publish.yml on
that tag, which publishes to pub.dev with automated publishing (OIDC) — no
stored credential. main is protected, so nothing is committed back:
pubspec.yaml and CHANGELOG.md are stamped with the version only inside the
publish job. To re-publish a tag, run Publish to pub.dev from the Actions
tab with that tag selected.
Libraries
- norbix_api
- Project-scoped Norbix API (https://api.norbix.ai).
- norbix_hub
- Account-scoped Norbix Hub (https://hub.norbix.ai).
- norbix_webhooks
- Inbound Norbix webhook receiver.