Skip to content

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:

GrantThe token acts forUsed by
Client credentialsyour application itselfserver-to-server access granted to your application at approval
Authorization codeone business that connected your application on the United Pet Club consent pagethe 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.

When your application is registered you receive:

CredentialLooks likeTreat as
client_ida public identifier for your appnot secret, but stable — it appears in logs on our side
client_secreta long random stringa 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.

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.1
Host: auth.unitedpetclub.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET

With curl:

Terminal window
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.

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6ImRldi1hY2Nlc3MifQ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "animal:read animal:write profile:read"
}
FieldMeaning
access_tokenThe bearer token. Opaque to you — do not parse it or depend on its contents.
token_typeAlways Bearer.
expires_inLifetime in seconds from now. The default is 3600 (one hour); your application may have been configured with a different lifetime.
scopeSpace-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.

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

  1. Your app sends the business’s browser to the authorize URL.
  2. The business signs in to United Pet Club, sees your app, its publisher and each scope you ask for, and chooses Allow or Deny.
  3. The browser comes back to your redirect URI with a one-time code.
  4. 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.

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
ParameterRequiredMeaning
response_typeyesAlways code.
client_idyesYour application’s client id.
redirect_uriyesMust exactly equal one of the redirect URIs registered for your app — same scheme, host, port, path and query.
scopenoSpace-separated scopes your app declared (booking scopes and business-resource scopes). Leave it out to ask for all of them.
staterecommendedAn unguessable value you tie to the user’s session and check when the browser comes back (CSRF protection). Returned unchanged.
code_challengerecommended; required for a public client and a self-registered appBASE64URL(SHA256(code_verifier)) — 43 characters.
code_challenge_methodwith a challengeS256 (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.

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=af0ifjsldkj

On Deny:

https://app.example.com/upc/callback?error=access_denied&error_description=The+business+declined+to+authorize+the+app.&state=af0ifjsldkj

Check that state is the one you issued before you do anything else. The code is valid for 10 minutes and can be used once.

Terminal window
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.

Terminal window
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.

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.

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 clientConfidential client
Registered withtoken_endpoint_auth_method none — today only through self-registrationclient_secret_basic or client_secret_post: every app registered under Account → Developer, and a self-registered app that asks for a secret
SecretNone. 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 exchangeRecommended; required for a self-registered app
Grantsauthorization_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 characters
const 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.

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:

FieldRequiredRules
redirect_urisyes1 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_methodnonone = a public client with no secret. client_secret_basic (the default) or client_secret_post = a confidential client, which is issued a secret.
client_namenoThe 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_urinoYour website, privacy policy and terms: absolute https URLs of at most 800 characters.
contactsnoE-mail addresses; the first one is kept as the app’s support contact.
scopenoThe 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_typesnoauthorization_code, optionally with refresh_token.
response_typesnocode.
software_id, software_versionnoYour 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).

HTTPerrorWhen
400invalid_redirect_uriredirect_uris is missing or empty, has more than 10 entries, or one of them breaks the rules above.
400invalid_client_metadataThe body is not a JSON object, or another field breaks its rule; error_description says which.
429temporarily_unavailable20 apps were registered from your address in the last 24 hours, or self-registration is paused for the day. Retry-After: 3600.
503temporarily_unavailableRegistration 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.

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 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.unitedpetclub.com/.well-known/oauth-protected-resource"
Content-Type: application/problem+json

The 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.

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." }
HTTPerrorWhenWhat to do
400unsupported_grant_typegrant_type is not client_credentials, authorization_code or refresh_tokenFix the request.
400invalid_requestA 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_tokenFix the request.
400unauthorized_clientA public client or a self-registered app asked for client_credentialsUse authorization_code with PKCE.
400invalid_grantThe 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).
401invalid_clientUnknown 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.
503temporarily_unavailableCredential verification could not be reachedRetry with backoff.

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.1
Host: api.unitedpetclub.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6ImRldi1hY2Nlc3MifQ...

When the API rejects a token it answers with an application/problem+json body:

HTTPtype (suffix of https://api.unitedpetclub.com/errors/…)Meaning
401unauthorizedNo token, or an invalid or expired one. Get a new token (or refresh) and retry once.
401token-revoked / authorization-revokedThe token, the business’s connection or your application was revoked. Stop retrying.
403insufficient-scopeThe token is valid but does not carry the scope the endpoint requires.
403forbiddenYour application lacks a permission the endpoint needs.
403delegated-token-requiredA client-credentials token on a route that acts for a business.
403payment-card-requiredThe business the token acts for has no valid payment card on file.
429rate-limit-exceededSlow 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.

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:

ScopeLets your app
scheduling:readSee the business’s services, prices, opening times and available slots, and ask for estimates
appointment:readSee the business’s appointments
appointment:writeBook, reschedule and cancel appointments with the business
client:readSee the business’s clients (names and contact details) and their pets
webhook:manageSubscribe to notices when the business’s appointments are booked, changed or cancelled

Client-credentials scopes are granted to your application at approval:

Scope familyExample scopes
Animalsanimal:read, animal:write
Profilesprofile:read, profile:write
Commerceorder:read, order:write, inventory:read, inventory:write, marketplace:read, marketplace:write, billing:read, billing:write
Health & breedinghealth:read, health:write, breeding:read, breeding:write
Content & engagementcontent: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.

  • Cache the access token and reuse it until shortly before expires_in elapses. 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 its expires_in when 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.