---
updatedAt: 2026-08-14T02:10:51.000Z
agentTools:
  projectIndex: https://mcm-docs.moloco.com/llms.txt
---

# 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.

![](https://files.readme.io/427631082866b091b32464cadc0b3e65d07ba5c094cb9253b77dce28d35c38ca-webhook_mcm_to_client_flow_en_white.png)

This guide explains how to configure a destination, verify deliveries, and handle retries. For event-specific payload fields and examples, see [Supported events](#supported-events).

<Callout icon="📘" theme="info">
  ### 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.
</Callout>

## 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**.

   ![](https://files.readme.io/a511b8dd86bef045a085409c6360231fb8813a69bef40ca3a9608c142a15d43d-image.png)
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. ![](https://files.readme.io/506050f94fd690c70c5a2d45ed0bb45c8a51b57f3f3a0c74407f34752ac36a21-image.png)
7. Select **Add**.
8. 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.

<Callout icon="⚠️" theme="warn">
  ### 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.
</Callout>

## 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:

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

MCM computes each `v1` value as follows:

```text
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.

   ![](https://files.readme.io/866b085a30421698b40c10e184c45597db7c5ffdeb9039769a835e2535b0c903-image.png)
5. Compare the computed digest with every received `v1` value using a constant-time comparison. Accept the delivery if any pair matches.

<Callout icon="❗" theme="error">
  ### Use the raw request body

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

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

<Tabs>
  <Tab title="Node.js">
    ```javascript
    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),
        );
      });
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python
    import hashlib
    import hmac
    import re
    import time
    from collections.abc import Iterable

    SHA256_HEX = re.compile(r"[0-9a-fA-F]{64}\Z")


    def verify_moloco_webhook(
        *,
        raw_body: bytes,
        signature_header: str | None,
        secrets: Iterable[bytes],
        tolerance_seconds: int = 300,
        now_seconds: int | None = None,
    ) -> bool:
        if not isinstance(raw_body, bytes) or not isinstance(signature_header, str):
            return False
        if (
            not isinstance(tolerance_seconds, int)
            or isinstance(tolerance_seconds, bool)
            or tolerance_seconds < 0
        ):
            return False

        timestamps = []
        signature_values = []
        for part in signature_header.split(","):
            key, separator, value = part.strip().partition("=")
            if not separator:
                continue
            if key == "t":
                timestamps.append(value)
            elif key == "v1":
                signature_values.append(value)

        if len(timestamps) != 1 or not signature_values:
            return False

        timestamp_text = timestamps[0]
        if not timestamp_text.isascii() or not timestamp_text.isdigit():
            return False

        timestamp = int(timestamp_text)
        if str(timestamp) != timestamp_text:
            return False

        if now_seconds is None:
            now_seconds = int(time.time())
        if (
            not isinstance(now_seconds, int)
            or isinstance(now_seconds, bool)
            or abs(now_seconds - timestamp) > tolerance_seconds
        ):
            return False

        received_signatures = []
        for value in signature_values:
            if SHA256_HEX.fullmatch(value) is None:
                return False
            received_signatures.append(bytes.fromhex(value))

        try:
            active_secrets = tuple(secrets)
        except TypeError:
            return False

        signed_content = f"{timestamp_text}.".encode("ascii") + raw_body
        for secret in active_secrets:
            if not isinstance(secret, bytes) or not secret:
                continue
            expected = hmac.new(secret, signed_content, hashlib.sha256).digest()
            if any(
                hmac.compare_digest(expected, received)
                for received in received_signatures
            ):
                return True

        return False
    ```
  </Tab>

  <Tab title="Go">
    ```go
    package webhooks

    import (
        "crypto/hmac"
        "crypto/sha256"
        "encoding/hex"
        "strconv"
        "strings"
        "time"
    )

    func VerifyMolocoWebhook(
        rawBody []byte,
        signatureHeader string,
        secrets [][]byte,
        tolerance time.Duration,
        now time.Time,
    ) bool {
        if signatureHeader == "" || tolerance < 0 || now.IsZero() {
            return false
        }

        var timestampText string
        var timestampSeen bool
        var signatureValues []string
        for _, rawPart := range strings.Split(signatureHeader, ",") {
            key, value, found := strings.Cut(strings.TrimSpace(rawPart), "=")
            if !found {
                continue
            }
            switch key {
            case "t":
                if timestampSeen {
                    return false
                }
                timestampSeen = true
                timestampText = value
            case "v1":
                signatureValues = append(signatureValues, value)
            }
        }

        if !timestampSeen || timestampText == "" || len(signatureValues) == 0 {
            return false
        }
        for i := 0; i < len(timestampText); i++ {
            if timestampText[i] < '0' || timestampText[i] > '9' {
                return false
            }
        }

        timestamp, err := strconv.ParseInt(timestampText, 10, 64)
        if err != nil || strconv.FormatInt(timestamp, 10) != timestampText {
            return false
        }

        age := now.Sub(time.Unix(timestamp, 0))
        if age > tolerance || age < -tolerance {
            return false
        }

        receivedSignatures := make([][]byte, 0, len(signatureValues))
        for _, value := range signatureValues {
            if len(value) != sha256.Size*2 {
                return false
            }
            received, err := hex.DecodeString(value)
            if err != nil || len(received) != sha256.Size {
                return false
            }
            receivedSignatures = append(receivedSignatures, received)
        }

        signedPrefix := []byte(timestampText + ".")
        for _, secret := range secrets {
            if len(secret) == 0 {
                continue
            }
            mac := hmac.New(sha256.New, secret)
            _, _ = mac.Write(signedPrefix)
            _, _ = mac.Write(rawBody)
            expected := mac.Sum(nil)

            for _, received := range receivedSignatures {
                if hmac.Equal(expected, received) {
                    return true
                }
            }
        }

        return false
    }
    ```
  </Tab>
</Tabs>

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.

```text
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

As of 2026 August, the following webhooks are supported:

* <Anchor target="_blank" href="https://mcm-docs.moloco.com/reference/receiveadaccountreengagechurned">Re-Engage Churned Ad Accounts</Anchor>
* <Anchor target="_blank" href="https://mcm-docs.moloco.com/reference/receivecampaignbudgethighupsell">Budget Increase Recommendations</Anchor>

## 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.