Documentation menu

Webhook

A signed JSON POST to any endpoint you control. Use it to feed a CRM, a data warehouse, an internal queue, or an ad platform SaaS Pro Max does not have an adapter for.

Prerequisites

  • An HTTPS endpoint that accepts POST with a JSON body and answers quickly. Anything in the 2xx range counts as accepted.

What to collect

Value Notes Stored as
Endpoint URL Where the conversion is posted. Config
Person fields hashed (default), plain or none. Controls how much of the person profile leaves SaaS Pro Max. Config
Signing secret Used for the X-SPM-Signature HMAC. Leave the field empty at creation and SaaS Pro Max generates a 32-byte secret and shows it to you exactly once. Vault

The payload

POST https://example.com/spm
Content-Type: application/json
User-Agent: SaaSProMax-Destinations/1.0
X-SPM-Signature: t=1786998896,v1=9f86d081…

{
  "type": "destination.event",
  "sentAt": "2026-08-15T13:00:00.000Z",
  "event": {
    "id": "evt_…",
    "name": "Purchase",
    "sourceEvent": "purchase",
    "timestamp": "2026-08-15T12:34:56.000Z",
    "app": { "id": "app_…", "environment": "production" },
    "value": 49,
    "currency": "USD",
    "contents": [{ "id": "SKU-1", "quantity": 2, "price": 20, "name": null }],
    "properties": { "…": "…" },
    "context": {
      "url": "…", "path": "/checkout", "title": "…", "referrer": "…",
      "source": "search", "medium": "organic",
      "utm": { "source": null, "medium": null, "campaign": null, "term": null, "content": null },
      "clickIds": { "gclid": "…", "fbclid": "…" },
      "geo": { "country": "US", "region": "CA", "city": "San Francisco" },
      "device": { "browser": "Chrome", "os": "macOS", "type": "desktop" },
      "locale": "en-US",
      "sessionId": "…"
    }
  },
  "person": {
    "id": "person_…",
    "distinctId": "user_…",
    "name": "Jane Doe",
    "emailSha256": "…",
    "phoneSha256": "…"
  }
}

event.name is the mapping rule's destination event; event.sourceEvent is the SPM event name it came from. event.id is stable, so it is also your deduplication key if a retry delivers the same conversion twice.

With personFields: "plain" the person object carries email and phone instead of their hashes. With "none", person is null.

Verifying the signature

X-SPM-Signature is t=<unix seconds>,v1=<hex> where the hex is HMAC-SHA256 over the string "<t>.<raw request body>" using the destination's signing secret. Compute it over the raw body bytes, before any JSON parsing or re-serialisation.

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

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.split("=").map((s) => s.trim())),
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

The timestamp is inside the signed material, so reject anything older than a few minutes — that is what makes a captured request unusable later.

Verifying the setup

Press Send test event on the destination page. SaaS Pro Max posts the same payload shape built from a sample event, signed the same way, with event.name: "test". The delivery log records the exact request (with the signature redacted) and your endpoint's response.

Limitations

  • 10 second timeout. An endpoint that does real work should acknowledge first and process asynchronously.
  • Retries at 30 s, 2 min and 8 min for network errors, timeouts, 408, 429 and 5xx. Any other status is recorded as failed and not retried, so answer 2xx for anything you consider handled.
  • One event per request.
  • Retries mean your endpoint must be idempotent on event.id.