SaaS Pro Max
Sign inStart free
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#

ValueNotesStored as
Endpoint URLWhere the conversion is posted.Config
Person fieldshashed (default), plain or none. Controls how much of the person profile leaves SaaS Pro Max.Config
Signing secretUsed 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#

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

JavaScript
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);
}
Python
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.