Function SDK reference
Handler exports, input and output shapes, SDK calls, hook contracts, error codes and every limit.
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.
| Kind | Export | Signature |
|---|---|---|
| Platform event | onPlatformEvent | (event: PlatformEvent) => void | Promise<void> |
Hook contact.create.pre | onContactCreatePre | (input: ContactCreateInput) => HookOutput<ContactCreateResult> | Promise<…> |
Hook contact.verify | onContactVerify | (input: ContactVerifyInput) => HookOutput<ContactVerifyResult> | Promise<…> |
The types come from the ./omnilab module, available in every function:
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
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
datacarries nocontact_idis skipped, so an anonymous visitor's activity never runs a function. - Page visits never trigger a function. A subscription that names
touchpoint.page_visit.v1is refused when you save it. - Delivery is at-least-once. The same
idcan 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.
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
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:
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.
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.
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
| Call | Returns | Notes |
|---|---|---|
OmniLab.Config.get(key) | The plugin config value, or undefined | Non-sensitive configuration only |
OmniLab.Config.getSecret("bundle.KEY") | The secret value | Keys 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
| Call | Notes |
|---|---|
OmniLab.Http.fetch(url, options?) | The general form |
OmniLab.Http.get / post / put / patch / delete | Set the method for you. get and delete take no body. |
The options are a subset of the web fetch options:
| Option | Type | Notes |
|---|---|---|
method | string | GET when you leave it out |
headers | Record<string, string> | Sent as given. No Content-Type is added for you. |
body | string | Text only: serialise JSON yourself with JSON.stringify |
A request that gets an answer resolves to a response, whatever its status:
| Field | Type | Notes |
|---|---|---|
ok | boolean | true for a 2xx status |
status | number | The HTTP status. A 4xx or 5xx resolves normally rather than throwing. |
statusText | string | The reason phrase alone, such as Not Found |
headers | Record<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:
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
| Call | Returns |
|---|---|
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:
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.
| Code | Retryable | Typical cause |
|---|---|---|
NOT_FOUND | No | A secret key that doesn't exist (keys are case-sensitive), a function with no bundle bound, or a contact that doesn't exist |
PERMISSION_DENIED | No | getSecret names a bundle that isn't bound to this function |
INVALID_ARGUMENT | No | A reserved custom field key, a malformed value, or a secret reference not shaped bundle.KEY |
ALREADY_EXISTS | No | A conflicting write |
EGRESS_BLOCKED | No | The hostname isn't allow-listed, or resolves to a private address |
RESOURCE_EXHAUSTED | No | The 21st SDK call in one invocation. The invocation is stopped, so catching it doesn't help. |
TIMEOUT | Yes | An HTTP request exceeded its 1500 ms limit |
UNAVAILABLE | Yes | A dependency was temporarily unreachable |
INTERNAL | Yes | An 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
| Limit | Value |
|---|---|
| Timeout | 50 – 2000 ms, default 2000 |
| Memory | 4 MiB for every handler. Max memory pages accepts 1 – 64 pages of 64 KiB, default 32, but isn't applied yet |
| SDK calls | 20 |
| HTTP request timeout | 1500 ms |
| HTTP response body | 4 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
| Limit | Value |
|---|---|
| Source size | 1 MB |
| Allowed hosts | 16 |
| Plugin config | 32 keys, 4 KB total |
| Secret bundles bound | 5 |
| Builds in flight | 1 |
Across all your functions
| Limit | Value |
|---|---|
| Active subscriptions per event type | 5 |
| Active hook bindings per hook point | 1 global, plus 1 per group |
| Secret bundles | 5 |
| Secrets per bundle | 32 |
| Secret bundle key | Up to 32 characters: lowercase letters, digits and underscores, starting with a letter |
| Secret key | Up to 64 characters: letters, digits and underscores, starting with a letter. Case-sensitive |
Retention and truncation
| Item | Value |
|---|---|
| Execution records | 60 days |
| Recorded input and output | 256 KB per side, truncated beyond, with original sizes reported |
| Captured logs | 16 KB total, 256 lines, 2 KB per line |
| Build log shown in Studio | The last 8 KB |
Build
| Limit | Value |
|---|---|
| Type check when you save | 10 seconds |
| Build | 3 minutes |
| A stalled build is released, so you can start another | 15 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.