# Guide to Maximizer Webhooks: Features and Best Practices

Maximizer webhooks push changes in Maximizer CRM to your own systems as they happen. A contact is added to the address book, a lead is updated, an opportunity moves to a new sales stage, an interaction is logged: each can reach your service within moments, so you can sync another database, alert a sales channel, or trigger follow-up without polling the Maximizer API.

This guide covers how Maximizer's targets and subscriptions fit together, the array payload and its fields, how to authenticate requests with a header on the target, and the 2-second response limit that decides whether a webhook is delivered at all.

## What are Maximizer webhooks?

Maximizer webhooks are organisation-level: a subscription receives every change to its entity, not just one user's. You manage them through the **Webhooks API**, with a token that belongs to an Administrator.

There are two pieces. A **target** is your endpoint: a URL and, optionally, headers Maximizer sends with every request. A **subscription** connects a target to one entity and operation, such as `Opportunity` and `Update`, optionally with filters. When a matching change happens, Maximizer sends a `POST` to the target.

Maximizer doesn't sign webhooks. To secure a target, its documentation says you can put a secret in the URL's query string or configure the target to send specific headers. A header is the better choice: it stays out of logs, and it's what Hookdeck's Maximizer source checks.

## Maximizer webhook features

| Feature | Details |
| --- | --- |
| Scope | Organisation-level only |
| Management | Webhooks API at `https://api.maximizer.com/webhooks`, Administrator token (Octopus regular or PAT, or OAuth) |
| Targets | `POST /v1/targets`, at most 10 per Maximizer instance |
| Subscriptions | `POST /v1/subscriptions`: one `Entity` and `Op` per target, with optional filters and recipients |
| Method | `POST` |
| Body | JSON array of webhook objects |
| Authentication | Headers you set on the target. No signature |
| Response requirement | `200 OK` within 2 seconds |
| Retries | Two more attempts after a short delay, then the webhook is discarded |
| Versioning | `Data.Version` per entity, `major.minor` |

## Common events

Maximizer identifies a webhook by its `Entity` and `Data.Op` together. The documented combinations are:

| `Entity` | `Op` |
| --- | --- |
| `AbEntry` (address book entries) | `Create`, `Update`, `Delete` |
| `Lead` | `Create`, `Update`, `Delete` |
| `LeadDefault` | `Create`, `Update`, `Delete` |
| `Opportunity` | `Create`, `Update`, `Delete` |
| `InteractionLog` | `Create`, `Update`, `Delete` |
| `InteractionLogDefault` | `Create`, `Update`, `Delete` |
| `Export` | `Read` |

Maximizer's Postman collection is the reference for the current list, with sample payloads for every supported object and version.

Each request body is an array. Here is Maximizer's "Opportunity Stage Change" example, trimmed:

```json
[
  {
    "Id": "d81f9d6e-8afc-4fa4-9052-2af207494e5d",
    "Timestamp": "2024-09-20T00:01:29.5149836Z",
    "ProcessedTimestamp": "2024-09-20T00:02:45.8974002Z",
    "WorkspaceId": "108F314C-6787-4EE6-BE49-C0E0C651F039",
    "SourceId": "1a0dde7c-743f-4b55-a2bf-e7738393a8e5",
    "Recipients": [],
    "Options": null,
    "Entity": "Opportunity",
    "Data": {
      "Op": "Update",
      "Original": {
        "Key": "T3Bwb3J0dW5pdHkJMjQwOTE5MjUxNjQ1MjA0MTQwMDAxTwkw",
        "SalesStageSetupName": "Qualified",
        "Objective": "Test Opportunity",
        "Revenue": 12000.00
      },
      "New": {
        "Key": "T3Bwb3J0dW5pdHkJMjQwOTE5MjUxNjQ1MjA0MTQwMDAxTwkw",
        "SalesStageSetupName": "Presentation"
      },
      "Version": "1.1"
    }
  }
]

```

* `Id` is a unique webhook ID.
* `Timestamp` is when the change happened at the source; `ProcessedTimestamp` is when Maximizer forwarded it.
* `WorkspaceId` identifies the Maximizer database.
* `Recipients` carries any recipients set on the subscription. `Options` is reserved, and `SourceId` is for Maximizer's internal use.
* `Data.Original` holds the previous values and is null on create; `Data.New` holds the changed values and is null on delete.
* `Data.Version` is the entity's version: the major number changes on breaking changes, the minor on additive ones.

> **Inspect Maximizer webhooks as they arrive.** Register a [Hookdeck Inspect](https://inspect.hookdeck.com) URL as a target to see the array Maximizer sends for each subscription, with no account or setup required.

## Setting up Maximizer webhooks

Generate a long random value first, for example with `openssl rand -hex 32`, and get an Administrator token for your Maximizer CRM instance.

Create a target with a secret header:

```bash
curl --request POST \
  --url https://api.maximizer.com/webhooks/v1/targets \
  --header "Authorization: Bearer $MAXIMIZER_ADMIN_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{
    "Name": "Opportunity sync",
    "Endpoint": {
      "Url": "https://example.com/webhooks/maximizer",
      "Headers": [
        { "Key": "Authorization", "Value": "Bearer YOUR_LONG_RANDOM_VALUE" }
      ]
    }
  }'

```

Then create a subscription for each `Entity` and `Op` you need, using the target's ID:

```bash
curl --request POST \
  --url https://api.maximizer.com/webhooks/v1/subscriptions \
  --header "Authorization: Bearer $MAXIMIZER_ADMIN_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{ "Entity": "Opportunity", "Op": "Update", "TargetId": "TARGET_ID" }'

```

Subscriptions can add filters, which must all match, such as `{ "Key": { "$EQ": "key-value" } }` or `{ "$HASFIELD": "field-name" }`. The combination of target, entity and operation must be unique.

To route through Hookdeck, create a source with the **Maximizer** type and use its URL as the target's `Url`. Enter the header key from the target in **API Key Header** and the exact value, including any `Bearer ` prefix, in **API Key**. Hookdeck finds the header case-insensitively, compares the whole value, and accepts `POST`.

## Securing Maximizer webhooks

Look up the header you set on the target and compare its whole value with the stored secret, in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER_NAME = (process.env.MAXIMIZER_WEBHOOK_HEADER || 'authorization').toLowerCase();
const EXPECTED = process.env.MAXIMIZER_WEBHOOK_SECRET; // e.g. "Bearer 3f9c..."

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/maximizer', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME);
  if (!EXPECTED || typeof received !== 'string' || !safeEqual(received, EXPECTED)) {
    return res.sendStatus(401);
  }

  let webhooks;
  try {
    webhooks = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!Array.isArray(webhooks)) return res.sendStatus(400);

  // Queue each item (route on item.Entity + item.Data.Op) and answer within 2 seconds.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("MAXIMIZER_WEBHOOK_HEADER", "Authorization")
EXPECTED = os.environ.get("MAXIMIZER_WEBHOOK_SECRET", "")

def verify_maximizer_request(headers) -> bool:
    if not EXPECTED:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # most frameworks look headers up case-insensitively
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), EXPECTED.encode("utf-8"))

```

A static header authenticates the sender, not the body. It cannot detect a modified payload, and with no signature a captured request can be replayed and still pass. Use HTTPS, a long random value, and update the target's headers and your receiver together if it might have leaked.

> **Make Maximizer webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your target header, answers Maximizer with a `200` well inside 2 seconds, and queues every CRM change durably for your service.

## Maximizer webhook limitations and pain points

### Two seconds and three attempts

**The Problem:** A handler that writes to a database or calls another API before responding loses webhooks under load.

**Why It Happens:** Targets must return `200 OK` within 2 seconds. Otherwise Maximizer tries twice more after a short delay, and if all three attempts fail the webhook is discarded.

**Workarounds:**

* Check the header, store the array and respond, then process it in the background.
* Return exactly `200`; Maximizer documents `200 OK` as the success response.

**How Hookdeck Can Help:** Hookdeck answers Maximizer straight away and stores each request, then delivers it to your service with a [delivery timeout](/docs/destinations#set-a-delivery-timeout) and [retries](/docs/retries) you choose, so a slow or briefly unavailable service no longer means lost changes.

### Ten targets per instance

**The Problem:** Several integrations each want their own endpoint and run out of targets.

**Why It Happens:** Maximizer limits each CRM instance to 10 targets.

**Workarounds:**

* Share one target across subscriptions, and route inside your service on `Entity` and `Data.Op`.

**How Hookdeck Can Help:** One Hookdeck source can feed many [connections](/docs/connections), each with its own [filter](/docs/filters) and destination, so one target serves every consumer.

### Version changes

**The Problem:** A new major version of an entity changes fields your handler depends on.

**Why It Happens:** `Data.Version` tracks the entity's version in the source system, and a new major version signals breaking changes.

**Workarounds:**

* Read `Data.Version` and handle the major versions you've tested, as Maximizer recommends.
* Log and acknowledge versions you don't recognise.

**How Hookdeck Can Help:** Every request is stored in Hookdeck with its body, so you can see when a new version first arrived and replay those events once your handler supports it.

### A secret in the URL

**The Problem:** A query-string secret ends up in access logs and monitoring tools.

**Why It Happens:** Maximizer offers a query-string secret as one way to secure a target.

**Workarounds:**

* Put the secret in the target's `Headers` instead.

**How Hookdeck Can Help:** Hookdeck's Maximizer source checks the header name and value you configure. Hookdeck answers Maximizer with a `200` either way, but a request without the right header is recorded as failed verification and never reaches your service.

## Best practices

### Treat the body as a list

Every request is an array. Process each item and deduplicate on its `Id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Route on `Entity` and `Data.Op` together

Neither field identifies the event alone. Build a key such as `Opportunity.Update` in your own code.

### Use `Original` and `New`

For updates, compare `Data.Original` with `Data.New` to see exactly what changed, such as a sales stage move, instead of re-reading the record.

### Filter at the subscription

Subscription filters cut traffic before it reaches you. Fewer requests means fewer chances to miss the 2-second window.

### Acknowledge fast and process asynchronously

Two seconds is too short for anything but storing the request. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Maximizer sends organisation-level CRM changes as a JSON array to a target you register through the Webhooks API, authenticated only by headers you put on the target. Use a secret header rather than a URL secret, compare it in constant time, route on `Entity` plus `Data.Op`, and return `200` within 2 seconds, because after three failed attempts Maximizer discards the webhook.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, answers within Maximizer's window, and keeps every change until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Maximizer webhooks reliably in minutes.