Function SDK reference

Handler exports, input and output shapes, SDK calls, hook contracts, error codes and every limit.

10 min read

The complete contract between your TypeScript and the OmniLab runtime.

Handler exports

Your file must export exactly one handler, named after the function's kind. Saving checks that export against the type for that kind, so with a missing or wrongly shaped handler, saving fails instead of the function failing in production.

KindExportSignature
Platform eventonPlatformEvent(event: PlatformEvent) => void | Promise<void>
Hook contact.create.preonContactCreatePre(input: ContactCreateInput) => HookOutput<ContactCreateResult> | Promise<…>
Hook contact.verifyonContactVerify(input: ContactVerifyInput) => HookOutput<ContactVerifyResult> | Promise<…>

The types come from the ./omnilab module, available in every function:

Importing the SDK and its types
import { OmniLab, contactDisplayName } from "./omnilab";
import type { PlatformEventHandler, ContactCreatePreHandler } from "./omnilab";

OmniLab is the SDK object. contactDisplayName is a pure helper for resolving a contact's name: see the two name shapes.

Handlers may be async. Only TypeScript is supported, in a single file whose entry point is user.ts.

Input shapes

PlatformEvent

What a platform-event function receives
type PlatformEvent = {
  type: string;   // e.g. "touchpoint.completed.v1"
  id: string;     // unique per event: deduplicate on this
  time: string;   // RFC 3339
  source: string;
  data: Json;     // the event payload, exactly as OmniLab published it
};

Event payloads

data holds the event's fields, and they differ by event type. The event reference lists them for every type, and shows which types reach a function. Cast data to the shape you expect, and treat every field as possibly absent.

How platform events are delivered

These rules apply on top of whatever a subscription selects:

  • Only events with an identified contact trigger a function. An event whose data carries no contact_id is skipped, so an anonymous visitor's activity never runs a function.
  • Page visits never trigger a function. A subscription that names touchpoint.page_visit.v1 is refused when you save it.
  • Delivery is at-least-once. The same id can reach your handler more than once.

Setting a contact field to the same value twice is harmless. Two things are not: calling an API that charges money, sends a message or allocates a voucher, and appending to a field you just read, which can lose one of the two writes. Deduplicate on event.id before either.

Hook contracts

Every hook input carries the same context object: metadata about the invocation, whatever the hook point. It is always present, and a field OmniLab didn't fill is an empty string.

HookContext, on every hook input
interface HookContext {
  idempotency_key: string;         // a new key per verification send; empty on contact.create.pre
  group_id: string;                // the organisation the binding was resolved for
  interaction_id: string;          // the campaign behind the operation, e.g. "interactions/<id>"
  interaction_public_key: string;  // the public key of that campaign's entry point
}

group_id, interaction_id and interaction_public_key tell you which organisation's binding routed the call and which campaign the operation came from. A sign-up through a campaign fills all three. A contact created from admin, import or the API carries no campaign, so the interaction fields stay empty.

Only contact.verify fills idempotency_key. OmniLab never retries a hook call, and each send, including a resend, gets a new key. Forward it to a provider that accepts one.

contact.create.pre receives an empty key. If your handler calls a provider that charges or changes something, supply your own dedupe value: the contact's email is the usual choice.

What a hook handler returns

HookOutput, from ./omnilab
type HookOutput<T> =
  | { decision: "continue"; result?: T }
  | { decision: "deny"; code: string; message: string };

OmniLab.Flow.continue(result?) and OmniLab.Flow.deny(code, message) build these values, and a plain object of the same shape is accepted too. A deny with an empty code is reported as DENIED.

The contract is broken when the handler returns nothing, uses another decision, or returns a result field the hook point doesn't define or gives it the wrong type. A broken contract counts as a failure. What each outcome does is in What a hook guarantees.

A contact carries one of two name shapes

Both hook points hand you a contact whose name arrives in one of two shapes, fixed by the campaign that acquired it:

  • firstname + lastname: what an acquisition form collects by default;
  • fullname: a single string, for campaigns whose form collects one Full Name field.

Never both. The shape the contact doesn't carry is empty, so reading firstname alone leaves a greeting blank for a Full Name campaign, and firstname + " " + lastname renders a lone space. Use the helper instead of concatenating:

Resolving a display name
import { contactDisplayName } from "./omnilab";

const name = contactDisplayName(input.contact);

It resolves fullname, then firstname + lastname, then email, and returns an empty string when the contact has no usable name. Render nothing or your own placeholder then, never contact.id, which is an opaque contacts/<uuid>.

Names are read-only from a hook. Neither result type carries a name field, so a function can't write either shape back.

contact.create.pre

Runs before a contact is stored, from sign-up, admin, import or API.

ContactCreateInput
interface ContactCreateInput {
  contact: {
    email: string;
    phone_number: string;
    firstname?: string;
    lastname?: string;
    fullname?: string;                 // set instead of firstname and lastname: see above
    custom_fields?: Record<string, string>;
    optin?: boolean;
  };
  source: "signup" | "admin" | "import" | "api";
  context: HookContext;                // idempotency_key is empty for this hook point
}

Continuing accepts an optional email, phone_number and custom_fields. Only the fields you return are applied: omit one to leave it as submitted.

optin is the marketing consent the visitor submitted. It is always sent, so false means they didn't opt in, never "unknown". It is read-only: the result carries no optin, so a function can't rewrite consent.

source is typed with four values, but every call sends "signup", whatever created the contact. Don't branch on it.

contact.verify

Runs when a double opt-in sign-up needs its verification email. Your handler delivers it.

ContactVerifyInput
interface ContactVerifyInput {
  contact: {
    email: string;
    phone_number?: string;
    firstname?: string;
    lastname?: string;
    fullname?: string;                 // set instead of firstname and lastname: see above
    optin?: boolean;
    custom_fields?: Record<string, string>;
  };
  verification_url: string;            // the link your email must point to
  expires_at: string;                  // RFC 3339 time when the link stops working
  context: HookContext;                // all four fields filled for a campaign sign-up
}

custom_fields holds the acquisition form's extra answers as submitted, so you can personalise the message from them. optin may steer which template you send, but it is not permission to skip the link: delivering verification_url is still required.

Continuing accepts an optional provider_message_id, recorded in the execution log only, and an optional already_verified. Set already_verified: true only when your records hold a confirmed opt-in for the address: see Skipping the email.

coupon.assign is not available

A coupon.assign hook point appears in the interface labelled "coming soon", and the SDK carries its types, but binding, invoking and testing it are all refused. Don't build against it.

SDK surface

These are the only calls that reach outside the sandbox. Everything else in your handler is pure computation.

Config

CallReturnsNotes
OmniLab.Config.get(key)The plugin config value, or undefinedNon-sensitive configuration only
OmniLab.Config.getSecret("bundle.KEY")The secret valueKeys are case-sensitive. Throws PERMISSION_DENIED when the bundle isn't bound to the function, and NOT_FOUND when the key doesn't exist.

Http

CallNotes
OmniLab.Http.fetch(url, options?)The general form
OmniLab.Http.get / post / put / patch / deleteSet the method for you. get and delete take no body.

The options are a subset of the web fetch options:

OptionTypeNotes
methodstringGET when you leave it out
headersRecord<string, string>Sent as given. No Content-Type is added for you.
bodystringText only: serialise JSON yourself with JSON.stringify

A request that gets an answer resolves to a response, whatever its status:

FieldTypeNotes
okbooleantrue for a 2xx status
statusnumberThe HTTP status. A 4xx or 5xx resolves normally rather than throwing.
statusTextstringThe reason phrase alone, such as Not Found
headersRecord<string, string>Names in lower case. A repeated header's values are joined with , .
text()Promise<string>The body as text
json<T>()Promise<T>The body parsed as JSON. Throws as soon as it's called when the body isn't valid JSON.

json() throws on the spot rather than returning a rejected promise, so response.json().catch(...) never sees a bad body. An empty body, such as a 201 or 204 with no content, isn't valid JSON either. Call it inside try/catch:

user.ts: post JSON and read the answer
import { OmniLab } from "./omnilab";
import type { PlatformEventHandler } from "./omnilab";

type WithContact = { contact?: { contact_id?: string } };

export const onPlatformEvent: PlatformEventHandler = async (event) => {
  const contactId = (event.data as WithContact).contact?.contact_id;

  const response = await OmniLab.Http.post("https://crm.lindenhall.example/api/visits", {
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + OmniLab.Config.getSecret("crm.API_TOKEN"),
    },
    body: JSON.stringify({ contactId, eventId: event.id }),
  });

  if (!response.ok) {
    throw new Error("CRM answered " + response.status + " " + response.statusText);
  }
  if (response.status === 204) {
    return; // accepted, with nothing to read
  }

  try {
    const visit = await response.json<{ id: string }>();
    console.log("CRM visit " + visit.id);
  } catch {
    console.warn("CRM answered " + response.status + " without a JSON body");
  }
};

Every request is checked against the function's allowed-hosts list, and internal or private addresses are refused whether or not you listed them. Only http and https are allowed. A request that fails before any answer rejects with an error code: EGRESS_BLOCKED, TIMEOUT or UNAVAILABLE. A response body over the size limit is truncated, and the response carries the header x-omnilab-response-truncated: true.

Contact

CallReturns
OmniLab.Contact.get(id)Promise<Contact>
OmniLab.Contact.setCustomField(id, key, value)Promise<Contact>: the updated contact
OmniLab.Contact.setCustomFields(id, fields)Promise<Contact>: all or nothing

All three resolve to the same object, so a write hands back the contact it just changed and you rarely need a follow-up read:

Contact
interface Contact {
  id: string;                              // "contacts/<uuid>"
  firstname: string;                       // one of two name shapes: see below
  lastname: string;
  fullname: string;
  email: string;
  phoneNumber: string;
  externalId: string;
  barcode: string;
  customFields: Record<string, string>;    // the only writable part
  emailVerified: boolean;
  optin: boolean;
  hasAcceptedTerms: boolean;
  isBlacklisted: boolean;
  blacklistReason: string;
  originInteraction: string;
}

Every field except customFields is read-only from a function. Custom field values are always strings.

A contact read here carries the same two name shapes as a hook input: firstname + lastname, or fullname, never both. Resolve it with contactDisplayName(contact) rather than reading the parts.

Custom field keys beginning system. or feature. are reserved and rejected with INVALID_ARGUMENT. A key can be up to 128 characters and a value up to 4 KB. Prefer setCustomFields for several keys: it is one call against your budget rather than several.

Logging

console.log, console.info, console.warn, console.error, console.debug and console.trace are captured into the execution record and shown in the Test tab. Capture is limited to 16 KB total, 256 lines and 2 KB per line; beyond that the log is marked truncated.

Designing within the SDK

The calls above are the whole surface. When your logic needs more, reach your own systems over HTTP and do the work there. Three consequences to plan around:

Keep your state outside the function. Every invocation starts fresh, so a value you assign at module level is gone by the next one. Persist what you need to contact custom fields, or to your own service.

Reach other OmniLab records through the API. Functions read and write contacts. For transactions, rewards, smart links and everything else, call the API from your own systems instead.

Functions run on triggers, not on a clock. There is no way to schedule one. If you need work to happen at a set time, run it from your own scheduler.

Error codes

SDK failures throw an OmniLabError carrying a code, a message and a retryable flag. Import the type from ./omnilab and read (error as OmniLabError).code.

CodeRetryableTypical cause
NOT_FOUNDNoA secret key that doesn't exist (keys are case-sensitive), a function with no bundle bound, or a contact that doesn't exist
PERMISSION_DENIEDNogetSecret names a bundle that isn't bound to this function
INVALID_ARGUMENTNoA reserved custom field key, a malformed value, or a secret reference not shaped bundle.KEY
ALREADY_EXISTSNoA conflicting write
EGRESS_BLOCKEDNoThe hostname isn't allow-listed, or resolves to a private address
RESOURCE_EXHAUSTEDNoThe 21st SDK call in one invocation. The invocation is stopped, so catching it doesn't help.
TIMEOUTYesAn HTTP request exceeded its 1500 ms limit
UNAVAILABLEYesA dependency was temporarily unreachable
INTERNALYesAn unexpected platform error

The SDK's OmniLabErrorCode type also lists RATE_LIMITED and QUOTA_EXCEEDED, but no SDK call raises them.

Error messages are meant for a human reading the execution log, and their wording isn't stable. A TIMEOUT or UNAVAILABLE message names the call, the host for an HTTP request, and the reason: Http.fetch POST crm.lindenhall.example failed: connection refused, or Contact.get failed: contact service unreachable. Never parse one: branch on the code.

Failure semantics

  • Hooks: what happens on a deny, an error, a timeout or a missing binding, for each hook point, is in What a hook guarantees.
  • Platform events: the activity behind the event is never affected. Retries are described in Failure and retries.

Limits

Per invocation

LimitValue
Timeout50 – 2000 ms, default 2000
Memory4 MiB for every handler. Max memory pages accepts 1 – 64 pages of 64 KiB, default 32, but isn't applied yet
SDK calls20
HTTP request timeout1500 ms
HTTP response body4 MiB, truncated beyond

The timeout covers your handler only. The time OmniLab spends preparing to run it isn't charged against your limit: the execution detail reports the two separately.

Per function

LimitValue
Source size1 MB
Allowed hosts16
Plugin config32 keys, 4 KB total
Secret bundles bound5
Builds in flight1

Across all your functions

LimitValue
Active subscriptions per event type5
Active hook bindings per hook point1 global, plus 1 per group
Secret bundles5
Secrets per bundle32
Secret bundle keyUp to 32 characters: lowercase letters, digits and underscores, starting with a letter
Secret keyUp to 64 characters: letters, digits and underscores, starting with a letter. Case-sensitive

Retention and truncation

ItemValue
Execution records60 days
Recorded input and output256 KB per side, truncated beyond, with original sizes reported
Captured logs16 KB total, 256 lines, 2 KB per line
Build log shown in StudioThe last 8 KB

Build

LimitValue
Type check when you save10 seconds
Build3 minutes
A stalled build is released, so you can start another15 minutes

There are no rate limits on invocation. Volume is bounded instead by the SDK call budget, the subscription cap, and the exclusion of anonymous page-visit events.

Next steps

On this page