Hook functions

Write a function that runs inside an OmniLab operation and decides whether it continues, then bind it to a hook point.

7 min read

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. That power is why hook functions need more care than platform event functions.

When to use it

Choose a hook function when an operation's outcome must depend on your logic. Examples: blocking sign-ups from disposable email domains, normalising data before it's stored, or sending verification mail through your own provider. 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 pointRuns whenYour exportcontinue lets you
contact.create.preBefore a contact is stored, from sign-up, admin, import or APIonContactCreatePreRewrite the email, phone number and custom fields
contact.verifyA double opt-in sign-up needs its verification emailonContactVerifyReport the provider's message id, or that the address is already verified

What a hook guarantees

Whencontact.create.precontact.verify
No active binding covers the organisationThe contact is stored as submittedThe sign-up fails, and campaigns there can't be published while double opt-in is on
Your handler returns continueThe fields you returned are applied, then the contact is storedThe visitor waits for the link you sent. With already_verified: true, the contact is created verified instead
Your handler returns denyThe contact is rejected with your code, or DENIED if the code is emptyThe sign-up fails
Your handler throws, runs past a limit or breaks the contract, or the function was never builtThe contact is rejectedThe sign-up fails
RetriesNone: the operation is waiting for your answerNone
To roll back a bad handlerDisable the binding: contacts are stored as submitted againSave and build your last working source. Don't disable the binding while double opt-in is on

A deny is a normal, successful run. The fourth row is a failure, and hooks fail closed: OmniLab refuses the operation rather than let it through unchecked.

Build your answer with OmniLab.Flow.continue(result?) or OmniLab.Flow.deny(code, message). A plain object of the same shape works too: the hook contract lists what is accepted.

contact.create.pre

Your file exports onContactCreatePre.

user.ts — hello world
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, so it's the right first version. Bind it, watch the executions appear, and check sign-ups still work. Only then add logic that can say no.

Rejecting and rewriting

continue() with no argument changes nothing. Pass an object and OmniLab applies only the fields you return: a field you leave out stays exactly as submitted.

user.ts — normalise the email, block a domain
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", "Email domain is on the blocklist");
  }

  // Only `email` is returned, so the phone number and custom fields are stored as submitted.
  return OmniLab.Flow.continue({ email });
};

Only the code you deny with goes back to the caller. The message stays in the execution log, so write it for the person reading the log, not the visitor.

Normalising here pays off because the hook runs before the contact is stored and before any connected CRM is updated. The email you return is the one OmniLab stores and forwards. Lowercasing it stops Foo@example.com and foo@example.com becoming two profiles.

When your provider fails

If your handler calls a third party, decide what happens when that party is down. Hooks fail closed, so doing nothing means a vendor outage stops sign-ups for every organisation the binding covers.

Catch the failure and continue. A vendor being unreachable is not evidence that a shopper is a fraud:

user.ts — refuse bad addresses, but survive an outage
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", "Checker flagged the address as disposable");
    }
  } catch (error) {
    // The checker is unreachable. Let the sign-up through rather than block a
    // real customer over someone else's outage.
    console.warn("email check unavailable, allowing sign-up: " + String(error));
  }

  return OmniLab.Flow.continue({ email });
};

Deny only on a definite negative answer. Treat "I couldn't get an answer" as a reason to continue.

try/catch covers a failed HTTP request, not your handler running out of time. Keep the function's timeout above your provider's slowest responses: see Runtime limits.

contact.verify

Your file exports onContactVerify. OmniLab builds the double opt-in link, and your handler delivers it through your email provider.

Start by proving the wiring without a provider. The link arrives in input.verification_url, so log it:

user.ts — hello world, for setup only
import { OmniLab } from "./omnilab";
import type { ContactVerifyHandler } from "./omnilab";

export const onContactVerify: ContactVerifyHandler = (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 email went out when it didn't, so real contacts never receive a link. Use this version only while you set the function up.

Sending through your provider

OmniLab never retries a hook call. Each send, including a resend the contact asks for, carries a new input.context.idempotency_key. Pass it on if your provider accepts an idempotency key; otherwise you can ignore it.

The context also says where the sign-up came from. context.interaction_id and context.interaction_public_key identify the campaign, and context.group_id the organisation the binding was resolved for. One function can use them to vary its email per campaign, instead of you keeping a function per campaign.

Build the greeting with contactDisplayName rather than input.contact.firstname. A contact carries either firstname + lastname or a single fullname, depending on how the campaign's acquisition form collects names. A handler that reads only firstname sends "Hello ," to everyone from a Full Name campaign.

user.ts — send with Brevo
import { OmniLab, contactDisplayName } from "./omnilab";
import type { ContactVerifyHandler } from "./omnilab";

export const onContactVerify: ContactVerifyHandler = async (input) => {
  // Reads fullname, or firstname + lastname, whichever the contact carries.
  const name = contactDisplayName(input.contact);

  // Plugin config values are strings, and Brevo expects a number.
  const templateId = Number(OmniLab.Config.get("brevo_template_id"));
  if (!Number.isInteger(templateId) || templateId < 1) {
    throw new Error("Set brevo_template_id in Plugin config to a Brevo template id");
  }

  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 }],
      templateId,
      params: { NAME: name, VERIFICATION_URL: input.verification_url },
    }),
  });

  if (!response.ok) {
    return OmniLab.Flow.deny("dispatch_failed", "Brevo refused the send: HTTP " + response.status);
  }

  const sent = await response.json<{ messageId?: string }>();
  return OmniLab.Flow.continue({ provider_message_id: sent.messageId ?? "" });
};

This needs api.brevo.com in the allowed hosts, a brevo_template_id key in Plugin config, and a bound brevo bundle holding API_KEY. All three are set up in Configure runtime and secrets. Without a valid template id the handler throws, so the sign-up fails and the execution log says why.

Continuing means dispatched, not delivered. continue tells OmniLab your provider accepted the message, not that it arrived. Deny only when the dispatch itself failed, because a deny fails the sign-up.

Skipping the email when the contact is already verified

Your own records may already hold a confirmed opt-in for this address, from an earlier campaign say. Then return already_verified: true instead of sending: OmniLab creates the contact as verified on the strength of your proof. Set it only from a confirmed double opt-in, never from mere presence in an imported list: you are vouching for consent.

user.ts — skip the email when your records prove consent
import { OmniLab, contactDisplayName } from "./omnilab";
import type { ContactVerifyHandler } from "./omnilab";

// Asks your own system whether this address already completed double opt-in.
// Any failure counts as "not proven", so the email is sent as usual.
async function hasConfirmedOptIn(email: string): Promise<boolean> {
  try {
    const response = await OmniLab.Http.get(
      "https://crm.lindenhall.example/v1/optins?email=" + encodeURIComponent(email),
      { headers: { authorization: "Bearer " + OmniLab.Config.getSecret("crm.API_TOKEN") } },
    );
    if (!response.ok) {
      return false;
    }
    const record = await response.json<{ confirmed?: boolean }>();
    return record.confirmed === true;
  } catch (error) {
    console.warn("opt-in lookup failed, sending the email: " + String(error));
    return false;
  }
}

export const onContactVerify: ContactVerifyHandler = async (input) => {
  if (await hasConfirmedOptIn(input.contact.email)) {
    return OmniLab.Flow.continue({ already_verified: true });
  }

  const name = contactDisplayName(input.contact);
  const templateId = Number(OmniLab.Config.get("brevo_template_id"));
  if (!Number.isInteger(templateId) || templateId < 1) {
    throw new Error("Set brevo_template_id in Plugin config to a Brevo template id");
  }

  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 }],
      templateId,
      params: { NAME: name, VERIFICATION_URL: input.verification_url },
    }),
  });

  if (!response.ok) {
    return OmniLab.Flow.deny("dispatch_failed", "Brevo refused the send: HTTP " + response.status);
  }

  const sent = await response.json<{ messageId?: string }>();
  return OmniLab.Flow.continue({ provider_message_id: sent.messageId ?? "" });
};

On top of the Brevo setup, this needs crm.lindenhall.example in the allowed hosts and a crm bundle holding API_TOKEN.

Bind it to the hook point

Choose the scope first:

ScopeUse it whenSet it with
GlobalThe function should run for every organisationGlobal (all groups) turned on
Specific groupsOnly some organisations need itTarget groups

A binding is live as soon as you create it

There's no draft or log-only binding: covered operations start waiting for your function within seconds. Test it first, and bind one low-traffic organisation before you go global.

In the function's editor:

  1. Select the Hooks tab.

    Lindenhall double opt-in with the Hooks tab highlighted

  2. Select Add binding.

    Hooks tab with the Add binding button highlighted

    The Hook point is fixed by the function and shown read-only.

  3. Under Target groups, select Lindenhall Shopping Centre.

    Add binding form with Lindenhall Shopping Centre in Target groups, highlighted

  4. Select Create.

    Add binding form with the Create button highlighted

The binding starts Active. To cover every organisation, turn on Global (all groups) at step 3 instead.

The form enforces the scoping rules:

  • At most one active global binding per hook point. If one exists, the toggle is disabled and says why.
  • A group belongs to at most one active binding per hook point. Groups already covered are greyed out.
  • A group-specific binding wins over the global one for its groups. Use a global binding for the default and group bindings for exceptions.

There's no edit action: to change a binding's groups, delete it and create a replacement. For contact.verify, bind before an admin turns on double opt-in: see Double opt-in email verification.

Take a binding out of the live path

What happens next depends on the hook point: see the last row of What a hook guarantees. For contact.verify, disabling the binding makes every double opt-in sign-up fail.

In the function's editor:

  1. Select the Hooks tab.
  2. On the binding's row, select Disable.

Enable puts it back. Delete removes the binding and keeps the function. The function card shows unbound when a hook function has no active binding.

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 covers this hook point. Disable it, or target specific groups instead.
  • A group is greyed out in Target groups: another active binding on this hook point already covers it.
  • Sign-ups fail since you created a binding: roll back as the guarantees say for your hook point, then read the execution log.
  • Runs look clean but operations are refused, or a test shows Contract violation: your handler's answer breaks the contract. See Reading a hook contract violation.
  • The verification email never arrives: open the execution detail. EGRESS_BLOCKED means the provider's host isn't allow-listed. PERMISSION_DENIED or NOT_FOUND from getSecret is a bundle or key problem: see Configure runtime and secrets.

Next steps

On this page