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