Authentication
Every call to the United Pet Club API is made by a registered application presenting a short-lived access token. There are two ways to get one:
| Grant | The token acts for | Used by |
|---|---|---|
| Client credentials | your application itself | server-to-server access granted to your application at approval |
| Authorization code | one business that connected your application on the United Pet Club consent page | the Partner API — booking, appointments, clients and webhooks for that business |
Both start with your application’s client_id — and its client_secret, unless it is a
public client — and both end with a bearer token you send on each API request.
Your credentials
Section titled “Your credentials”When your application is registered you receive:
| Credential | Looks like | Treat as |
|---|---|---|
client_id | a public identifier for your app | not secret, but stable — it appears in logs on our side |
client_secret | a long random string | a password. Store it in a secret store, never in client-side code, repositories, or URLs. A public client has none. |
Secrets can be rotated; after a rotation the previous secret keeps working for a 24-hour grace period so you can roll your deployment without downtime.
Registering an application
Section titled “Registering an application”Register it yourself: sign in to United Pet Club, act as the profile that publishes the app (your
company’s profile), and open Account → Developer → Applications
(https://account.unitedpetclub.com/en-US/account/developer/apps). Register an App asks for the
app’s name, website, privacy policy, support e-mail and redirect URIs, and which scopes it will ask
businesses for: the booking scopes, and the scopes of the
business resources (animals, breeding, profiles, health, sales,
inventory, offers, invoices, content, messages, social). The client secret is shown once, when the app is registered and when you
rotate it.
An app businesses connect through the Partner API is usable at once. The consent page shows businesses that it has not been reviewed by United Pet Club until it has been; United Pet Club can suspend an app that misuses the API. On the app’s page you can edit its details and redirect URIs, change its scopes, rotate its secret, and see — and disconnect — the businesses that connected it.
Software that is installed on many sites, or that has no developer account behind it — a WordPress booking plugin, an AI-built website, an AI agent — can register itself instead, without signing in: see Self-registration.
Other access, such as client-credentials scopes for your own account’s data, is still granted with the United Pet Club partner team — see Support.
Client credentials: a token for your application
Section titled “Client credentials: a token for your application”Send a POST to the token endpoint with the standard form-encoded body:
POST /connect/token HTTP/1.1Host: auth.unitedpetclub.comContent-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRETWith curl:
curl -X POST https://auth.unitedpetclub.com/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET"A JSON body is accepted too, if that is easier from your stack:
{ "grant_type": "client_credentials", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET" }You can also authenticate with HTTP Basic (Authorization: Basic base64(client_id:client_secret))
instead of the two body fields, on every grant. You do not choose scopes in the request — the token
carries every scope your application has been granted.
Success response
Section titled “Success response”{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6ImRldi1hY2Nlc3MifQ...", "token_type": "Bearer", "expires_in": 3600, "scope": "animal:read animal:write profile:read"}| Field | Meaning |
|---|---|
access_token | The bearer token. Opaque to you — do not parse it or depend on its contents. |
token_type | Always Bearer. |
expires_in | Lifetime in seconds from now. The default is 3600 (one hour); your application may have been configured with a different lifetime. |
scope | Space-separated list of the scopes this token carries. |
No refresh token is issued for client credentials. When a token expires, request a new one.
A client-credentials token acts for no business, so the Partner API’s business routes refuse it
(403, 0010010000406). For those, connect the business first.
Connecting a business
Section titled “Connecting a business”The Partner API acts for a business that chose to connect your app. The flow is standard OAuth 2.0 authorization code with PKCE (RFC 6749, RFC 7636):
- Your app sends the business’s browser to the authorize URL.
- The business signs in to United Pet Club, sees your app, its publisher and each scope you ask for, and chooses Allow or Deny.
- The browser comes back to your redirect URI with a one-time
code. - Your server exchanges the code for an access token and a refresh token.
Connecting is free. To allow the connection, the business must keep a valid payment card on file (Dashboard → Finances → Billing); without one the consent page tells it so, links there and keeps Allow off. Its apps work only while it keeps the card. Denying needs nothing.
1. Send the business to the consent page
Section titled “1. Send the business to the consent page”https://auth.unitedpetclub.com/connect/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https%3A%2F%2Fapp.example.com%2Fupc%2Fcallback &scope=scheduling%3Aread%20appointment%3Aread%20appointment%3Awrite &state=af0ifjsldkj &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256| Parameter | Required | Meaning |
|---|---|---|
response_type | yes | Always code. |
client_id | yes | Your application’s client id. |
redirect_uri | yes | Must exactly equal one of the redirect URIs registered for your app — same scheme, host, port, path and query. |
scope | no | Space-separated scopes your app declared (booking scopes and business-resource scopes). Leave it out to ask for all of them. |
state | recommended | An unguessable value you tie to the user’s session and check when the browser comes back (CSRF protection). Returned unchanged. |
code_challenge | recommended; required for a public client and a self-registered app | BASE64URL(SHA256(code_verifier)) — 43 characters. |
code_challenge_method | with a challenge | S256 (the only method accepted). |
Redirect URIs are registered on your app’s page: absolute https URLs — or http on localhost /
127.0.0.1 / [::1] for local development — without a fragment, at most 800 characters, up to 10 per
app.
A request the consent page cannot accept — an unknown or suspended client, a redirect URI that is not
registered, another response_type, a malformed challenge, no challenge from a public or self-registered
app, or a scope your app did not declare — is
explained to the business on the page and never redirected back, so an attacker cannot use your
redirect URI as an open redirector. Test your authorize URL yourself before you ship it.
The business approves for the profile it is acting as (the page offers to switch to another of its business profiles). Approving the same app again updates the connection’s scopes — one connection per app and business.
2. Handle the callback
Section titled “2. Handle the callback”On Allow the browser is sent to your redirect URI with the code and your state:
https://app.example.com/upc/callback?code=upcac_...&state=af0ifjsldkjOn Deny:
https://app.example.com/upc/callback?error=access_denied&error_description=The+business+declined+to+authorize+the+app.&state=af0ifjsldkjCheck that state is the one you issued before you do anything else. The code is valid for 10
minutes and can be used once.
3. Exchange the code
Section titled “3. Exchange the code”curl -X POST https://auth.unitedpetclub.com/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=upcac_..." \ -d "redirect_uri=https://app.example.com/upc/callback" \ -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET"redirect_uri must be the one the code was issued for, and code_verifier is required when you sent a
code_challenge. A public client leaves out client_secret; its code always
carries a challenge, so it always sends code_verifier.
{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6ImRldi1hY2Nlc3MifQ...", "token_type": "Bearer", "expires_in": 3600, "scope": "scheduling:read appointment:read appointment:write", "refresh_token": "upcrt_..."}The access token acts for the business that approved, with the scopes it granted. Store the refresh token with the business in your system — encrypted, like a password.
4. Refresh
Section titled “4. Refresh”curl -X POST https://auth.unitedpetclub.com/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token" \ -d "refresh_token=upcrt_..." \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET"A public client leaves out client_secret here too.
The answer has the same shape: a new access token and a new refresh token. Refresh tokens rotate — the one you used stops working the moment the new one is issued — so always store the newest. A refresh token is valid for 90 days from when it was issued, and every refresh gives you a fresh 90 days, so an integration that refreshes at least that often never has to ask the business again.
If two of your workers refresh with the same token at once, one gets the new pair and the other gets
invalid_grant: single-flight refreshes per business, or re-read the stored token and retry once.
When the business disconnects
Section titled “When the business disconnects”A business can disconnect your app at any time (Dashboard → Settings → Connected Apps, or Account → Security →
Connected Apps). Within seconds
the API answers its access tokens with 401 (0010010000402), refreshing answers invalid_grant
(“The business revoked this app’s access.”), and your webhook subscriptions for it stop (each is disabled at
its next delivery). Tell the
user, stop calling, and offer to connect again.
A connected business with no valid payment card on file — none added, or one that expired, was declined
or needs re-verification — is answered 403 (0010010000407) until it adds one. Its connection is
kept, and your calls work again within about 10 seconds of the card being added.
Public clients and PKCE
Section titled “Public clients and PKCE”An app that cannot keep a secret — a website that runs in the browser, a desktop or phone app, an AI
agent on someone’s computer — is a public client. It has a client_id and no client_secret, and
PKCE proves that the app exchanging a code is the one that asked for it.
| Public client | Confidential client | |
|---|---|---|
| Registered with | token_endpoint_auth_method none — today only through self-registration | client_secret_basic or client_secret_post: every app registered under Account → Developer, and a self-registered app that asks for a secret |
| Secret | None. A client_secret sent anyway is refused (401 invalid_client). | client_secret in the body, or HTTP Basic |
PKCE (S256) | Required at the authorize URL and at the code exchange | Recommended; required for a self-registered app |
| Grants | authorization_code and refresh_token; client_credentials is refused (400 unauthorized_client) | All three for an app registered under Account → Developer; a self-registered app gets authorization_code and refresh_token only |
Make a new PKCE pair for every authorization request:
// Node.js 18+import crypto from "node:crypto";
const codeVerifier = crypto.randomBytes(32).toString("base64url"); // 43 charactersconst codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");Send code_challenge and code_challenge_method=S256 to the authorize URL, keep code_verifier with
the user’s session, and send it with the code. Refresh tokens work as for any app (90 days, rotated on
every use). A public client’s refresh token is all anyone needs to get new access tokens for that
business, so store it where only your app can read it.
Self-registration
Section titled “Self-registration”POST https://auth.unitedpetclub.com/connect/register (RFC 7591) registers an app without signing in
and without a key. It is meant for software nobody registers by hand: a WordPress booking plugin that
each site registers for itself, an AI-built website, an AI agent. The
WordPress plugins and AI agents guide walks through the whole flow.
The body is JSON client metadata:
| Field | Required | Rules |
|---|---|---|
redirect_uris | yes | 1 to 10 absolute URLs: https, or http on localhost, 127.0.0.1 or [::1]; no fragment, no user info, at most 800 characters each. The authorize URL later accepts only an exact match, port and query included. |
token_endpoint_auth_method | no | none = a public client with no secret. client_secret_basic (the default) or client_secret_post = a confidential client, which is issued a secret. |
client_name | no | The name businesses see on the consent page: at most 120 characters, and it may not name United Pet Club (“United Pet …”, “UPC”). Default: the host of the first redirect URI. |
client_uri, policy_uri, tos_uri | no | Your website, privacy policy and terms: absolute https URLs of at most 800 characters. |
contacts | no | E-mail addresses; the first one is kept as the app’s support contact. |
scope | no | The Partner API scopes the app may ask businesses for, space-separated. Default: all five (scheduling:read appointment:read appointment:write client:read webhook:manage). No other scope can be registered: a self-registered app cannot ask for the business-resource scopes. |
grant_types | no | authorization_code, optionally with refresh_token. |
response_types | no | code. |
software_id, software_version | no | Your software’s id and version, for example a plugin’s slug and release; kept to 200 and 60 characters. |
Other RFC 7591 members (logo_uri, jwks, software_statement, …) are ignored.
201 Created answers the registered client, with Cache-Control: no-store: client_id,
client_id_issued_at (Unix seconds), redirect_uris, token_endpoint_auth_method, grant_types
(authorization_code, refresh_token), response_types (code), client_name, scope, and whichever of
client_uri, policy_uri, tos_uri, software_id and software_version you sent. A confidential client
also gets client_secret — shown this once — and client_secret_expires_at 0 (it does not expire).
| HTTP | error | When |
|---|---|---|
400 | invalid_redirect_uri | redirect_uris is missing or empty, has more than 10 entries, or one of them breaks the rules above. |
400 | invalid_client_metadata | The body is not a JSON object, or another field breaks its rule; error_description says which. |
429 | temporarily_unavailable | 20 apps were registered from your address in the last 24 hours, or self-registration is paused for the day. Retry-After: 3600. |
503 | temporarily_unavailable | Registration is not available right now. Retry later. |
A self-registered app:
- can be used at once, but only for businesses that allow it on the consent page, which shows it as an unverified app registered by the host of its redirect URI (not by a publisher), and only while each business keeps a payment card on file;
- must send a PKCE challenge, whether or not it has a secret;
- has no publisher: nobody manages it under Account → Developer → Applications, so its secret cannot be
rotated and its redirect URIs and scopes cannot be changed. To change them, register a new client and
have the businesses connect it. Register once per site or install and keep the
client_id; never register per request; - can be suspended or revoked by United Pet Club, and disconnected by any business, like any app.
Discovery metadata
Section titled “Discovery metadata”OAuth clients that support discovery, AI agents and MCP clients among them, can find everything on this
page from two documents. Both are anonymous and may be cached for an hour. (The
MCP server is a separate resource, https://mcp.unitedpetclub.com, with its own
document and scopes; a token for one does not work on the other.)
The API (RFC 9728): GET https://api.unitedpetclub.com/.well-known/oauth-protected-resource
{ "resource": "https://api.unitedpetclub.com", "authorization_servers": ["https://auth.unitedpetclub.com"], "scopes_supported": ["scheduling:read", "appointment:read", "appointment:write", "client:read", "webhook:manage"], "bearer_methods_supported": ["header"]}Where it is set, resource_documentation names this documentation too. A 401 for a missing, invalid
or expired token points at the document:
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer resource_metadata="https://api.unitedpetclub.com/.well-known/oauth-protected-resource"Content-Type: application/problem+jsonThe authorization server (RFC 8414):
GET https://auth.unitedpetclub.com/.well-known/oauth-authorization-server
{ "issuer": "https://auth.unitedpetclub.com", "authorization_endpoint": "https://auth.unitedpetclub.com/connect/authorize", "token_endpoint": "https://auth.unitedpetclub.com/connect/token", "registration_endpoint": "https://auth.unitedpetclub.com/connect/register", "scopes_supported": ["scheduling:read", "appointment:read", "appointment:write", "client:read", "webhook:manage"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"], "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"], "code_challenge_methods_supported": ["S256"]}It also names the privacy policy and the terms (op_policy_uri, op_tos_uri) and, where set, this
documentation (service_documentation). scopes_supported lists the scopes a business can grant on the
consent page; client-credentials scopes are granted at approval and are not listed.
Error responses
Section titled “Error responses”Token endpoint errors use the standard OAuth 2.0 shape, with the RFC’s snake_case names and
Cache-Control: no-store (successful token responses carry it too):
{ "error": "invalid_client", "error_description": "Client authentication failed." }| HTTP | error | When | What to do |
|---|---|---|---|
400 | unsupported_grant_type | grant_type is not client_credentials, authorization_code or refresh_token | Fix the request. |
400 | invalid_request | A required parameter is missing: client_id and client_secret for client_credentials; client_id, code and redirect_uri for authorization_code; client_id and refresh_token for refresh_token | Fix the request. |
400 | unauthorized_client | A public client or a self-registered app asked for client_credentials | Use authorization_code with PKCE. |
400 | invalid_grant | The code expired, was already used, was issued for another redirect URI or failed the PKCE check, or a public client’s code was issued without a PKCE challenge; the refresh token expired, was already used or is unknown; or the business disconnected your app. error_description says which. | Do not retry the same value. Send the business through the consent page again (or re-read a refresh token another worker rotated). |
401 | invalid_client | Unknown client, a wrong or missing secret, a secret sent by a public client, or the application is not approved (error_description says “The application is not approved for API access.” in that case) | Check the credentials; contact support if the app was suspended. Do not retry in a loop. |
503 | temporarily_unavailable | Credential verification could not be reached | Retry with backoff. |
Using the token
Section titled “Using the token”Send it as a bearer token on every API request:
GET /api/scheduling/v1/appointments?from=2026-10-01T00:00:00Z&to=2026-10-31T00:00:00Z HTTP/1.1Host: api.unitedpetclub.comAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6ImRldi1hY2Nlc3MifQ...When the API rejects a token it answers with an application/problem+json body:
| HTTP | type (suffix of https://api.unitedpetclub.com/errors/…) | Meaning |
|---|---|---|
401 | unauthorized | No token, or an invalid or expired one. Get a new token (or refresh) and retry once. |
401 | token-revoked / authorization-revoked | The token, the business’s connection or your application was revoked. Stop retrying. |
403 | insufficient-scope | The token is valid but does not carry the scope the endpoint requires. |
403 | forbidden | Your application lacks a permission the endpoint needs. |
403 | delegated-token-required | A client-credentials token on a route that acts for a business. |
403 | payment-card-required | The business the token acts for has no valid payment card on file. |
429 | rate-limit-exceeded | Slow down — see Rate limits. |
The unauthorized answer also carries a WWW-Authenticate: Bearer resource_metadata="…" header that
points at the discovery metadata. See Errors for the full
problem-details contract.
Scopes
Section titled “Scopes”Each endpoint declares the scope it requires, by area and by read/write. The token’s scope field tells
you what you hold.
Partner API scopes are granted by the business on the consent page, from the set your app declared:
| Scope | Lets your app |
|---|---|
scheduling:read | See the business’s services, prices, opening times and available slots, and ask for estimates |
appointment:read | See the business’s appointments |
appointment:write | Book, reschedule and cancel appointments with the business |
client:read | See the business’s clients (names and contact details) and their pets |
webhook:manage | Subscribe to notices when the business’s appointments are booked, changed or cancelled |
Client-credentials scopes are granted to your application at approval:
| Scope family | Example scopes |
|---|---|
| Animals | animal:read, animal:write |
| Profiles | profile:read, profile:write |
| Commerce | order:read, order:write, inventory:read, inventory:write, marketplace:read, marketplace:write, billing:read, billing:write |
| Health & breeding | health:read, health:write, breeding:read, breeding:write |
| Content & engagement | content:read, content:write, publishing:read, engagement:read, channel:read, analytics:read |
Request only the scopes your integration needs — a business is more likely to approve a short list, and a smaller scope set limits the blast radius of a leaked secret.
Handling tokens well
Section titled “Handling tokens well”- Cache the access token and reuse it until shortly before
expires_inelapses. Requesting a token per API call is the most common cause of throttling on the token endpoint. - Refresh proactively — fetch a new token a minute or so before expiry rather than waiting for a
401. - On
401 unauthorized, get one new token and retry once. If the retry also fails, stop and alert: the application may have been suspended, its secret rotated, or the business may have disconnected it. A token can also stop working before itsexpires_inwhen United Pet Club rotates its token signing key: the same one new token and one retry recovers. - Single-flight token requests inside your process — per business for refreshes — so concurrent workers don’t race each other when a token expires.
- Never put a token in a URL or log it. Refresh tokens are long-lived credentials: encrypt them at rest.
- Server-side only. A client secret must never ship in a browser, mobile app or public repository; a mobile or single-page app talks to your server, which holds the secret and the tokens — or it is a public client, which has no secret and uses PKCE.
- Quickstart — token to first API response in two calls.
- Booking & Scheduling → Partner API — what a connected business’s token can do.
- WordPress plugins and AI agents — self-registration, PKCE and discovery, end to end.
- Rate limits — the
429contract and theX-RateLimit-*headers.