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.
Subscribe
Section titled “Subscribe”POST /api/scheduling/v1/webhooksHost: api.unitedpetclub.comAuthorization: Bearer <delegated access token>Content-Type: application/json
{ "url": "https://hooks.example.com/upc", "eventTypes": ["appointment.confirmed", "appointment.cancelled"] }HTTP/1.1 201 CreatedLocation: /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_…"}signingSecretis 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 a400.urlmust be an absolutehttpsURL of at most 800 characters, without user info or a fragment, and must reach a public address:localhostand 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.
Event types
Section titled “Event types”| Type | Sent when |
|---|---|
appointment.requested | A booking request waits for the business to accept it. |
appointment.confirmed | An appointment is confirmed. |
appointment.rescheduled | An appointment moved to a new time. |
appointment.cancelled | An appointment was cancelled — by the client, the business or an app. |
appointment.completed | The visit is done. |
appointment.no_show | The client did not come. |
appointment.expired | A request or hold lapsed without being confirmed or paid. |
webhook.test | Only 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.
The delivery
Section titled “The delivery”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 }}| Field | Meaning |
|---|---|
id | The event id. The same on every retry — deduplicate on it. |
type | One of the event types above. |
createdAt | When the change happened. |
businessProfileId | The business the event belongs to. |
authorizationId | Your app’s connection with that business. |
data.state | The appointment’s state after the change. |
data.startUtc / data.endUtc | The appointment’s time after the change. |
data.previousStartUtc | The 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:
| Header | Value |
|---|---|
UPC-Webhook-Id | This delivery’s id (a retry of the same event has the same id). |
UPC-Webhook-Event-Id | The event id — the body’s id. |
UPC-Webhook-Event-Type | The body’s type. |
UPC-Webhook-Timestamp | When this attempt was signed, in Unix seconds. |
UPC-Webhook-Signature | v1= followed by the lowercase hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with your signing secret. |
Verify every delivery
Section titled “Verify every delivery”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);}# Pythonimport 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.
Respond fast, then work
Section titled “Respond fast, then work”- Answer with any
2xxwithin 10 seconds. Anything else — a3xx(redirects are not followed), a4xx, a5xx, 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 Goneto unsubscribe: the subscription is disabled (GONE) and nothing more is sent.
Retries, order and duplicates
Section titled “Retries, order and duplicates”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(orUPC-Webhook-Event-Id). - Events can arrive out of order — compare
createdAt, or re-read the appointment and trust what it says now.
When an endpoint is disabled
Section titled “When an endpoint is disabled”A subscription’s statusCode becomes DISABLED, with a disabledReasonCode, when:
disabledReasonCode | Why | To resume |
|---|---|---|
FAILING | 50 attempts in a row failed. | Fix your endpoint, then POST …/enable. |
GONE | Your endpoint answered 410. | POST …/enable if that was a mistake. |
BLOCKED_DESTINATION | The host resolved to an address United Pet Club does not call. | Point DNS at a public address, then POST …/enable. |
AUTHORIZATION_REVOKED | The business disconnected your app. | The business connects your app again; then POST …/enable. |
APP_INACTIVE | Your 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.
Manage subscriptions
Section titled “Manage subscriptions”| Call | What it does |
|---|---|
GET /api/scheduling/v1/webhooks | Your 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-secret | A new signingSecret, shown once. |
POST /api/scheduling/v1/webhooks/{subscriptionId}/enable | Turns a DISABLED subscription back on and resets its failure count. |
POST /api/scheduling/v1/webhooks/{subscriptionId}/test | Sends a webhook.test event now. |
GET /api/scheduling/v1/webhooks/{subscriptionId}/deliveries?limit=50 | The 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.
Test an endpoint
Section titled “Test an endpoint”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.
Delivery log
Section titled “Delivery log”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.
Errors
Section titled “Errors”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.