Webhooks

Overview

Webhooks let your application receive event notifications from Moloco Commerce Media (MCM) without polling. MCM sends each notification as a signed HTTP POST request to a destination that you configure in MCM Portal.

This guide explains how to configure a destination, verify deliveries, and handle retries. For event-specific payload fields and examples, see Supported events (To be added soon).

📘

Terminology

A destination is a webhook configuration in MCM Portal. An event describes what happened. A delivery is MCM's attempt to send one event to a destination.

Prerequisites

Before starting, you need:

  • Administrator access to MCM Portal.
  • The Webhooks feature enabled for your platform. If Webhooks is not available in the administration area, contact your Moloco representative.
  • An HTTPS endpoint that MCM can reach and that can return a response within five seconds.
  • Access to the unmodified request-body bytes in your application. Signature verification fails if your framework parses or rewrites the JSON before you verify it.

STEP 1: Create a webhook destination

  1. In the administration area of MCM Portal, open Webhooks.

  2. Select New destination.

  3. Under Events to subscribe, select the events that your application will receive.

  4. Enter the Endpoint URL, Destination name, and, optionally, a Description. The endpoint URL must use HTTPS.

  5. Optionally, select Send test to check whether the URL is reachable. Because this check is unsigned, a receiver that requires a valid signature may reject it by design.

  6. Select Add.

  7. Copy the signing secret and store it in your secret manager. MCM displays the secret when it is issued; do not store it in source code or logs.

⚠️

The test sent before you add the destination is unsigned

This test only checks connectivity and is not shown in delivery history. Do not disable signature verification on a production endpoint to make this check pass. After you save the destination and its signing secret, send another test from the destination detail page to exercise the signed delivery flow.

STEP 2: Receive a delivery

MCM sends each delivery as an HTTP POST request with a JSON body and these headers:

  • Content-Type: application/json
  • User-Agent: Moloco-Webhook/1
  • x-moloco-webhook-id: A unique identifier assigned to each webhook delivery.
  • x-moloco-webhook-signature: The timestamp and one or more HMAC signatures used to verify the delivery. For the exact format, see Verify a signature below.

Every request body contains id, event, test, event_at, platform_id, and the event-specific data object. event_at is the time when MCM generated the notification. Use the corresponding API Reference page as the source of truth for the complete schema and a valid payload example.

STEP 3: Verify a signature

Your endpoint URL is publicly reachable, so anyone could send a POST request that looks like a webhook. Verifying the signature proves that a delivery genuinely came from MCM and was not altered in transit. Always verify before you act on a delivery's contents. The signature header has this format:

t=<unix_timestamp>,v1=<hex_encoded_signature>[,v1=<hex_encoded_signature>]

MCM computes each v1 value as follows:

signed_content = <t> + "." + <raw_request_body>
v1 = hex(HMAC-SHA256(signing_secret, signed_content))

To verify a delivery:

  1. Read t and every v1 value from x-moloco-webhook-signature.

  2. Reject a timestamp outside the replay window that your application permits. The example below uses five minutes; choose a window appropriate for your security requirements.

  3. Build the signed content from t, a period (.), and the exact request-body bytes that you received.

  4. Compute an HMAC-SHA256 digest with each active signing secret that you store.

  5. Compare the computed digest with every received v1 value using a constant-time comparison. Accept the delivery if any pair matches.

Use the raw request body

Do not parse and re-serialize the JSON before verification. Even an insignificant formatting change produces a different signature.

The examples below accept more than one local secret so that requests continue to verify while you rotate a signing key.

import { createHmac, timingSafeEqual } from 'node:crypto';

const SHA256_HEX = /^[0-9a-f]{64}$/i;

export function verifyMolocoWebhook({
  rawBody,
  signatureHeader,
  secrets,
  toleranceSeconds = 300,
  nowSeconds = Math.floor(Date.now() / 1000),
}) {
  if (
    !Buffer.isBuffer(rawBody) ||
    typeof signatureHeader !== 'string' ||
    !Array.isArray(secrets) ||
    !Number.isSafeInteger(toleranceSeconds) ||
    toleranceSeconds < 0 ||
    !Number.isSafeInteger(nowSeconds)
  ) {
    return false;
  }

  const timestamps = [];
  const signatureValues = [];
  for (const part of signatureHeader.split(',')) {
    const [key, ...rest] = part.trim().split('=');
    const value = rest.join('=');
    if (key === 't') timestamps.push(value);
    if (key === 'v1') signatureValues.push(value);
  }

  if (timestamps.length !== 1 || signatureValues.length === 0) {
    return false;
  }

  const timestampText = timestamps[0];
  const timestamp = Number(timestampText);
  if (
    !Number.isSafeInteger(timestamp) ||
    timestamp < 0 ||
    String(timestamp) !== timestampText ||
    Math.abs(nowSeconds - timestamp) > toleranceSeconds
  ) {
    return false;
  }

  const receivedSignatures = [];
  for (const value of signatureValues) {
    if (!SHA256_HEX.test(value)) return false;
    receivedSignatures.push(Buffer.from(value, 'hex'));
  }

  return secrets.some((secret) => {
    let key;
    if (Buffer.isBuffer(secret)) {
      key = secret;
    } else if (typeof secret === 'string') {
      key = Buffer.from(secret, 'utf8');
    } else {
      return false;
    }
    if (key.length === 0) return false;

    const expected = createHmac('sha256', key)
      .update(Buffer.from(`${timestampText}.`, 'utf8'))
      .update(rawBody)
      .digest();

    return receivedSignatures.some((received) =>
      timingSafeEqual(received, expected),
    );
  });
}

The Node.js example accepts secrets as strings or Buffer values. The Python and Go examples accept secrets as bytes. For a production Go call, pass your replay window and current time, for example 5*time.Minute and time.Now().

Signature test vector

Use this fixed input to test only your signature implementation. It is not a complete webhook event, and its timestamp is intentionally fixed.

Signing secret: whsec_example_do_not_use_in_production
Timestamp:      1709539200
Raw body:       {"id":"Abcdef0123456789","event":"campaign.budget.high_upsell","test":true}
Expected v1:    2a20592debc622850fc410b9a2b88c5b4f4f2d3d0c0706f93e616a1df3980418

When you use this vector, set Node.js nowSeconds or Python now_seconds to 1709539200. For Go, pass time.Unix(1709539200, 0) as now. This prevents the replay-window check from rejecting the fixed timestamp.

STEP 4: Acknowledge a delivery

After you verify the signature, durably queue or store the event and return any HTTP 2xx response within five seconds. MCM does not use the response body.

  • A timeout, network error, redirect, or any non-2xx response counts as a failed attempt.
  • MCM does not follow redirects. Configure the final HTTPS URL as the destination.
  • For a delivery with test: true, verify and acknowledge it normally, but do not trigger production side effects.

STEP 5: Handle retries, duplicates, and ordering

By default, MCM makes up to three total delivery attempts. After the first failed attempt, MCM waits approximately 10 seconds; after the second, approximately 20 seconds. A destination's configured retry policy can differ.

Automatic retries and manual resends keep the original request body and webhook ID (x-moloco-webhook-id and body id). The signature timestamp and signature are generated again for each attempt.

MCM can deliver the same webhook more than once, and events are not guaranteed to arrive in order. Do not assume exactly-once or ordered delivery. Make your receiver idempotent:

  1. Use x-moloco-webhook-id or the body id as the idempotency key.
  2. Atomically record the key while durably storing or queuing the event, for example by using a unique constraint in the same transaction.
  3. If the key already exists, do not queue the event or repeat its side effects; return 2xx.
  4. Make downstream side effects idempotent as well, or record their completion transactionally where possible.

STEP 6: Test your integration

MCM Portal provides two test flows:

  • Before saving a destination: Send test performs an unsigned connectivity check. It does not create a delivery-history record.
  • After saving a destination: Send test uses the normal signed delivery pipeline, sets test to true, and creates a record under Deliveries. The destination must be enabled, subscribed to the selected event, and have an active signing secret.

Before going live, confirm that your receiver:

  • Verifies the signed test with the saved secret.
  • Returns 2xx within five seconds.
  • Records the webhook ID without producing real business side effects.
  • Shows the test as successful in Deliveries.

Supported events

(To be added)

STEP 7: Rotate a signing secret

Rotate a secret without interrupting delivery:

  1. Add a signing secret in the destination detail page and copy it to your secret manager.
  2. Deploy your receiver with both the current and new secrets.
  3. Send a signed test and confirm that your receiver accepts it.
  4. Revoke the old secret.

During rotation, x-moloco-webhook-signature can contain more than one v1 value. Check every value against every active secret that you store.

Go-live checklist

  • The destination uses the final HTTPS URL and subscribes only to required events.
  • Signing secrets are stored outside source code and logs.
  • Signature verification uses the raw body and a constant-time comparison.
  • The receiver returns 2xx within five seconds after durable acceptance.
  • Duplicate and out-of-order events are safe to process.
  • test: true cannot cause production side effects.
  • Monitoring alerts on repeated failures and unexpected signature errors.

Did this page help you?