Skip to content
Documentation · Webhooks

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.

  • eventFilters accepts exact event names ("jobRequest.completed"), prefix wildcards ("jobRequest.*" matches every name starting jobRequest.), or "*" for everything.
  • Registering a URL that already has a subscription returns a 409 carrying existingId. Edit that subscription with PATCH instead.
  • PATCH with eventFilters replaces the stored list. Changing url keeps the signing secret, which is how a validated test receiver is promoted to production; rotate afterwards for a fresh secret.
  • DELETE removes the subscription and invalidates its secret.
409 Conflict URL already registered; the document carries existingId.

URL requirements

Checked when a subscription is created, when its URL is changed, and again at delivery time.

  • https only, and the host must be a domain name. IP literals are rejected.
  • The host must be publicly resolvable and routable: no localhost, no .local or .internal hostnames, 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 detail starts Webhook 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

POST /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.

Request body
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.
Response fields
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
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"] }'

List subscriptions

GET /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.

Response fields
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
curl https://platform.getproperly.com/v1/webhooks \
  -H "x-api-key: $PROPERLY_API_KEY"

Update a subscription

PATCH /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.

Request body
Field Type Required Notes
url string Optional —
eventFilters string[] Optional Replaces the list.
enabled boolean Optional —
curl
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" }'

Delete a subscription

DELETE /v1/webhooks/{webhookId} 204 No Content x-api-key

Removes the subscription and invalidates its signing secret. Returns 204.

Request
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:

Request 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:

Body
{
  "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 maxRetries retries (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-id is the delivery id (whd_…), unique per (event, subscription) and stable across retries. Dedupe on it.
  • webhook-retry-count is 0 on the first attempt; webhook-max-retries echoes your subscription setting.
  • notifiedAt is 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 createdAt and dedupe by webhook-id.
  • For API-initiated events such as property.create, clientRequestId echoes the Idempotency-Key you 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:

Formula
webhook-signature = "v1," + base64( HMAC-SHA256( signingSecret, webhook-id + "." + webhook-timestamp + "." + rawBody ) )
  • The secret is the subscription's signingSecret exactly 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.

Node.js
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" };
}

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

POST /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.

Request body
Field Type Required Notes
eventName string Required Any event name.
curl
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" }'

Delivery log

GET /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.

Query parameters
Parameter Type Required Notes
cursor string Optional A delivery id from a previous page's nextCursor.
limit integer Optional 1–100, default 50.
curl
curl "https://platform.getproperly.com/v1/webhooks/sub_9WqAb2cD/deliveries?limit=50" \
  -H "x-api-key: $PROPERLY_API_KEY"

Rotate the signing secret

POST /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
curl -X POST https://platform.getproperly.com/v1/webhooks/sub_9WqAb2cD/rotateSecret \
  -H "x-api-key: $PROPERLY_API_KEY"

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.

Type to search the Platform API docs.