Hook functions
Write a function that runs inside an OmniLab operation and decides whether it continues, then bind it to a hook point.
A hook function runs during an operation, not after it. OmniLab pauses, calls your code, and does what your answer says: continue, optionally with values you supply, or refuse outright. That power is the reason hook functions need more care than platform event functions.
When to use it
Choose a hook function when the outcome of an OmniLab operation has to depend on your logic — blocking signups from disposable email domains, normalizing data before it is stored, or sending verification mail through your own provider. If you only want to react to something that already happened, use a platform event function instead.
The two hook points
A hook function is created for one hook point and can only ever bind to that one.
| Hook point | Runs when | Continue lets you | Deny causes |
|---|---|---|---|
contact.create.pre | Before a contact is stored, from signup, admin, import, or API | Rewrite the email, phone number, and custom fields | The contact is rejected |
contact.verification | A double opt-in verification message needs to be sent | Report the provider's message id, for the log only | The signup fails |
A hook is on the critical path
Once a binding is active, every covered operation waits for your function, and if it cannot be evaluated the operation is denied. An error, a timeout, and a return value that does not match the contract are all treated as a refusal. For contact.create.pre that means signups stop for the organizations the binding covers. Test before you activate, and keep the timeout tight.
The two decisions
Always build your return value with OmniLab.Flow. Returning a plain object is a contract violation, and a contract violation is a denial.
OmniLab.Flow.continue(result?) // proceed, optionally supplying values
OmniLab.Flow.deny(code, message) // block the operationcontact.create.pre
Your file exports onContactCreatePre.
import { OmniLab } from "./omnilab";
import type { ContactCreatePreHandler } from "./omnilab";
export const onContactCreatePre: ContactCreatePreHandler = (input) => {
console.log("Hello world! A contact is being created: " + input.contact.email);
return OmniLab.Flow.continue();
};This lets every contact through unchanged, which makes it the right first version: bind it, watch the executions appear, and confirm signups still work before you add any logic that can say no.
Rejecting and rewriting
continue() with no argument changes nothing. Pass an object to have OmniLab apply values — only the fields you return are applied, so omitting one leaves it exactly as submitted.
import { OmniLab } from "./omnilab";
import type { ContactCreatePreHandler } from "./omnilab";
export const onContactCreatePre: ContactCreatePreHandler = (input) => {
const email = input.contact.email.trim().toLowerCase();
if (email.endsWith("@blocked.example")) {
return OmniLab.Flow.deny("blocked_domain", "This email domain is not accepted.");
}
// Only `email` is returned, so the phone number and custom fields are stored as submitted.
return OmniLab.Flow.continue({ email });
};The code and message you deny with are surfaced to the caller, so make the message something a person could act on.
Normalizing here is worth doing because the hook runs before the contact is stored and before any connected CRM is updated — the email you return is the one OmniLab persists and forwards, so lowercasing it prevents Foo@example.com and foo@example.com becoming two profiles.
When your provider fails
If your handler calls out to a third party, decide deliberately what happens when that third party is down. Because hooks fail closed, doing nothing means a vendor outage stops signups for every organization the binding covers.
Catch the failure and continue. A vendor being unreachable is not evidence that a shopper is a fraud:
import { OmniLab } from "./omnilab";
import type { ContactCreatePreHandler } from "./omnilab";
export const onContactCreatePre: ContactCreatePreHandler = async (input) => {
const email = input.contact.email.trim().toLowerCase();
try {
const response = await OmniLab.Http.get(
"https://api.example-checker.com/v1/check?email=" + encodeURIComponent(email),
);
const verdict = await response.json<{ disposable: boolean }>();
if (verdict.disposable) {
return OmniLab.Flow.deny("disposable_email", "Please use a permanent email address.");
}
} catch (error) {
// The checker is unreachable. Let the signup through rather than blocking a
// real customer over someone else's outage — an unscreened signup is a much
// cheaper mistake than a closed door.
console.warn("email check unavailable, allowing signup: " + String(error));
}
return OmniLab.Flow.continue({ email });
};Deny only on a definitive negative answer. Treat "I could not get an answer" as a reason to continue.
A handler timeout cannot be caught
try/catch protects you from an HTTP error, not from running out of time — when the handler's own timeout expires the invocation is cut short and the operation is refused, with no chance to recover. That is why the timeout needs headroom above your dependency's slow tail rather than being tuned down to its median.
contact.verification
Your file exports onContactVerification. This hook point is how OmniLab sends double opt-in verification messages — your handler is responsible for actually delivering them.
Start by proving the wiring without a provider. The verification link arrives in input.verification_url, so log it:
import { OmniLab } from "./omnilab";
import type { ContactVerificationHandler } from "./omnilab";
export const onContactVerification: ContactVerificationHandler = (input) => {
console.log("Hello world! Verification link for " + input.contact.email);
console.log(input.verification_url);
return OmniLab.Flow.continue();
};Sign up with a test contact, open the execution log, and copy the link from the Logs panel to complete verification by hand.
Replace this before you go live
Continuing without sending anything tells OmniLab the message was dispatched when it was not. Real contacts would never receive a link. Use this version only while you set the function up.
Sending through your provider
Add the provider call, and forward input.context.idempotency_key so a repeated attempt cannot send twice. OmniLab never retries a hook invocation itself, but the signup around it can be attempted again — a shopper resubmitting the form, or your storefront retrying the call — and the key is stable across those, so your provider can discard the duplicate. An explicit resend deliberately carries a different key, so a contact who asks for a new link gets one.
This is the one hook point that supplies a key. contact.create.pre receives an empty one, so a handler there that calls a paid provider needs its own dedupe value.
import { OmniLab } from "./omnilab";
import type { ContactVerificationHandler } from "./omnilab";
export const onContactVerification: ContactVerificationHandler = async (input) => {
const response = await OmniLab.Http.post("https://api.brevo.com/v3/smtp/email", {
headers: {
"content-type": "application/json",
"api-key": OmniLab.Config.getSecret("brevo.api_key"),
},
body: JSON.stringify({
to: [{ email: input.contact.email, name: input.contact.firstname }],
templateId: 1,
params: {
FIRSTNAME: input.contact.firstname,
VERIFICATION_URL: input.verification_url,
},
// Forwarded so a retry is deduplicated by the provider.
headers: { "X-Mailin-Custom": input.context.idempotency_key },
}),
});
if (!response.ok) {
return OmniLab.Flow.deny("dispatch_failed", "Could not send the verification email.");
}
const sent = await response.json<{ messageId?: string }>();
return OmniLab.Flow.continue({ provider_message_id: sent.messageId ?? "" });
};Two things this needs before it works: api.brevo.com on the function's allowed-hosts list, and a brevo secret bundle bound to it. Both are covered in Configure runtime and secrets.
Continuing means dispatched, not delivered
continue tells OmniLab your provider accepted the message. It says nothing about whether it arrived. Deny only when the dispatch itself failed, because denying fails the signup.
Bind it to the hook point
Open the function and use the Hooks tab, then select Add binding.
The Hook point is fixed and shown read-only. What you choose is the scope:
| Scope | When to use it | Set it by |
|---|---|---|
| Global | The function should run for every organization in the tenant | Turning on Global (all groups) |
| Specific groups | Only some organizations need this logic | Leaving the toggle off and selecting Target groups |
The interface enforces the scoping rules for you:
- At most one active global binding per hook point. If one exists, the global toggle is disabled and explains why.
- A group belongs to at most one active binding per hook point. Groups already covered appear greyed out.
- A group-specific binding wins over the global one for the groups it names. Use a global binding for the default and group-specific bindings for exceptions.

Select Create. The binding starts Active and takes effect immediately — there is no draft or log-only binding, so the click itself puts your code in the live path.
That makes the scoping rules your rollout tool. Bind to one low-traffic organization first, leave it for a day, read the execution log, and only then widen to a global binding. Widening is a delete-and-recreate, which is cheap; discovering a bad handler across every organization at once is not.
Managing a binding
Each row offers Disable and Delete. Disabling keeps the configuration but stops the routing, which is how you take your logic out of a live flow in one click. Deleting removes it, and routing stops immediately.
There is no edit action — to change which groups a binding covers, delete it and create a replacement.
The function card shows its scope at a glance: Global, the names of the targeted groups, or unbound when a hook function has no active binding.
Double opt-in needs a binding to exist
If double opt-in is enabled for an organization and no function is bound to contact.verification, signups for that organization fail — nothing can send the verification message. Bind the function before enabling double opt-in, not after.
Failure semantics
Hooks fail closed, uniformly. There are no retries: the operation is waiting, so there is no time for one.
| What happened | What OmniLab does |
|---|---|
You returned deny | Blocks the operation with your code and message. This is a normal, successful invocation. |
| Your handler threw, or timed out | Blocks the operation |
| Your return value did not match the contract | Blocks the operation |
| No active binding covers this organization | Runs the default behavior, as if no function existed |
That last row is the safety net worth remembering: disabling a binding restores the default behavior instantly, and is the first thing to do if a hook starts causing failures.
If something's blocked
The Hooks tab is missing. The tab only appears on hook functions. Check the kind badge at the top of the editor.
The global toggle is disabled. Another active global binding already covers this hook point. Disable it, or target specific groups instead.
A group is greyed out in the target picker. It is already covered by another active binding on this hook point.
Signups started failing after you activated a binding. Disable the binding to restore the default behavior, then look at the execution log for the reason. A Contract violation rather than a deliberate denial means your return shape is wrong.
Your test run shows a contract violation. You returned a plain object instead of using OmniLab.Flow, denied without a code, or supplied a result field this hook point does not define.
The verification email never arrives. Check that the outbound call succeeded in the execution detail. An EGRESS_BLOCKED error means the provider host is not allow-listed; a NOT_FOUND error means the secret bundle is not bound or the key name is wrong.