Function SDK reference
Handler exports, input and output shapes, the available SDK calls, hook contracts, error codes, event types, 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. The build checks that export against the type for that kind, so a missing or wrongly shaped handler fails the build instead of failing in production.
| Kind | Export | Signature |
|---|---|---|
| Platform event | onPlatformEvent | (event: PlatformEvent) => void | Promise<void> |
Hook contact.create.pre | onContactCreatePre | (input: ContactCreateInput) => HookOutput<ContactCreateResult> |
Hook contact.verification | onContactVerification | (input: ContactVerificationInput) => HookOutput<ContactVerificationResult> |
The types come from the ./omnilab module, available in every function:
import { OmniLab } from "./omnilab";
import type { PlatformEventHandler, ContactCreatePreHandler } from "./omnilab";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; // ISO-8601
source: string;
data: Json; // the event payload, exactly as OmniLab published it
};Delivery is at-least-once. The same id can arrive more than once, so make external side effects idempotent.
Event payloads
data differs by event type. Every payload carries the same context block, and each family adds its own fields on top.
data: {
web_context: { utm: { … }, user_agent: { is_bot, is_mobile, is_desktop, is_tablet } },
interaction: { interaction_id, interaction_display_name, interaction_public_key, interaction_snapshot_id },
contact: { contact_id },
group_context: { group_id, unique_key },
tenant_context:{ subdomain, db_name },
}contact.contact_id is the id you pass to OmniLab.Contact.get and the field writers, so it is the one you will read most.
| Family | Adds to data |
|---|---|
touchpoint.* and cta.clicked.v1 | touchpoint_id, touchpoint_title, touchpoint_type, interactive_totem_type, instant_game_type, event_id |
reward.* | reward_id, internal_reward_id, reward_display_name, reward_winning_method |
reward.won.v1, reward.redeemed.v1 | the reward fields, plus coupon_code_id and coupon_code_value |
contact.created.v1 | email, firstname, lastname |
contact.identified.v1, contact.authenticated.v1 | email |
notification.sent.v1 | notification_id, channel |
feedback.submitted.v1 | feedback, rating |
question.answered.v1 | question_id, answer |
Get the exact payload from Studio rather than from this table
Open your function's Test tab and use Load example. It fills the input editor with a complete, current envelope for any event type in the catalogue — the same shape production delivers. Treat that as the authority and this table as a planning summary, and paste the example straight in to develop against real fields.
Read data defensively even so. Cast it to the shape you expect, and treat every field as possibly absent rather than assuming a payload is fully populated.
Hook contracts
Every hook input carries a context object, but only contact.verification populates its idempotency_key. OmniLab never retries a hook invocation; the key exists because the operation around it can be attempted again, and it stays stable across those attempts — forward it to any provider that accepts one so a repeat cannot cause a duplicate send. An explicit resend deliberately carries a different key.
contact.create.pre receives an empty key. Creating a contact has no external side effect OmniLab owns, so no key is computed for it. If your handler calls out to a provider that charges or mutates something, you have to supply your own dedupe value — the contact's email is the usual choice.
Build your return value with OmniLab.Flow — never return a plain object:
OmniLab.Flow.continue(result?) // proceed, optionally supplying values
OmniLab.Flow.deny(code, message) // block the operationcontact.create.pre
Runs before a contact is stored, from signup, admin, import, or API.
{
contact: {
email: string;
phone_number: string;
firstname: string;
lastname: string;
custom_fields: Record<string, string>;
};
source: string; // always "signup" today
context: { idempotency_key: string }; // empty for this hook point
}Continuing accepts an optional email, an optional phone_number, and custom_fields. Only the fields you return are applied — omit a field to leave it as submitted. Denying rejects the contact and the operation fails.
The hook runs before the contact is stored and before any connected CRM is updated, so the email you return is the value OmniLab persists and the value it forwards downstream. That is what makes normalizing here worthwhile: returning a lowercased address prevents Foo@example.com and foo@example.com becoming two profiles.
source is declared as a string so more origins can be added, but every call today sends "signup". Don't branch on it yet.
contact.verification
Runs when a double opt-in verification message needs to be sent. Your handler is responsible for actually delivering it.
{
contact: { email: string; firstname: string; lastname: string };
interaction: { id: string; public_key: string };
verification_url: string;
expires_at: string;
context: { idempotency_key: string };
}Continuing accepts an optional provider_message_id, which is recorded in the execution log and changes nothing. Continuing means the dispatch was accepted, not that the message arrived. Denying makes the signup fail.
coupon.assign is not available
A coupon.assign hook point appears in the interface labelled "coming soon", and the SDK still carries its types, but it is not available yet — binding, invoking, and testing it are all refused. Do not build against it until it is released.
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 | Throws NOT_FOUND when unset. The bundle must be bound to the function. |
Http
| Call | Notes |
|---|---|
OmniLab.Http.fetch(url, options?) | The general form |
OmniLab.Http.get / post / put / patch / delete | Convenience wrappers |
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. Each request has a 1500 ms timeout, and a response body larger than 4 MiB is truncated with a header marking it.
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:
{
id: string; // "contacts/<uuid>"
firstname: string;
lastname: 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.
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 OmniLab 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 a typed error carrying a code, a message, and a retryable flag.
| Code | Retryable | Typical cause |
|---|---|---|
NOT_FOUND | No | An unset secret, or a contact that does not exist |
PERMISSION_DENIED | No | An operation the function is not allowed to perform |
INVALID_ARGUMENT | No | A reserved custom field key, or a malformed value |
ALREADY_EXISTS | No | A conflicting write |
EGRESS_BLOCKED | No | The hostname is not allow-listed, or resolves to a private address |
TIMEOUT | Yes | An HTTP request exceeded its 1500 ms limit |
UNAVAILABLE | Yes | A dependency was temporarily unreachable |
INTERNAL | Yes | An unexpected platform error |
Error messages are meant for a human reading the execution log, and their wording is not stable. Never parse one — branch on the code.
Failure semantics
| Trigger | On failure | Retries |
|---|---|---|
| Hook | Fails closed — the operation is denied. Applies to handler errors, timeouts, and output that breaks the contract. | None |
| Platform event | The activity behind the event is unaffected; the failed run is recorded | A transient problem is retried with increasing backoff, up to five attempts, and then stops. A permanent problem — the function was deleted, or the event could not be read — stops immediately. A handler that throws is not retried, because the same code would throw again. |
Subscribable event types
These are the event types the picker offers, grouped as it groups them and searchable within it. If OmniLab emits a type that is not listed, you can add it with the custom event field.
| Family | Shown as | Event type |
|---|---|---|
| Touchpoint | Started | touchpoint.started.v1 |
| Touchpoint | Participated | touchpoint.participated.v1 |
| Touchpoint | Participation form filled | touchpoint.participation_form_filled.v1 |
| Touchpoint | Completed | touchpoint.completed.v1 |
| Touchpoint | Terms accepted | touchpoint.terms_accepted.v1 |
| Touchpoint | CTA clicked | cta.clicked.v1 |
| Reward | Eligible | reward.eligible.v1 |
| Reward | Won | reward.won.v1 |
| Reward | Lost | reward.lost.v1 |
| Reward | Redeemed | reward.redeemed.v1 |
| Reward | Temporary won | reward.temporary_won.v1 |
| Reward | Temporary lost | reward.temporary_lost.v1 |
| Reward | Temporary blocked | reward.temporary_blocked.v1 |
| Reward | Expired | reward.expired.v1 |
| Contact | Created | contact.created.v1 |
| Contact | Identified | contact.identified.v1 |
| Contact | Authenticated | contact.authenticated.v1 |
| Other | Notification sent | notification.sent.v1 |
| Other | Feedback submitted | feedback.submitted.v1 |
| Other | Question answered | question.answered.v1 |
Two platform rules apply on top of this list. Anonymous page-visit events are never subscribable, and OmniLab refuses a subscription that names one. And an event that carries no identified contact never triggers a function, whatever your subscription says.
Filters accept a closed set of keys — tenant, group, interaction, and contact — combined with AND and matched exactly.
Limits
Per invocation
| Limit | Value |
|---|---|
| Timeout | 50 – 2000 ms, default 2000 |
| Memory | 1 – 64 pages at 64 KiB each, default 32 pages (2 MiB) |
| 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 is not 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 |
| Active subscriptions per event type | 5 per tenant |
| Active hook bindings per hook point | 1 global, plus 1 per group |
| Builds in flight | 1 |
Per tenant
| Limit | Value |
|---|---|
| Secret bundles | 5 |
| Secrets per bundle | 32 |
| Secret bundle key | Up to 32 characters, lowercase letters, digits, and underscores, starting with a letter |
Retention and truncation
| Item | Value |
|---|---|
| Execution records | 24 hours |
| 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 | 4 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.