Official Go SDK for Norbix. It mirrors the TypeScript, Python, .NET, Dart, Kotlin and Swift SDKs: a shared transport (auth, scope, environment and region headers, retries), full API and Hub endpoint coverage, generated DTO structs, and an inbound webhook receiver with HMAC verification.
Status: generated foundation. Endpoint methods accept/return JSON (
map[string]anyin, anyoutpointer) so you can decode into the generated DTO structs innorbix/api/dtosandnorbix/hub/dtos.
go get github.com/norbix-code/sdk-go/v2/norbixRequires Go 1.22+.
package main
import (
"context"
"fmt"
"github.com/norbix-code/sdk-go/v2/norbix"
"github.com/norbix-code/sdk-go/v2/norbix/hub/dtos"
)
func main() {
// Service-to-service: long-lived API key. Reads NORBIX_* env vars when fields are empty.
c, err := norbix.New(norbix.Options{
APIKey: "sk_...",
ProjectID: "proj_...",
})
if err != nil {
panic(err)
}
defer c.Close()
ctx := context.Background()
// API call — decode straight into a generated DTO.
var schemas map[string]any
if err := c.API.Database.GetDatabaseSchemas(ctx, nil, &schemas); err != nil {
panic(err)
}
fmt.Println(schemas)
// API call with a path parameter and a typed result.
var user dtos.UserDto
_ = c.API.Membership.GetUser(ctx, "user_123", nil, &user)
}c, _ := norbix.New(norbix.Options{ProjectID: "proj_..."})
_, err := c.Login(ctx, norbix.LoginCredentials{UserName: "alice", Password: "..."})
// subsequent calls use the bearer token returned by Login| Constructor | Access |
|---|---|
norbix.New(opts) |
c.API.<Module>.<Method> / c.Hub.<Module>.<Method> |
norbix.NewAPI(opts) |
flat: c.Database, c.Membership, c.Files, ... |
norbix.NewHub(opts) |
flat: c.Account, c.Email, c.Webhooks, ... |
The notification modules (Email, Push, Sms) live on c.Hub.Notifications; every
Sms Hub route (34) has a method there, each proven against a local fake server in
norbix/hub/sms_test.go (verb + resolved path, no provider is ever contacted).
Every Options field falls back to an environment variable, then a default:
| Option | Env var | Default |
|---|---|---|
ProjectID |
NORBIX_PROJECT_ID |
required |
APIKey |
NORBIX_API_KEY |
— |
BearerToken |
NORBIX_BEARER_TOKEN |
— |
AccountID |
NORBIX_ACCOUNT_ID |
— (no Hub method needs it: account routes work with a key or a bearer token; sign-up, invitations, regions and VerifyAccount need no token at all) |
Env |
NORBIX_ENV |
PROD (no header sent) |
Region |
NORBIX_REGION |
none |
BaseURLAPI |
NORBIX_API_URL |
https://api.norbix.ai |
BaseURLHub |
NORBIX_HUB_URL |
https://hub.norbix.ai |
Setting a Region on a default base URL composes it as a subdomain, e.g.
https://nb-eu-germany.api.norbix.ai. A custom base URL is never rewritten.
Per-request env/region/token overrides and switching at runtime are available via
c.SetEnv, c.SetRegion, c.SetBearerToken, c.SetScope.
Failures return *errors.Error or a typed variant. Use errors.As:
import norbixerr "github.com/norbix-code/sdk-go/v2/norbix/errors"
var e *norbixerr.Error
if errors.As(err, &e) {
fmt.Println(e.HTTPStatus(), e.Code, e.Message)
for _, item := range e.Errors {
fmt.Println(item.ErrorCode, item.FieldName, item.Message)
}
_ = e.Body // the answer exactly as it arrived
}Types: AuthenticationError (401/403), NotFoundError (404),
RateLimitError (429), ValidationError (400/422), base Error otherwise.
Idempotent verbs (GET/DELETE) retry on 429/5xx with exponential backoff.
Message and Code are the gateway's own. The gateway puts them inside
responseStatus.errors[], so the SDK reads that list first, takes the first
entry for Message / 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 <status>) with the code HTTP_<status> is the last
fallback, used when the body says nothing — a 500 page that is not JSON, say.
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 fill
out and return nil, so code carried on as if the call had worked. It now
returns a *errors.Error with Status 200 and the gateway's message and code.
If your code checked out.ResponseStatus.IsSuccess itself, check err
instead. Endpoints that answer with raw bytes rather than a document (file
download, the public file link — the ones you pass a *[]byte to) are not JSON
and are unchanged.
c.Hub.Notifications.StopEmailCampaign, GetEmailDisableDependencies and CheckEmailIntegrationDomainHealth cover the Email campaign, module and integration checks; c.Hub.Email.GetEmailPreferencesByLink(ctx, map[string]any{"token": t}, &out) reads the preferences behind a signed unsubscribe link and needs no API key. To read one campaign message use GetEmailCampaignBatchNotification(ctx, campaignID, batchID, notificationID, …) (the …/messages/{id} methods were removed with their gateway route).
The gateway now requires the provider integration id when it creates a
campaign or saves a notification trigger. Email and Push campaigns take it
inside campaign ("campaign": {"integrationId": …}); an SMS campaign takes it
at the top level ("integrationId", distinct from "databaseIntegrationId"):
err := client.Hub.Notifications.CreateSmsCampaign(ctx, map[string]any{
"templateId": "stpl_123",
"integrationId": "sms_int_123", // the SMS provider, required
"deliveryType": "PhoneNumbers",
"phoneNumbers": map[string]any{"recipientsSourceType": "PhoneNumbers", "phoneNumbers": []string{"+37060000000"}},
}, nil)A trigger action (dtos.TriggerActionEmailDto / PushDto / SmsDto) carries
integrationId (required) and the optional language and initiatorId.
client.Hub.Account.CheckProjectLanguages(ctx, projectId, req, &out) lists the
templates that miss a project language — send a proposed defaultLanguage /
languages to check before UpdateProjectLanguages, or nothing to check the
current settings. client.Hub.Triggers.GetTriggersNeedingAttention(ctx, map[string]any{"triggerType": "Schema"}, &out) lists the triggers whose last
run was stopped before sending (for example a template that misses a
language).
- An SMS campaign can go to the account owner and team members:
"deliveryType": "AccountUsers"with"accountUsers": {"recipientsSourceType": "AccountUsers", "recipients": [<account user ids>]}(dtos.SmsToAccountUsersDeliverySettingsDto). Each member gets it on the phone saved withclient.Hub.Account.UpdateMyAccountUserPhone; members without a phone are skipped. See docs/hub/account.md. GetSmsCampaignstakes"campaignId"to return only that campaign.- An SMS template has a body only:
dtos.SmsMessageContentDtohas noSubjectany more, and neither do SMS campaign messages (Email and Push messages keep theirs). dtos.SmsCampaignDto.CreatedByIdsays who created the campaign.
- A schema may hold
objectandarrayfields at any depth (dtos.ObjectFieldDto,dtos.ArrayFieldDto); the nested value is just part of the record's JSON text."arrayFilters"onUpdateOne/UpdateMany(Api) andUpdateOneRecord/UpdateManyRecords(Hub) changes one element of a nested array in place. "expandReferences": trueonFind/FindOne/FindOwn(Api) andFindRecords/FindOneRecord(Hub) returns every reference field as{id, display}— decode it intoapi.ExpandedReference/hub.ExpandedReference. One unreadable linked source refuses the read withCM-ERRORS-DATABASE-056.c.API.Files.GetFileById(ctx, filesIntegrationID, id, nil, &out)andc.Hub.Files.GetFileById(ctx, map[string]any{"filesIntegrationId": …, "id": …}, &out)read a file by the id a listing returned (nbfl_…) — the id a record's file field stores. Same response asGetFileInfo/GetFile.- Record validation refusals now carry one code per keyword
(
CM-ERRORS-DATABASE-039…-056); see docs/hub/database.md.
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
Goal: show every term of services in a simple list, in display order.
var out map[string]any
if err := c.API.Database.FindTerms(ctx, "services", nil, &out); err != nil {
panic(err)
}{
"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).
Goal: show just the roots (no parent) — for the first level of a menu.
var out map[string]any
if err := c.API.Database.FindTerms(ctx, "services", map[string]any{"filter": "{ \"parentId\": null }"}, &out); err != nil {
panic(err)
}{
"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.
Goal: the user expanded Indoors — load what is directly under it.
var out map[string]any
if err := c.API.Database.FindTermsChildren(ctx, "services", "term_indoors", nil, &out); err != nil {
panic(err)
}{
"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.
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.
var out map[string]any
if err := c.API.Database.FindTermsChildren(ctx, "products", "term_gift_ideas", nil, &out); err != nil {
panic(err)
}{
"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.
Goal: render the full services tree at once, already nested.
var out map[string]any
if err := c.API.Database.FindTermTree(ctx, "services", nil, &out); err != nil {
panic(err)
}{
"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.
Goal: start from Indoors and go at most 2 levels deep.
var out map[string]any
if err := c.API.Database.FindTermTree(ctx, "services", map[string]any{"rootTermId": "term_indoors", "depth": 2}, &out); err != nil {
panic(err)
}{
"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.
Goal: see how taxonomies relate to each other (e.g. a Cities taxonomy whose parent is Countries), structure only.
var out map[string]any
if err := c.API.Database.FindTaxonomyTree(ctx, nil, &out); err != nil {
panic(err)
}{
"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.
Goal: same structure, but also pull each taxonomy's terms in the same call.
var out map[string]any
if err := c.API.Database.FindTaxonomyTree(ctx, map[string]any{"includeTerms": true}, &out); err != nil {
panic(err)
}{
"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
databaseIntegrationIdkey in the request map to target a non-default database.
c.API.Payments takes a payment on the project: a payment order is what a
buyer pays for (lines priced in minor units, a merchant reference, a price
version), and a checkout sends the buyer to the provider (Montonio, …).
| Method | Route |
|---|---|
CreatePaymentOrder(ctx, req, &out) |
POST /{version}/payments/orders → {id: "ord_…"} |
GetPaymentOrder(ctx, id, nil, &out) |
GET /{version}/payments/orders/{Id} → {item: order} |
GetPaymentOrders(ctx, req, &out) |
GET /{version}/payments/orders (query buyerUserId, merchantReference, status) → {list: {items, …}} |
StartPaymentCheckout(ctx, id, req, &out) |
POST /{version}/payments/orders/{Id}/checkout → {checkout, reused, order} |
var created struct{ ID string `json:"id"` }
err := c.API.Payments.CreatePaymentOrder(ctx, map[string]any{
"buyerUserId": userID,
"merchantReference": "ad-1042-promo-1",
"idempotencyKey": "ad-1042-promo-1", // same body + same key → same id
"currency": "EUR",
"priceVersion": "2026-10-01",
"lines": []any{map[string]any{"sku": "star-day", "title": "Star × day", "quantity": 21, "unitAmountMinor": 30}},
"metadata": map[string]string{"adId": "ad_1042"},
}, &created)
var started struct {
Checkout struct{ PaymentURL string `json:"paymentUrl"` } `json:"checkout"`
Reused bool `json:"reused"`
}
err = c.API.Payments.StartPaymentCheckout(ctx, created.ID, map[string]any{
"integrationId": montonioIntegrationID,
"returnUrl": "https://shop.example.com/orders/return",
}, &started)
// redirect the buyer to started.Checkout.PaymentURLWhen the provider confirms the payment the order becomes Paid, and a webhook
destination or a Code function hears payments.order.paid (also
…partially_refunded, …refunded, …reversed, …expired). Decode the
webhook payload with webhooks.DecodePayload[norbix.PaymentOrderEvent], or read
event.AsPaymentOrder() in a Code function; de-duplicate on OrderEventID.
The norbix/webhooks package verifies, parses, normalises, and dispatches
inbound deliveries. Signature algorithm matches the gateway:
X-Norbix-Signature: sha256=<hex> where the HMAC-SHA256 input is
"<timestamp>.<rawBody>".
r := webhooks.New(webhooks.Options{}) // reads NORBIX_WEBHOOK_SIGNING_SECRET etc.
r.On(webhooks.EventMembershipUserRegistered,
func(ctx context.Context, payload json.RawMessage, e webhooks.Event) error {
user, _ := webhooks.DecodePayload[dtos.UserDto](payload)
log.Printf("registered %s (%s)", user.UserName, e.Metadata.UserID)
return nil
})
// Mutation events ({from, to}) decode with DecodeMutation:
r.On(webhooks.EventDatabaseRecordUpdated,
func(ctx context.Context, payload json.RawMessage, e webhooks.Event) error {
m, _ := webhooks.DecodeMutation[MyDoc](payload)
_ = m.From; _ = m.To
return nil
})
// Catch-all logger for every catalog event:
r.OnAll(webhooks.EventNames, func(ctx context.Context, env webhooks.Envelope, c webhooks.Context) error {
log.Printf("%s %s", env.Event, env.ID)
return nil
})
res, err := r.Handle(ctx, webhooks.HandleInput{RawBody: raw, Headers: req.Header})In your HTTP handler, respond 401 on *webhooks.SignatureError and 400 on
*webhooks.ParseError. A full runnable server is in
examples/webhook-receiver.
Normalisation matches the JS SDK: entity events hand you the entity, mutation
events {from, to}, batch events the array; wrapper ids (record id, schema,
user id) are lifted onto event.Metadata. Payments events lift the provider
ids (PaymentProvider, PaymentProviderEventID, PaymentEventName,
IntegrationID); payments.order.* also lifts OrderID, OrderStatus and
OrderEventID — the de-duplication key of an order change.
Each POSTed envelope is
{ id, eventId, event, createdOn, accountId, projectId, triggerId?, data }.
id(Envelope.ID,Event.DeliveryID, alsoX-Norbix-Delivery) is one per delivery. A retry of the same delivery keeps itsid— use it to drop retries.eventId(Envelope.EventID,Event.EventID,HandleResult.EventID) is one per change. Every delivery made for ONE record change — the plain webhook delivery and each schema Webhook-trigger delivery — carries the sameeventId.
A destination that is subscribed to the event and targeted by a schema
Webhook trigger gets two deliveries for one record change: one with
triggerId empty, one with triggerId set; two different ids, one eventId.
De-duplicate on eventId. When a publisher has no shared event id (Files,
Membership, Payments, AI triggers) eventId equals id. Older gateways do not
send eventId: Event.EventID, HandleResult.EventID and
Envelope.EffectiveEventID() then fall back to id.
r.On(webhooks.EventDatabaseRecordInserted,
func(ctx context.Context, payload json.RawMessage, e webhooks.Event) error {
if alreadyProcessed(e.EventID) { // your store, e.g. Redis SETNX with a TTL
return nil // answer 2xx so the gateway does not retry
}
// ... handle the change once ...
return nil
})The examples/webhook-receiver server
shows this with an in-memory set.
When Norbix calls one of your functions on AWS Lambda, Azure Functions or
Google Cloud Functions, the function receives one JSON envelope: the event
name, the data of the source event, a short-lived bearer token for the
run-as service user and your own config. norbix.ParseFunctionEvent reads
it, the As* views decode the slot that matches Event into your own
types, and ClientOptions() turns the token and the deployment urls into
the norbix.Options a client takes. Your function is a separate program
that imports this module.
// AWS Lambda — main.go
package main
import (
"context"
"encoding/json"
"github.com/aws/aws-lambda-go/lambda"
"github.com/norbix-code/sdk-go/v2/norbix"
)
type Order struct {
ID string `json:"_id"`
Status string `json:"status"`
Total float64 `json:"total"`
}
func handler(ctx context.Context, raw json.RawMessage) (map[string]any, error) {
event, err := norbix.ParseFunctionEvent(raw) // *norbix.CodeFunctionEvent; a *errors.Error on a foreign body
if err != nil {
return nil, err
}
if event.Event != "database.record.updated" {
return map[string]any{"ok": true}, nil
}
var old, next *Order
if _, err := event.AsDatabaseRecordChange(&old, &next); err != nil { // nil when absent
return nil, err
}
if next != nil && next.Status == "processing" && (old == nil || old.Status != next.Status) {
c, err := norbix.New(event.ClientOptions()) // bearer = context.runAs.accessToken (15 min), urls from the envelope
if err != nil {
return nil, err
}
defer c.Close()
doc, _ := json.Marshal(map[string]any{"orderId": next.ID, "status": "processing", "eventId": event.EventID})
if err := c.API.Database.InsertOne(ctx, "order_events", map[string]any{"document": string(doc)}, nil); err != nil {
return nil, err
}
}
return map[string]any{"ok": true}, nil
}
func main() { lambda.Start(handler) }| View | Source | Gives |
|---|---|---|
AsDatabaseRecordChange(&old, &new) |
database |
Collection, SchemaID, SchemaVersion, RecordID, Truncated, OldRef / NewRef (documents above 200 KB are left out; fetch them with the refs); old / new decoded into your type, untouched when absent |
AsUserChange(&old, &new) |
membership |
UserID; the user documents decoded |
AsFileChange(&old, &new) |
files |
Path; the file documents decoded |
AsPayment() |
payments |
the webhook fact (Provider, EventName, OrderID, Status, AmountMinor, Currency, ExternalIDs, …); on payments.order.* the callback that changed the order |
AsPaymentOrder() |
payments (payments.order.*) |
OrderEventID (the de-duplication key, the same on every retry), Event, From, To, Reason, Order (ID, Status, Lines, TotalMinor, SettledMinor, RefundedMinor, Metadata, Checkouts, …), Payment |
AsAIUsage(&payload) |
ai |
TargetUserID; the usage payload decoded |
AsScheduledTask() |
scheduler |
TaskID, Name, FiredAt |
Payload(&out) |
manual, scheduler |
the caller's JSON object decoded; untouched when none |
DecodeConfig(&out) |
any | your config, bound at fire time; event.Config is nil when the action has none |
A view of another source is a *errors.Error with code
NORBIX_FUNCTION_EVENT_NO_SLOT naming the slot and the event.
ParseFunctionEvent refuses a body that is not an envelope
(NORBIX_FUNCTION_EVENT_INVALID) or whose specVersion is newer than 1
(NORBIX_FUNCTION_EVENT_UNSUPPORTED). The raw slots stay on event.Data
(json.RawMessage), the whole body on event.Raw().
From your backend — the functions an integration may call, their aliases and versions, a call by hand, a sample envelope for a local run and the last runs of a trigger:
integrationID := "int_6rBACnEaxQ0x1jTiHtAuWV"
var functions, qualifiers, result, sample, runs map[string]any
_ = c.Hub.Code.GetCodeFunctions(ctx, map[string]any{"integrationId": integrationID, "prefix": "orders-"}, &functions)
_ = c.Hub.Code.GetCodeFunctionQualifiers(ctx, map[string]any{"integrationId": integrationID, "functionId": "orders-on-update"}, &qualifiers)
_ = c.Hub.Code.InvokeCodeFunction(ctx, map[string]any{
"functionRef": map[string]any{"integrationId": integrationID, "functionId": "orders-on-update", "qualifier": "prod"},
"payload": map[string]any{"orderId": "6ac54a59212c29912eaddd81", "dryRun": true},
"invocationType": "Sync",
}, &result)
_ = c.Hub.Code.GetCodeFunctionEventSample(ctx, map[string]any{"source": "database", "event": "database.record.updated"}, &sample)
_ = c.Hub.Code.GetCodeFunctionRuns(ctx, map[string]any{"triggerId": "trg_1", "limit": 20}, &runs)A run through the Fake provider (a dry run) also carries input
(dtos.CodeFunctionRunDto.Input) — the envelope the function received, as
JSON, with context.runAs.accessToken masked to *** and the text cut at
64 KB. It is empty for every real provider (the payload is the project's
data) and when the function was not called. There is no default Code
integration: every call names the integration it means (integrationId),
as the examples above do.
Each call of a marketplace function is one run row: an invoke (user, API
key, AI), a trigger run or a scheduler fire.
client.Hub.Code.GetMarketplaceFunctionRuns lists them — pass exactly one
of functionId (every source, newest first, up to 50), triggerId or
taskId. A row (dtos.MarketplaceFunctionRunDto) carries Status (ok /
failed / skipped, empty on rows written before the field existed),
DurationMs, Source, Event, CorrelationId and HasDetails.
The input, steps and response of a run are written to the project log under
the row's CorrelationId, only while the environment's Norbix Logging is on
— then HasDetails is true. Read them with the logs route
(GET /{version}/logs/audit); its query field is targetCorrelationId:
var runs dtos.GetMarketplaceFunctionRunsResponse
err := client.Hub.Code.GetMarketplaceFunctionRuns(ctx, map[string]any{"functionId": "func_…", "limit": 10}, &runs)
for _, run := range runs.Runs {
if !run.HasDetails || run.CorrelationId == "" {
continue // logs were off for this run
}
var details dtos.GetLogsByCorrelationIdResponse
err = client.Hub.Logs.GetLogsByCorrelationId(ctx, map[string]any{"targetCorrelationId": run.CorrelationId}, &details)
// details.Items: marketplace:run:started → marketplace:run:step … → completed | failed | skipped
}An MCP connection stores its server's tool catalog when it is validated.
client.Hub.Code.GetMarketplaceIntegrationTools reads it
(GET /{version}/code/marketplace/integrations/{IntegrationViewId}/tools)
into dtos.GetMarketplaceIntegrationToolsResponse: Tools
(Name, Title, Description, InputSchema as JSON text, ReadOnlyHint,
DestructiveHint), ListedAt, CatalogHash, Truncated. A connection that
was never validated answers CM-ERRORS-MARKETPLACE-020.
var catalog dtos.GetMarketplaceIntegrationToolsResponse
err := client.Hub.Code.GetMarketplaceIntegrationTools(ctx, "int_…", map[string]any{}, &catalog)Note: Go's generated ReadOnlyHint / DestructiveHint are plain bool, so
"not set by the server" and false read the same.
Who may use a connection is set per role with generated access packages
(dtos.MarketplaceAccessLevel: None, Viewer, FunctionsOnly, ReadOnly,
FullAccess, plus the manage flag):
| Method | Call |
|---|---|
GetMarketplaceIntegrationAccess |
GET …/integrations/{IntegrationViewId}/access → dtos.GetMarketplaceIntegrationAccessResponse (Roles, Packages, Tools, Functions) |
SetMarketplaceIntegrationRoleAccess |
PUT …/access/roles, body roleId, package, manage → dtos.IdResponse (the role id) |
SaveMarketplaceIntegrationCustomAccess |
POST …/access/roles/custom, body roleId, basePackage, tools, functions, manage, newPolicyForThisRoleOnly → dtos.SaveMarketplaceIntegrationCustomAccessResponse |
StartMarketplaceIntegrationSignIn (POST …/integrations/{IntegrationViewId}/oauth/start
→ dtos.StartMarketplaceIntegrationSignInResponse) starts the OAuth sign-in of
an MCP connection: open AuthorizeUrl in a browser. A signed-in connection
shows SignedIn, SignedInBy, SignedInAt, TokenExpiresAt.
SignOutMarketplaceIntegration (POST …/oauth/sign-out → dtos.SignOutMarketplaceIntegrationResponse) signs an MCP connection out: WasSignedIn, Revoke (Revoked, NoRevocationEndpoint, Failed or empty).
TestMarketplaceIntegration also answers AccessChanges (ReadOnlyAdded,
ReadOnlyRemoved, NotGrantable) and each step's ErrorCode.
err := client.Hub.Code.SetMarketplaceIntegrationRoleAccess(ctx, "int_…", map[string]any{
"roleId": "acc_…_nr_…", "package": dtos.MarketplaceAccessLevelReadOnly, "manage": false,
}, nil)client.API.Ai is the end-user AI chat for a signed-in project user:
availability, sessions, entries, feedback, attachments, memory and
StartEndUserChatTurn, which answers at once with a turnId. The answer
streams over the gateway's SSE endpoint on the user's own channel
ai-chat:{projectId}:{authId} (events ai.chat.turn.*, ai.chat.session.*);
a subscription to another user's channel is refused with 403 and
responseStatus.errorCode = "AiChatChannelRefused" before the stream starts —
do not retry it. This SDK has no SSE client; decode the events with the
dtos package.
var out dtos.StartEndUserChatTurnResponse
err := client.API.Ai.StartEndUserChatTurn(ctx, map[string]any{"sessionId": id, "message": "Hi"}, &out)Project owners configure the assistant on the Hub: client.Hub.Account
(GetProjectAiSettings, UpdateProjectAiSettings, CreateProjectAiAssistant,
UpdateProjectAiAssistant, DeleteProjectAiAssistant, GetProjectAiUsage,
SetAdminPortalEnabled) and client.Hub.Ai (GetEmbeddingIntegrations,
SaveEmbeddingIntegration, GetEmbeddingIntegration,
DeleteEmbeddingIntegration, TestEmbeddingIntegration,
SetLlmIntegrationAsDefault).
- Account and projects — project settings, CORS, legal documents, Admin Portal, public project config, project AI settings, AI service users, developer MCP endpoint
- AI integrations — LLM and MCP integrations
- Database — schemas, records (Hub + Api), taxonomies and term trees, schema triggers, database integrations, saved aggregates
- Push — push integrations, templates, campaigns, devices
- Scheduler — cron tasks (email campaigns), module enable / disable (PUT)
norbix/
client.go, client_split.go, config.go full / API-only / Hub-only clients
errors/ error taxonomy
internal/transport/ shared HTTP layer + tests
api/ <module>.go, namespace.go API endpoint modules
api/dtos/ generated API DTO structs
hub/ <module>.go, namespace.go Hub endpoint modules
hub/dtos/ generated Hub DTO structs
webhooks/ receiver, signature, events + tests
examples/webhook-receiver/ runnable webhook sink
norbix/api/dtos/dtos.go and norbix/hub/dtos/dtos.go are generated from the
Norbix gateway's own contract, straight from a running gateway. They carry a
DO NOT EDIT header: a change made here is lost on the next run. If a type is
wrong, the gateway or the generator is wrong.
The endpoint modules (norbix/api/*.go, norbix/hub/*.go) are not
generated today — the script that made them is gone. They are maintained by
hand, with a test per method, and they keep their old header until a module
generator exists.
Start the gateway (API on :5002, Hub on :5001), then run the generator from
the private typegen toolchain — one command, both files:
python3 <typegen>/languages/go/generate.py --out .
go build ./norbix/... && go vet ./norbix/... && gofmt -l norbix/ && go test ./norbix/...--api-url and --hub-url point it somewhere else; --api-file / --hub-file
read a saved contract export instead of a live gateway. Two runs on the same
gateway give byte-identical files, so a non-empty git diff after a run is a
real contract change — read it before committing.
The major version is frozen at v2 until the public launch.
- A breaking change is released as a minor (for example v2.2.0 → v2.3.0), never as a new major.
- Write it as
feat(<scope>): <what>and add a lineBreaking: <what changed and what callers must do>in plain words, in the pull-request body and in the commit message. - Never mark it the conventional-commits way: no
!in the title (feat!:), no BREAKING CHANGE footer. ThePR titlecheck fails a pull request that does. - As a safety net, the release config (
.releaserc.json→releaseRules) maps breaking commits to a minor, so one that slips through still does not bump the major.
MIT