Webhooks & events
Webhooks
The Properly Platform API POSTs every matching event to your URL as signed JSON. One subscription per URL, one signing secret per subscription.
Subscriptions
A subscription is a destination URL plus the events it should receive. Each subscription has its own signing secret, returned once when it is created and again only when rotated.
-
eventFiltersaccepts exact event names ("jobRequest.completed"), prefix wildcards ("jobRequest.*"matches every name startingjobRequest.), or"*"for everything. -
Registering a URL that already has a subscription returns a 409 carrying
existingId. Edit that subscription with PATCH instead. -
PATCHwitheventFiltersreplaces the stored list. Changingurlkeeps the signing secret, which is how a validated test receiver is promoted to production; rotate afterwards for a fresh secret. -
DELETEremoves the subscription and invalidates its secret.
URL requirements
Checked when a subscription is created, when its URL is changed, and again at delivery time.
-
httpsonly, and the host must be a domain name. IP literals are rejected. -
The host must be publicly resolvable and routable: no
localhost, no.localor.internalhostnames, no cloud-metadata hosts, and no DNS names that resolve to private or link-local address ranges. -
A violation at subscribe or URL-change time is a 400 problem whose
detailstartsWebhook url …, for example "Webhook url must use https", "Webhook url host must be a domain name, not an IP address", or "Webhook url host resolves to a non-public address". -
DNS can change between registration and delivery. A violation detected at delivery time marks
that delivery
failed_permanent.
Register a subscription
/v1/webhooks 201 Created
x-api-key
One subscription per destination URL. The response carries the signing secret exactly once; store it server-side. The URL must be https on a publicly resolvable domain name (no IP literals, no private or link-local hosts); violations → 400. A URL that already has a subscription → 409 carrying existingId.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | Required | https only, domain-name host, publicly resolvable and routable. |
eventFilters | string[] | Required | Min 1 item. Exact event names, prefix wildcards ("jobRequest.*"), or "*". |
maxRetries | integer | Optional | 0–10, default 5. |
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | Required | Subscription id (sub_…). |
url | string | Required | — |
eventFilters | string[] | Required | — |
enabled | boolean | Required | — |
maxRetries | integer | Required | — |
createdAt | date-time | Required | — |
signingSecret | string | Required | 32-byte base64url. Returned only here and from rotateSecret. |
- Deliveries time out after 10 seconds; a timeout counts as a failed attempt. After 10 consecutive permanently-failed deliveries the subscription is auto-disabled.
curl -X POST https://platform.getproperly.com/v1/webhooks \
-H "x-api-key: $PROPERLY_API_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://example.com/properly/webhooks",
"eventFilters": ["jobRequest.*", "comment.new"] }' {
"id": "sub_9WqAb2cD",
"url": "https://example.com/properly/webhooks",
"eventFilters": ["jobRequest.*", "comment.new"],
"enabled": true,
"maxRetries": 5,
"createdAt": "2026-09-16T10:02:11.000Z",
"signingSecret": "bXlfc2lnbmluZ19zZWNyZXRfMzJfYnl0ZXNfbG9uZyE"
} List subscriptions
/v1/webhooks 200 OK
x-api-key
Lists subscriptions in the collection envelope with secrets redacted. Unpaginated: nextCursor is always null and hasMore always false.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | Required | Subscription id (sub_…). |
url | string | Required | — |
eventFilters | string[] | Required | — |
enabled | boolean | Required | — |
maxRetries | integer | Required | — |
createdAt | date-time | Required | — |
disabledReason | string | Optional | Present only while the subscription is disabled with a recorded reason, e.g. auto_disabled_after_repeated_delivery_failures. Cleared when re-enabled. |
curl https://platform.getproperly.com/v1/webhooks \
-H "x-api-key: $PROPERLY_API_KEY" {
"data": [
{
"id": "sub_9WqAb2cD",
"url": "https://example.com/properly/webhooks",
"eventFilters": ["jobRequest.*", "comment.new"],
"enabled": true,
"maxRetries": 5,
"createdAt": "2026-09-16T10:02:11.000Z"
}
],
"nextCursor": null,
"hasMore": false
} Update a subscription
/v1/webhooks/{webhookId} 200 OK
x-api-key
eventFilters replaces the stored list (not a union). Changing url keeps the existing signing secret, which is how a validated test receiver is promoted to production; rotate afterwards for a fresh secret. A new url is re-checked against the https and public-domain rules (400 on violation) and must not belong to another subscription (409 with existingId). Re-enabling clears disabledReason and resets the failure counter.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | Optional | — |
eventFilters | string[] | Optional | Replaces the list. |
enabled | boolean | Optional | — |
curl -X PATCH https://platform.getproperly.com/v1/webhooks/sub_9WqAb2cD \
-H "x-api-key: $PROPERLY_API_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://example.com/properly/webhooks-prod" }' {
"id": "sub_9WqAb2cD",
"url": "https://example.com/properly/webhooks-prod",
"eventFilters": ["jobRequest.*", "comment.new"],
"enabled": true,
"maxRetries": 5,
"createdAt": "2026-09-16T10:02:11.000Z"
} Delete a subscription
/v1/webhooks/{webhookId} 204 No Content
x-api-key
Removes the subscription and invalidates its signing secret. Returns 204.
curl -X DELETE https://platform.getproperly.com/v1/webhooks/sub_9WqAb2cD \
-H "x-api-key: $PROPERLY_API_KEY" The delivery
Each matching event is POSTed to your URL as JSON with these headers:
content-type: application/json
webhook-id: whd_8FkQz31M
webhook-timestamp: 1789553705
webhook-signature: v1,uEHrSAEa+gZAdH5b7BnvPzkSk6XQqQVKx0aBwL9MaiE=
webhook-retry-count: 0
webhook-max-retries: 5 The body envelope is identical to the legacy API's payload bodies, so an existing legacy handler parses it unchanged:
{
"clientRequestId": "aB3xY9…",
"apiRequestId": "kQ7Lm2…",
"targetAccountId": "oU8C9LsuBw",
"createdAt": "2026-09-16T10:15:04.000Z",
"eventName": "jobRequest.completed",
"results": [ { "…": "event-specific, see the event catalog" } ],
"notifiedAt": "2026-09-16T10:15:05.412Z"
} - Respond 2xx (any 2xx) within 10 seconds. Deliveries time out
after 10 s and a timeout counts as a failed attempt. Non-2xx responses, timeouts, and network
failures are retried with exponential backoff starting at 30 s, up to
maxRetriesretries (default 5). After the final failure the delivery is marked permanently failed; it stays visible in the delivery log, and the event remains readable from the polling feed. Do heavy processing after acknowledging, not before. -
webhook-idis the delivery id (whd_…), unique per (event, subscription) and stable across retries. Dedupe on it. -
webhook-retry-countis 0 on the first attempt;webhook-max-retriesechoes your subscription setting. -
notifiedAtis stamped per attempt, so the raw body and therefore the signature differ between retries. Verify against the exact bytes received, never a re-serialization. - Ordering is not guaranteed. Retries and parallel deliveries can arrive out of
order. Order by the payload's
createdAtand dedupe bywebhook-id. -
For API-initiated events such as
property.create,clientRequestIdechoes theIdempotency-Keyyou sent on the originating request (a generated id otherwise), which correlates fan-out with your own writes. - Webhook event log and delivery history are retained for 90 days.
Auto-disable
After 10 consecutive permanently-failed deliveries the subscription is
disabled:
enabled: false with
disabledReason: "auto_disabled_after_repeated_delivery_failures", both visible in
GET /v1/webhooks.
- Any successful delivery resets the counter. Test deliveries do not count either way.
-
Re-enable with
PATCH /v1/webhooks/{webhookId}and body{ "enabled": true }, which also resets the counter. -
Then backfill what was missed from
GET /v1/events.
Signature scheme
Deliveries follow the Standard Webhooks convention:
webhook-signature = "v1," + base64( HMAC-SHA256( signingSecret, webhook-id + "." + webhook-timestamp + "." + rawBody ) ) -
The secret is the subscription's
signingSecretexactly as returned, an ASCII base64url string. Use it as the HMAC key directly; do not base64-decode it. -
The official Standard Webhooks libraries base64-decode the secret they are given, so pass them
base64(signingSecret)rather than the secret itself, or use the verifier below. -
During the 24-hour rotation overlap the header carries two space-delimited values (
v1,<new> v1,<old>). Accept the delivery if any value matches. -
Reject deliveries whose
webhook-timestamp(unix seconds) is more than 5 minutes from your clock. - Compare signatures with a constant-time comparison.
Verification code
Ready to paste. The Express wiring captures the raw body, which is where integrations usually go wrong.
const crypto = require("crypto");
const MAX_SKEW_SECONDS = 5 * 60;
const timingSafeEqual = (a, b) => {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
};
// Signed content is `${id}.${timestamp}.${rawBody}`; each header entry is `v1,<base64 HMAC>`.
// During secret rotation the header carries several space-delimited entries.
function expectedSignature(secret, msgId, timestamp, rawBody) {
const digest = crypto
.createHmac("sha256", secret)
.update(`${msgId}.${timestamp}.${rawBody}`, "utf8")
.digest("base64");
return `v1,${digest}`;
}
/**
* @param {object} headers lowercase-keyed request headers
* @param {string} rawBody the exact bytes received, BEFORE JSON.parse
* @param {string[]} secrets secrets to accept (pass old + new during rotation)
* @returns {{ok: boolean, reason?: string}}
*/
function verifyWebhook(headers, rawBody, secrets) {
const msgId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const header = headers["webhook-signature"];
if (!msgId || !timestamp || !header) {
return { ok: false, reason: "missing webhook-id / webhook-timestamp / webhook-signature header" };
}
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(skew) || skew > MAX_SKEW_SECONDS) {
return { ok: false, reason: `timestamp skew ${skew}s exceeds ${MAX_SKEW_SECONDS}s` };
}
const presented = header.split(" ").filter(Boolean);
const accepted = secrets.filter(Boolean).map(s => expectedSignature(s, msgId, timestamp, rawBody));
const matched = presented.some(candidate => accepted.some(valid => timingSafeEqual(candidate, valid)));
return matched ? { ok: true } : { ok: false, reason: "no presented signature matches the signing secret" };
} const express = require("express");
const app = express();
// Capture the RAW body — verify against these exact bytes, not a re-serialization.
app.post("/properly/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const result = verifyWebhook(req.headers, req.body.toString("utf8"), [process.env.PROPERLY_WEBHOOK_SECRET]);
if (!result.ok) return res.status(401).send(result.reason);
const event = JSON.parse(req.body);
// Dedupe on req.headers["webhook-id"], then handle event.eventName / event.results.
res.sendStatus(200);
}); import base64
import hashlib
import hmac
import time
MAX_SKEW_SECONDS = 5 * 60
def expected_signature(secret: str, msg_id: str, timestamp: str, raw_body: bytes) -> str:
# Signed content is "<webhook-id>.<webhook-timestamp>.<raw body>".
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
return "v1," + base64.b64encode(digest).decode("ascii")
def verify_webhook(headers: dict, raw_body: bytes, secrets: list) -> tuple:
"""headers: lowercase-keyed; raw_body: exact bytes received; secrets: old + new during rotation."""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
header = headers.get("webhook-signature")
if not msg_id or not timestamp or not header:
return False, "missing webhook-id / webhook-timestamp / webhook-signature header"
try:
skew = abs(int(time.time()) - int(timestamp))
except ValueError:
return False, "webhook-timestamp is not an integer"
if skew > MAX_SKEW_SECONDS:
return False, f"timestamp skew {skew}s exceeds {MAX_SKEW_SECONDS}s"
presented = [entry for entry in header.split(" ") if entry]
accepted = [expected_signature(s, msg_id, timestamp, raw_body) for s in secrets if s]
for candidate in presented:
for valid in accepted:
if hmac.compare_digest(candidate, valid):
return True, None
return False, "no presented signature matches the signing secret" Diagnostics
Test-fire a synthetic delivery, read the per-attempt delivery log, and rotate the signing secret without a support ticket.
Send a synthetic test delivery
/v1/webhooks/{webhookId}/test 202 Accepted
x-api-key
Signs a synthetic payload flagged test: true with your real secret and POSTs it to the configured URL asynchronously. It appears in the delivery log like any delivery and never appears in the GET /v1/events feed.
| Field | Type | Required | Notes |
|---|---|---|---|
eventName | string | Required | Any event name. |
curl -X POST https://platform.getproperly.com/v1/webhooks/sub_9WqAb2cD/test \
-H "x-api-key: $PROPERLY_API_KEY" -H "Content-Type: application/json" \
-d '{ "eventName": "property.create" }' { "deliveryId": "whd_8FkQz31M", "status": "pending" } Delivery log
/v1/webhooks/{webhookId}/deliveries 200 OK
x-api-key
Per-delivery history, newest first, in the collection envelope. Page by hasMore and nextCursor (the last row's id). eventId is null on synthetic test deliveries. History is bounded by the 90-day retention window. An unknown cursor → 400; restart without it.
| Parameter | Type | Required | Notes |
|---|---|---|---|
cursor | string | Optional | A delivery id from a previous page's nextCursor. |
limit | integer | Optional | 1–100, default 50. |
curl "https://platform.getproperly.com/v1/webhooks/sub_9WqAb2cD/deliveries?limit=50" \
-H "x-api-key: $PROPERLY_API_KEY" {
"data": [
{
"id": "whd_8FkQz31M",
"eventId": "evt_2mNpR4sT",
"status": "delivered",
"test": false,
"attemptCount": 2,
"firstAttemptAt": "2026-09-16T10:15:05.412Z",
"lastAttemptAt": "2026-09-16T10:15:36.108Z",
"nextRetryAt": null,
"createdAt": "2026-09-16T10:15:05.000Z",
"attempts": [
{ "at": "2026-09-16T10:15:05.412Z", "responseCode": 500, "responseSnippet": "Internal Server Error", "errorMessage": null, "durationMs": 340 },
{ "at": "2026-09-16T10:15:36.108Z", "responseCode": 200, "responseSnippet": "OK", "errorMessage": null, "durationMs": 88 }
]
}
],
"nextCursor": "whd_8FkQz31M",
"hasMore": true
} Rotate the signing secret
/v1/webhooks/{webhookId}/rotateSecret 200 OK
x-api-key
No body. Returns a new signing secret. For 24 hours deliveries carry both signatures (space-delimited); after the window the old secret stops signing.
curl -X POST https://platform.getproperly.com/v1/webhooks/sub_9WqAb2cD/rotateSecret \
-H "x-api-key: $PROPERLY_API_KEY" {
"id": "sub_9WqAb2cD",
"signingSecret": "bmV3X3NpZ25pbmdfc2VjcmV0XzMyX2J5dGVzX2xvbmc",
"previousSecretExpiresAt": "2026-09-17T10:02:11.000Z"
} Reading the delivery log
Each row is one delivery with status of pending, delivered, or failed_permanent, the attempt count, timestamps, and an
attempts[] array with each attempt's response code, response snippet, error
message, and duration. cursor is a delivery id from a previous page.
Synthetic test deliveries are flagged "test": true.