Skip to content
norbix-codePublic

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

norbix-go

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]any in, any out pointer) so you can decode into the generated DTO structs in norbix/api/dtos and norbix/hub/dtos.

Install

go get github.com/norbix-code/sdk-go/v2/norbix

Requires Go 1.22+.

Quick start

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)
}

User-on-behalf-of (login)

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

Client shapes

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).

Configuration

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.

Errors

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.

Breaking change — a refused call now returns an error

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.

Email: stop a campaign, check a domain, read preferences from a link

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).

Notifications: provider id, languages, triggers that need attention

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).

SMS: Account users audience, campaign id filter; subject removed

  • 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 with client.Hub.Account.UpdateMyAccountUserPhone; members without a phone are skipped. See docs/hub/account.md.
  • GetSmsCampaigns takes "campaignId" to return only that campaign.
  • An SMS template has a body only: dtos.SmsMessageContentDto has no Subject any more, and neither do SMS campaign messages (Email and Push messages keep theirs).
  • dtos.SmsCampaignDto.CreatedById says who created the campaign.

Database: nested fields, expanded references, files by id

  • A schema may hold object and array fields at any depth (dtos.ObjectFieldDto, dtos.ArrayFieldDto); the nested value is just part of the record's JSON text. "arrayFilters" on UpdateOne / UpdateMany (Api) and UpdateOneRecord / UpdateManyRecords (Hub) changes one element of a nested array in place.
  • "expandReferences": true on Find / FindOne / FindOwn (Api) and FindRecords / FindOneRecord (Hub) returns every reference field as {id, display} — decode it into api.ExpandedReference / hub.ExpandedReference. One unreadable linked source refuses the read with CM-ERRORS-DATABASE-056.
  • c.API.Files.GetFileById(ctx, filesIntegrationID, id, nil, &out) and c.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 as GetFileInfo / GetFile.
  • Record validation refusals now carry one code per keyword (CM-ERRORS-DATABASE-039 … -056); see docs/hub/database.md.

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.

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).

List only top-level terms (filtered)

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.

Get a term's children

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.

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.

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.

Get the whole term tree in one call

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.

Get only a sub-tree, capped by depth

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.

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.

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.

Get the taxonomy structure tree — with 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 databaseIntegrationId key in the request map to target a non-default database.

Payment orders and checkout

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.PaymentURL

When 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.

Webhooks

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.

id vs eventId — de-duplication

Each POSTed envelope is { id, eventId, event, createdOn, accountId, projectId, triggerId?, data }.

  • id (Envelope.ID, Event.DeliveryID, also X-Norbix-Delivery) is one per delivery. A retry of the same delivery keeps its id — 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 same eventId.

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.

Cloud functions

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.

Marketplace function runs

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
}

Marketplace MCP connections — tool catalog

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.

Marketplace connections — access

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)

End-user AI chat and project AI settings

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).

Module guides

  • 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)

Package layout

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

Codegen notes

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.

Regenerate the types

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.

Versioning

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 line Breaking: <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. The PR title check 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.

License

MIT

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages