Configure webhooks

Register a callback in Studio, subscribe it to events, authenticate it and verify every delivery.

7 min read

Here, Lindenhall sends prize wins to its CRM as they happen, so nobody has to poll for them. You do the whole setup yourself in Enterprise Settings > Webhooks.

Before you begin

  • An admin account in Studio. The Webhooks tab only appears when Global is chosen in the organisation switcher.
  • A public HTTPS endpoint. Plain http, and hosts that resolve to a private address, are refused.
  • Somewhere safe to store the signing secret, which is shown once.
  • Separate endpoints for staging and production, so test traffic never reaches live systems.

Create the callback

In the organisation switcher, choose Global, and select General Settings. Then:

  1. Select the Webhooks tab.

    Enterprise Settings with the Webhooks tab highlighted

  2. Select New webhook.

    Webhooks tab listing three callbacks, with the New webhook button highlighted

  3. Enter a Name, here Lindenhall CRM.

    Create webhook window with the Name Lindenhall CRM, highlighted

    One callback per downstream system keeps failures isolated.

  4. Enter the Endpoint URL, here https://crm.lindenhall.example/omnilab/events.

    Create webhook window with the Endpoint URL highlighted

  5. Keep HTTP method on POST, unless the receiving system needs another.

    Create webhook window with HTTP method POST highlighted

  6. Select Create.

    Create webhook window with the Create button highlighted

  7. Copy the signing secret into your secret storage before you close the window.

    Signing secret window, the value blurred here, with the copy button highlighted

If you closed the window without copying it, rotate the signing secret to get a new one.

Subscribe the callback to events

A new callback receives nothing until it has at least one subscription. On the Webhooks tab:

  1. On the callback's row, select Subscriptions.

    Webhooks tab with Subscriptions on the CRM sync row highlighted

  2. Select Add subscription.

    CRM sync Subscriptions tab with the Add subscription button highlighted

  3. Enter a Name for this slice of traffic, here CRM sync — reward wins only.

    Subscription form with the name CRM sync — reward wins only, highlighted

  4. In Event types, select the events you want, here Won and Redeemed.

    Event types reading 2 selected, with the reward events picker open, highlighted

    The picker groups events by family, such as Touchpoint, Reward and Booking & Ticket, and you can search it. Leave it empty only if you want every event type: an empty list subscribes to all of them.

  5. (Optional) Enter a type the picker doesn't list in Custom event type, here submission.created.v1.

    Subscription form with submission.created.v1 in Custom event type, highlighted

  6. (Optional) Select Add custom.

    Subscription form with the Add custom button highlighted

    The type joins Event types with a dashed outline. Copy names from the event reference.

  7. (Optional) To narrow delivery to one organisation, select Add filter.

    Subscription form with the Add filter button highlighted

  8. (Optional) In the new filter row, replace the key tenant_id with group_id.

    Filter row with the key group_id, highlighted

  9. (Optional) Enter the organisation's ID as the value, here groups/6946c01f-8542-471c-9e2f-dac2661027c6.

    Filter row with an organisation ID as the value, highlighted

    Filters combine with AND and match exactly. How filters match lists what each key matches today.

  10. Select Create.

    Subscription form with the Create button highlighted

    The subscription appears in the list marked Active, with its event types and filters.

Use Pause to stop delivery while keeping the configuration, and Resume to switch it back on.

Receive your first event

Studio has no test send and no delivery log, so your receiver's own log is where deliveries show up. This Node.js receiver checks each request, skips repeats and answers straight away.

It needs Node.js 18 or later, and the verifier from Verify the signature saved as verify.js in the same folder. Set up the project:

Terminal
npm init -y
npm pkg set type=module
npm install express
server.js
import express from "express";
import { isValidOmniLabSignature } from "./verify.js";

const signingSecret = process.env.OMNILAB_SIGNING_SECRET;
if (!signingSecret) {
  throw new Error("Set OMNILAB_SIGNING_SECRET to the callback's signing secret");
}

// Use your own database in production: this set empties on every restart.
const handledIds = new Set();

const app = express();

// express.raw keeps the exact bytes the signature covers. Events reach 256 KB.
app.post(
  "/omnilab/events",
  express.raw({ type: "*/*", limit: "1mb" }),
  (req, res) => {
    const webhookId = req.get("webhook-id");
    const webhookTimestamp = req.get("webhook-timestamp");
    const header = req.get("webhook-signature");
    const rawBody = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";

    if (!webhookId || !webhookTimestamp || !header) {
      return res.status(400).send("Missing webhook headers");
    }
    const valid = isValidOmniLabSignature({
      header,
      webhookId,
      webhookTimestamp,
      rawBody,
      signingSecret,
    });
    if (!valid) {
      return res.status(400).send("Invalid signature");
    }

    // A retry carries the same webhook-id: acknowledge it, don't handle it twice.
    if (handledIds.has(webhookId)) {
      return res.sendStatus(204);
    }

    let event;
    try {
      event = JSON.parse(rawBody);
    } catch {
      return res.status(400).send("Body is not JSON");
    }
    handledIds.add(webhookId);

    // Answer before doing any work: the platform waits 30 seconds at most.
    res.sendStatus(204);

    // Hand the event to your own queue here. This example only logs it.
    console.log(`Received ${event.type} (${event.id})`, event.data);
  },
);

app.listen(3000, () => {
  console.log("Listening on http://localhost:3000/omnilab/events");
});

Start it with the signing secret you copied when you created the callback:

Terminal
OMNILAB_SIGNING_SECRET='<your signing secret>' node server.js

The callback's Endpoint URL must reach this server over public HTTPS. During development, put a tunnelling service in front of port 3000. Then set its address, ending in /omnilab/events, as the Endpoint URL on the General tab.

To send a first event, play a published campaign on your staging account and win its prize. The win sends reward.won.v1, and the receiver logs its type and ID.

A 4xx other than 429 is never retried, so a receiver that answers 400 loses that event. Return a 5xx when you want a delivery back. Every case is in Delivery guarantees.

Authenticate to your endpoint

If your receiver needs credentials of its own, give the callback an authentication mode. Each mode asks for these fields:

ModeGood fitWhat you enter
NoneThe endpoint relies on signature verification aloneNothing
BasicEndpoints that expect a username and passwordUsername, then Password under Secret credentials
Bearer tokenServices that accept a static API tokenOptional Custom header name, then Bearer token under Secret credentials. The header value is always Bearer <token>.
OAuth2 (client credentials)Platforms that issue short-lived tokensClient ID, Token endpoint, Scopes, Client auth method, optional Audience, then Client secret under Secret credentials

For OAuth2, Client auth method is required and takes one of two values. client_secret_basic sends the client ID and secret in an Authorization header, and client_secret_post sends them in the token request body. Any other value is refused when you save.

Save the mode before its secret: a secret is only accepted for the mode already saved. On the Webhooks tab:

  1. On the callback's row, select Edit, here on Analytics export.

    Webhooks tab with Edit on the Analytics export row highlighted

  2. Select the Authentication tab.

    Analytics export with the Authentication tab highlighted

  3. In Mode, choose the mode your endpoint expects, here OAuth2 (client credentials).

    Authentication tab with Mode set to OAuth2 (client credentials), highlighted

  4. Enter the Client ID, here lindenhall-analytics.

    Authentication tab with the Client ID lindenhall-analytics, highlighted

  5. Enter the Token endpoint, here https://idp.lindenhall.example/oauth2/token.

    Authentication tab with the Token endpoint, highlighted

  6. Enter the Scopes, here analytics.write.

    Authentication tab with the Scopes analytics.write, highlighted

  7. Enter the Client auth method, here client_secret_post.

    Authentication tab with the Client auth method client_secret_post, highlighted

  8. Select Save authentication.

    Authentication tab with the Save authentication button highlighted

  9. Under Secret credentials, enter the password, token or client secret.

    Secret credentials with the Client secret field highlighted

  10. Select Save secrets.

    Secret credentials with the Save secrets button highlighted

    Secrets are never shown again. Leaving a secret field blank later keeps the stored value.

Rotate the signing secret

After a rotation, every delivery is signed with the new secret and with the previous one, until the next rotation. A verifier still holding the old secret keeps passing, so you can switch without missing events.

Retiring a leaked secret takes two rotations

After one rotation, the leaked secret still signs every delivery as the previous secret. Rotate twice to stop it signing anything, then give your verifier the newest secret.

On the Webhooks tab:

  1. On the callback's row, select Edit.

    Webhooks tab with Edit on the CRM sync row highlighted

  2. Select the Secrets tab.

    CRM sync with the Secrets tab highlighted

  3. Select Rotate signing secret.

    Secrets tab with the Rotate signing secret button highlighted

  4. Confirm the rotation in the browser prompt.

    The prompt says verifiers using the old secret start failing straight away. They keep passing until the next rotation.

  5. Copy the secret from the New signing secret window into your secret storage.

    New signing secret window, the value blurred here, with the copy button highlighted

  6. Update your verifier with the new secret.

The Secrets tab also shows a Secret reference. It identifies the secret in support conversations, and can't sign or verify anything.

Reshape the request (optional)

Most receivers take the event as sent. When yours needs a different shape, use the Transformation tab:

  • HTTP method and Content type for the outgoing request.
  • URL template (optional): overrides the endpoint on the General tab. Useful for routing event types to different paths.
  • Body template (optional): leave it blank to send the raw event JSON.
  • Custom headers: sent on every delivery.

Templates reference event fields with {{event.<field>}}, and event.payload holds the event's data:

Body template
{
  "eventId": "{{event.id}}",
  "eventType": "{{event.type}}",
  "rewardId": "{{event.payload.reward_id}}"
}

A custom header named Authorization, Proxy-Authorization, webhook-id, webhook-timestamp or webhook-signature, in any case, is refused when you select Save transformation. Set credentials on the Authentication tab instead. Leave out User-Agent too: the platform sets its own. The signature covers the body your template produces.

Monitor delivery health

Each callback card carries a status badge:

  • Verified: the normal state. A callback is created Verified and stays so while it accepts deliveries. It doesn't mean traffic has been tested.
  • Failing: your endpoint answered 410 Gone. Expand the card, or open the General tab, to read the reason recorded.

A 410 Gone response stops delivery for good

410 Gone tells the platform the endpoint is retired. The callback stops receiving events, and nothing in Studio switches it back on. Return 410 only when you mean it. If it happens by accident, create a replacement callback and add its subscriptions again.

If something's blocked

  • The callback receives nothing: check that it has a subscription, that the subscription is Active, and that each event type is one the event reference lists as delivered. A misspelt custom event saves without complaint and never matches. An interaction_id filter matches no event today.
  • A subscription won't save: every filter needs a key and a value, and the key must be tenant_id, group_id or interaction_id. The key field also suggests contact_id, which is refused.
  • Signature checks started failing: the secret was rotated twice since your verifier last changed, or the body was parsed before verification. Use the newest secret and the raw body.
  • Deliveries stopped after an incident: events that failed for good, or ran out of retries, are not sent again. If the endpoint answered 410 Gone, the callback is now Failing and needs replacing.
  • The URL is refused when you save: the endpoint must start with https:// and resolve to a public address.

Next steps

On this page