Skip to content

Webhooks

When a business has connected your app through the Partner API, you can ask United Pet Club to POST a notice to your server whenever one of the business’s appointments changes — booked, confirmed, moved, cancelled, completed, missed or expired — instead of polling GET /appointments.

Webhooks are managed with the same delegated token as the rest of the Partner API and need the webhook:manage scope. Each subscription belongs to your app’s connection with one business: it receives that business’s events, and only while the business keeps your app connected and keeps a valid payment card on file.

POST /api/scheduling/v1/webhooks
Host: api.unitedpetclub.com
Authorization: Bearer <delegated access token>
Content-Type: application/json
{ "url": "https://hooks.example.com/upc", "eventTypes": ["appointment.confirmed", "appointment.cancelled"] }
HTTP/1.1 201 Created
Location: /api/scheduling/v1/webhooks/0192f2c4-…
{
"subscriptionId": "0192f2c4-…",
"url": "https://hooks.example.com/upc",
"eventTypes": ["appointment.confirmed", "appointment.cancelled"],
"statusCode": "ACTIVE",
"disabledReasonCode": null,
"consecutiveFailureCount": 0,
"lastSuccessAtUtc": null,
"lastFailureAtUtc": null,
"createdAtUtc": "2026-09-24T18:02:11Z",
"signingSecret": "whsec_…"
}
  • signingSecret is shown once — on create and on rotate. Store it like a password; you need it to verify every delivery.
  • eventTypes: leave it out (or send ["*"]) to receive every appointment event. An unknown type is a 400.
  • url must be an absolute https URL of at most 800 characters, without user info or a fragment, and must reach a public address: localhost and private, loopback, link-local or reserved IP addresses are refused. The address is checked again every time we connect, so a host that later resolves to a private address gets its subscription disabled (BLOCKED_DESTINATION).
  • An app may register up to 10 endpoints per business; the eleventh answers 409 Scheduling_SchedulingPartner_AD_WebhookLimit_RJ.
TypeSent when
appointment.requestedA booking request waits for the business to accept it.
appointment.confirmedAn appointment is confirmed.
appointment.rescheduledAn appointment moved to a new time.
appointment.cancelledAn appointment was cancelled — by the client, the business or an app.
appointment.completedThe visit is done.
appointment.no_showThe client did not come.
appointment.expiredA request or hold lapsed without being confirmed or paid.
webhook.testOnly when you ask for it — see Test an endpoint.

Events cover every appointment of the business, including the ones it booked itself or that came through its booking page. data.bookedByThisApp tells you which ones your app booked.

Each delivery is a POST with a JSON body:

{
"id": "0192f2d0-7c1e-7a55-9e0b-3d2c1f0a9b88",
"type": "appointment.rescheduled",
"createdAt": "2026-09-24T18:05:42.113Z",
"businessProfileId": "3c1d2e4f-…",
"authorizationId": "0192f0bb-…",
"data": {
"appointmentId": "0192f1aa-…",
"state": "CONFIRMED",
"startUtc": "2026-10-04T15:00:00Z",
"endUtc": "2026-10-04T16:30:00Z",
"previousStartUtc": "2026-10-03T14:00:00Z",
"bookedByThisApp": true
}
}
FieldMeaning
idThe event id. The same on every retry — deduplicate on it.
typeOne of the event types above.
createdAtWhen the change happened.
businessProfileIdThe business the event belongs to.
authorizationIdYour app’s connection with that business.
data.stateThe appointment’s state after the change.
data.startUtc / data.endUtcThe appointment’s time after the change.
data.previousStartUtcThe old start, on appointment.rescheduled only.

The notice is deliberately small. Read the appointment (GET /api/scheduling/v1/appointments/{appointmentId}) for everything else — it is always current, and it is the only place a client’s contact details appear (with client:read).

Every delivery carries these headers:

HeaderValue
UPC-Webhook-IdThis delivery’s id (a retry of the same event has the same id).
UPC-Webhook-Event-IdThe event id — the body’s id.
UPC-Webhook-Event-TypeThe body’s type.
UPC-Webhook-TimestampWhen this attempt was signed, in Unix seconds.
UPC-Webhook-Signaturev1= followed by the lowercase hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with your signing secret.

Compute the HMAC over the raw request body — before any JSON parsing — and compare it in constant time. Reject a timestamp more than five minutes away from your clock, so a captured delivery cannot be replayed later.

// Node.js (Express): app.post("/upc", express.raw({ type: "application/json" }), handler)
import crypto from "node:crypto";
function verifyUpcWebhook(req, secret) {
const timestamp = req.get("UPC-Webhook-Timestamp");
const signature = req.get("UPC-Webhook-Signature") ?? "";
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = "v1=" + crypto.createHmac("sha256", secret)
.update(`${timestamp}.${req.body.toString("utf8")}`).digest("hex");
const a = Buffer.from(signature), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
# Python
import hashlib, hmac, time
def verify_upc_webhook(headers, raw_body: bytes, secret: str) -> bool:
timestamp = headers.get("UPC-Webhook-Timestamp", "")
signature = headers.get("UPC-Webhook-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, "v1=" + mac)

Answer 401 to a delivery that fails verification — it counts as a failure, which is what you want if someone else is posting to your endpoint.

  • Answer with any 2xx within 10 seconds. Anything else — a 3xx (redirects are not followed), a 4xx, a 5xx, a timeout or a refused connection — is a failed attempt. The response body is ignored.
  • Put the event on your own queue and do the work afterwards; do not call the Partner API before you answer.
  • Answer 410 Gone to unsubscribe: the subscription is disabled (GONE) and nothing more is sent.

Delivery is at least once. A failed attempt is retried after 1 minute, then 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours — eight attempts over about 22 hours — after which the delivery is DEAD. Deliveries go out in short passes, and an endpoint that fails in a pass gets its other deliveries on the next pass, so:

  • The same event can arrive more than once — deduplicate on id (or UPC-Webhook-Event-Id).
  • Events can arrive out of order — compare createdAt, or re-read the appointment and trust what it says now.

A subscription’s statusCode becomes DISABLED, with a disabledReasonCode, when:

disabledReasonCodeWhyTo resume
FAILING50 attempts in a row failed.Fix your endpoint, then POST …/enable.
GONEYour endpoint answered 410.POST …/enable if that was a mistake.
BLOCKED_DESTINATIONThe host resolved to an address United Pet Club does not call.Point DNS at a public address, then POST …/enable.
AUTHORIZATION_REVOKEDThe business disconnected your app.The business connects your app again; then POST …/enable.
APP_INACTIVEYour app is no longer approved.Contact Support.

Deliveries still pending when an endpoint is disabled are cancelled. While the business has no valid payment card on file, its deliveries are cancelled too — notices about changes made in that time are not sent later — but the subscription stays ACTIVE and delivers again once a card is back. While the card cannot be checked, deliveries wait and are tried again.

CallWhat it does
GET /api/scheduling/v1/webhooksYour app’s subscriptions for this business (the secret is never returned).
GET /api/scheduling/v1/webhooks/{subscriptionId}One subscription.
DELETE /api/scheduling/v1/webhooks/{subscriptionId}Stops it for good (204); pending deliveries are cancelled.
POST /api/scheduling/v1/webhooks/{subscriptionId}/rotate-secretA new signingSecret, shown once.
POST /api/scheduling/v1/webhooks/{subscriptionId}/enableTurns a DISABLED subscription back on and resets its failure count.
POST /api/scheduling/v1/webhooks/{subscriptionId}/testSends a webhook.test event now.
GET /api/scheduling/v1/webhooks/{subscriptionId}/deliveries?limit=50The most recent deliveries (1–100), newest first.

Rotating a secret takes effect at once: the next delivery is signed with the new secret and the old one stops working. During a rotation, have your endpoint accept a signature made with either secret until your deployment has the new one everywhere.

POST …/test signs and sends a webhook.test event to the endpoint straight away and answers the delivery — statusCode SUCCEEDED or DEAD, lastResponseStatus and lastErrorText. A test is never retried. Its body has the usual envelope, with data.subscriptionId.

Each row of …/deliveries carries deliveryId, eventId, eventType, statusCode (PENDING, SUCCEEDED, DEAD or CANCELLED), attemptCount, nextAttemptAtUtc, lastAttemptAtUtc, lastResponseStatus, lastErrorText, createdAtUtc and deliveredAtUtc. Use it to see why an endpoint is failing before you enable it again.

The webhook routes answer the Partner API’s usual errors (see Booking → Errors on the partner routes), plus 404 for a subscription that is not yours, 409 Scheduling_SchedulingPartner_AD_WebhookLimit_RJ at the endpoint limit, and 503 Scheduling_SchedulingPartner_HD_Webhook_FL when webhook signing is not available — retry later.