Webhooks
Moat tells your app when a connected person's page changes. Events go to the user_update_path you set when linking the person.
Who receives events#
Each Moat page keeps a list of connected apps. An app receives events for a person when its entry has both a callback_site_param and a user_update_path. Moat sends one request per app, even if the app appears more than once.
Event catalog#
| Event | Sent when | Format |
|---|---|---|
profile.updated | Any section of the page is saved | JSON, signed |
profile.deleted | The page is permanently deleted | JSON, signed |
profile.suspend.updated | An admin suspends or restores the account | JSON, signed |
profile.unlinked | The person removes your app from their page | JSON, signed |
profile.basic.updated | Name, title, company or location is saved | Form, token |
profile.tags.updated | Status signals are saved | Form, token |
New integrations should build on the four signed JSON events. The two form events exist for older integrations.
Verifying signatures#
JSON events carry X-Moat-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body using your webhook secret. Compute it over the exact bytes you received, before any JSON parsing, and compare in constant time.
$body = file_get_contents('php://input');
$given = $_SERVER['HTTP_X_MOAT_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $body, MOAT_XSITE_WEBHOOK_SECRET);
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);import crypto from "node:crypto";
function isFromMoat(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header || ""), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib
def is_from_moat(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")timestamp. After the signature passes, discard events older than a few minutes to limit replays.Form events carry a token field instead. Compare it with the token we issued for your app, in constant time.
Payloads#
Examples are trimmed to the fields your integration should rely on. Ignore fields you don't recognise, because new ones may be added.
profile.updated
{
"event": "profile.updated",
"item_key": "38e98f6431107e72ccf0e4ba",
"profile_url": "https://moat.page/profile/d/amara-okafor-38e98f6431107e72ccf0e4ba",
"timestamp": 1790000000,
"profile": {
"first_name": "Amara",
"last_name": "Okafor",
"headline": "Designs payment flows people finish",
"current_title": "Staff Product Designer",
"current_company": "Loomwell",
"profile_photo": "https://...",
"city": "Toronto",
"country": "CA",
"open_to_work": "open",
"profile_flags": "open_to_work,can_mentor",
"skills": [{ "value": "Figma", "proficiency": "expert" }],
"public_key": "c6424007df52eee0ee82483f7c464dc3"
}
}profile.deleted and profile.unlinked
{
"event": "profile.deleted",
"item_key": "38e98f6431107e72ccf0e4ba",
"timestamp": 1790000000
}On profile.deleted, delete what you hold about the person from Moat, as the partner terms require. On profile.unlinked, stop showing their Moat page and stop syncing it.
profile.suspend.updated
{
"event": "profile.suspend.updated",
"action": "suspend",
"item_key": "38e98f6431107e72ccf0e4ba",
"timestamp": 1790000000
}action is suspend or unsuspend. Hide the person's card while they're suspended.
Handling events well#
- Respond fast. Return a
2xxas soon as the signature checks out, then do the work in a queue. - Expect repeats. Use
item_key,eventandtimestamptogether to skip events you've already processed. - Don't rely on order. If two updates arrive close together, keep the one with the later
timestamp. - Refetch when in doubt. The payload is a snapshot. For the latest state, read the profile from the API.