Hospet Connect APILink to Hospet Connect API
Connect your practice software to Hospet. Use the API to work with your practice’s information in Hospet, and webhooks to hear about changes the moment they happen.
The API uses JSON request and response bodies, standard HTTP status codes, and API keys for authentication. Here’s how an integration usually works:
- Get an API key An admin of your practice’s Hospet Connect account creates one on the API & Webhooks page, in the profile menu at the top right of Hospet Connect.
- Call the API Your software sends requests with the key to create and read your practice’s information. Start with Authentication.
- Listen for events Add a webhook endpoint, and Hospet tells your software when something happens, so it doesn’t have to keep checking.
Here’s what the API covers today. We’re adding more, and every addition is listed in the changelog.
- One-time assessments: send a pet owner a quality of life assessment and get the results back, PDF included.
- Clients: find out who has added your practice to their support team, and which pets they share with you.
- Login links: send someone from a patient record in your software straight to the pet in Hospet Connect, signed in.
- Species and breeds: look up the IDs used to describe a pet, if you’d rather not send names.
Examples and common scenarios shows how practice software usually puts these together, such as linking patients to Hospet pets and adding a “View in Hospet” button.
Using the Hospet Connect API and webhooks walks your practice through the setup.
https://my.hospet.app/api/connect/v1curl https://my.hospet.app/api/connect/v1/species \
-H "Authorization: Bearer $HOSPET_API_KEY"require "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/species")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
species = JSON.parse(response.body)
puts species["data"]const response = await fetch("https://my.hospet.app/api/connect/v1/species", {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
const species = await response.json();
console.log(species.data);import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/species",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
)
species = response.json()
print(species["data"])AuthenticationLink to Authentication
Send your API key in the Authorization header as a bearer token. Keys start with hsp_sk_. Every request must use HTTPS.
Admins of an approved Hospet Connect account create keys on the API & Webhooks page, in the profile menu at the top right of Hospet Connect. You can have up to 10 active keys, for example one for each system that connects to Hospet. A key is shown once, when you create it, so copy it straight into your password manager or secrets store.
Keep keys on your server. Anyone with a key can email your clients in your practice’s name, so never put one in a web page, a mobile app or a code repository. If a key might have been exposed, revoke it on the same page. It stops working immediately.
Who gets the results email
Assessments created with a key are sent on behalf of the person who created the key. When the client submits one, the results email goes to that person and to your practice’s primary contact. If that person is no longer on your account, it goes to the account owner instead.
A missing, unknown or revoked key returns 401. A valid key for a practice that isn’t approved yet returns 403. See Errors.
curl https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH \
-H "Authorization: Bearer $HOSPET_API_KEY"require "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
assessment = JSON.parse(response.body)
puts assessment["status"]const response = await fetch("https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH", {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
const assessment = await response.json();
console.log(assessment.status);import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
)
assessment = response.json()
print(assessment["status"]){
"error": {
"type": "authentication_error",
"code": "api_key_invalid",
"message": "Invalid API key provided: hsp_sk_Fq3…9xKc",
"param": null,
"doc_url": "https://support.hospet.app/developers/#error-code-api_key_invalid",
"request_id": "a8b7c6d5-e4f3-4a2b-9c1d-0e9f8a7b6c5d"
}
}ErrorsLink to Errors
Hospet uses HTTP status codes to say whether a request worked: 2xx means it did, 4xx means something about the request needs fixing, and 5xx means a problem on Hospet’s end.
Every error response has an error object. Handle errors in code using type and code, not message, which may be reworded. Validation errors use status 400 and list every problem in errors, so you can fix them all at once.
The error object
- typestring
The kind of error. One of the error types below.
- codestring
A short string you can handle in code. See the codes below.
- messagestring
A readable explanation. It may change, so don’t match on it.
The parameter the error is about, using dots for nested ones, such as
owner.email.A link to this error code’s entry in these docs.
- request_idstring
Also sent as the
X-Request-Idheader. Include it if you contact us about a request. - errorsarray of objects
For validation errors, every problem found, each with
param,codeandmessage. The top-level fields describe the first one.
HTTP status codes
| Status | Meaning |
|---|---|
200 OK | It worked. |
201 Created | The assessment was created. |
400 Bad Request | A parameter is missing or invalid, the JSON couldn’t be read, or an idempotency key was reused. |
401 Unauthorized | The API key is missing, not valid, or revoked. |
403 Forbidden | The key is valid, but your practice can’t use the API yet. |
404 Not Found | Nothing with that ID exists in your practice. |
429 Too Many Requests | You hit a rate limit. Wait for the number of seconds in Retry-After. |
500 Server Error | Something went wrong on Hospet’s end. These are rare, and safe to retry with the same idempotency key. |
Error types
| Type | Meaning |
|---|---|
authentication_error | The request didn’t include a valid API key. |
permission_error | The key is valid, but it isn’t allowed to do this. |
invalid_request_error | Something about the request is wrong: a missing or invalid parameter, or an ID that doesn’t exist. |
idempotency_error | An idempotency key was reused with different parameters. |
rate_limit_error | Too many requests in a short time. |
api_error | A problem on Hospet’s end. |
Error codes
- The request has no
api_key_missing401Authorization: Bearerheader. - The key doesn’t match any key in Hospet. Check for missing characters.
api_key_invalid401 - Someone revoked this key in Hospet Connect. Create a new one.
api_key_revoked401 - Your practice’s Hospet Connect account is waiting for approval, or is no longer active.
provider_not_approved403 - A required parameter is missing.
parameter_missing400paramnames it. - A parameter has a value Hospet can’t accept.
parameter_invalid400paramnames it andmessagesays why. - The request included a parameter the API doesn’t accept, such as a typo in a field name.
parameter_unknown400paramnames it. - The request body isn’t valid JSON. Check the
invalid_json400Content-Typeheader too. - Nothing with that ID exists in your practice.
resource_missing404paramnames the ID when it came from a parameter. - The PDF isn’t available until the client submits the assessment.
assessment_not_completed400 - This idempotency key was used before with different parameters. Use a new key.
idempotency_key_reused400 - Too many requests. Retry after the number of seconds in
rate_limited429Retry-After. - Your practice has created the most one-time assessments allowed in a day. Try again tomorrow, or contact us to raise the limit.
daily_create_limit429 - Something went wrong on Hospet’s end. Retry with the same idempotency key.
internal_error500
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "owner.email is invalid",
"param": "owner.email",
"doc_url": "https://support.hospet.app/developers/#error-code-parameter_invalid",
"request_id": "6f1d2c3b-8a9e-4f70-b1c2-d3e4f5a6b7c8",
"errors": [
{
"param": "owner.email",
"code": "parameter_invalid",
"message": "owner.email is invalid"
}
]
}
}Idempotent requestsLink to Idempotent requests
If a create request times out, you can’t tell whether Hospet received it. To retry safely, send an Idempotency-Key header with a value that’s unique to the assessment you’re creating, such as a UUID you store alongside it.
When Hospet has already seen that key from your practice, it doesn’t create a second assessment or send a second email. It returns the original assessment with status 200 and an Idempotent-Replayed: true header.
Using the same key with different parameters returns a 400idempotency_key_reused error, so a key can’t be applied to the wrong request by mistake.
Keys can be up to 255 characters and are kept permanently for your practice, so generate a new one for each new assessment. Only POST requests accept them. GET requests are already safe to repeat.
curl https://my.hospet.app/api/connect/v1/one_time_assessments \
-H "Authorization: Bearer $HOSPET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pet": {
"name": "Biscuit",
"species_name": "Dog",
"breed_name": "Beagle"
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442"
}
}'require "net/http"
require "json"
require "securerandom"
uri = URI("https://my.hospet.app/api/connect/v1/one_time_assessments")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = SecureRandom.uuid
request.body = JSON.generate({
owner: {
first_name: "Pat",
last_name: "Smith",
email: "pat@example.com",
},
pet: {
name: "Biscuit",
species_name: "Dog",
breed_name: "Beagle",
},
message: "Please complete this before Tuesday’s visit.",
metadata: {
patient_id: "PMS-10442",
},
})
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
assessment = JSON.parse(response.body)
puts assessment["url"]const response = await fetch("https://my.hospet.app/api/connect/v1/one_time_assessments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
owner: {
first_name: "Pat",
last_name: "Smith",
email: "pat@example.com",
},
pet: {
name: "Biscuit",
species_name: "Dog",
breed_name: "Beagle",
},
message: "Please complete this before Tuesday’s visit.",
metadata: {
patient_id: "PMS-10442",
},
}),
});
const assessment = await response.json();
console.log(assessment.url);import os
import uuid
import requests
response = requests.post(
"https://my.hospet.app/api/connect/v1/one_time_assessments",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com",
},
"pet": {
"name": "Biscuit",
"species_name": "Dog",
"breed_name": "Beagle",
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442",
},
},
)
assessment = response.json()
print(assessment["url"])HTTP/1.1 200 OK
Content-Type: application/json
Idempotent-Replayed: true{
"error": {
"type": "idempotency_error",
"code": "idempotency_key_reused",
"message": "Keys for idempotent requests can only be used with the same parameters they were first used with. Use a new Idempotency-Key for a new assessment.",
"param": null,
"doc_url": "https://support.hospet.app/developers/#error-code-idempotency_key_reused",
"request_id": "0c9b8a7f-6e5d-4c3b-a291-807f6e5d4c3b"
}
}MetadataLink to Metadata
Attach your own data to an assessment with metadata, such as your patient or appointment ID. It comes back every time you retrieve the assessment and in every webhook event, so you can match results to your records without storing Hospet’s IDs.
- Up to 50 keys.
- Keys can be up to 40 characters.
- Values are strings of up to 500 characters. Numbers and booleans are converted to strings.
- Nested objects and arrays aren’t allowed.
Metadata isn’t shown to your client. Even so, don’t put medical notes or other sensitive information in it. It’s meant for IDs and labels.
{
"owner": {
"first_name": "Pat",
"email": "pat@example.com"
},
"metadata": {
"patient_id": "PMS-10442",
"appointment_id": "A-88231"
}
}{
"id": "ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
"object": "one_time_assessment",
"metadata": {
"patient_id": "PMS-10442",
"appointment_id": "A-88231"
}
}PaginationLink to Pagination
List endpoints return a list object, with the items in data. Assessments come newest first.
Set the page size with limit, from 1 to 100 (the default is 10). To get the next page, pass the id of the last item as starting_after. When has_more is false, you’ve reached the end.
Because each page starts after a specific assessment, you won’t skip or repeat any when new ones are created while you page through.
{
"object": "list",
"url": "/api/connect/v1/one_time_assessments",
"has_more": true,
"data": [
"…"
]
}# Pass the last ID from the previous page as starting_after.
curl -G https://my.hospet.app/api/connect/v1/one_time_assessments \
-H "Authorization: Bearer $HOSPET_API_KEY" \
-d limit=100 \
-d status=completed \
-d starting_after=ota_8kQ2vX9rT4mN7pL1bC3dF6gHrequire "net/http"
require "json"
# Yields every completed assessment, fetching 100 at a time.
def each_completed_assessment
params = { limit: 100, status: "completed" }
loop do
uri = URI("https://my.hospet.app/api/connect/v1/one_time_assessments")
uri.query = URI.encode_www_form(params)
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
page = JSON.parse(response.body)
page["data"].each { |assessment| yield assessment }
break unless page["has_more"]
params[:starting_after] = page["data"].last["id"]
end
end
each_completed_assessment { |assessment| puts assessment["id"] }// Yields every completed assessment, fetching 100 at a time.
async function* completedAssessments() {
const params = new URLSearchParams({ limit: "100", status: "completed" });
while (true) {
const response = await fetch(`https://my.hospet.app/api/connect/v1/one_time_assessments?${params}`, {
headers: { Authorization: `Bearer ${process.env.HOSPET_API_KEY}` },
});
const page = await response.json();
yield* page.data;
if (!page.has_more) break;
params.set("starting_after", page.data.at(-1).id);
}
}
for await (const assessment of completedAssessments()) {
console.log(assessment.id);
}import os
import requests
def completed_assessments():
"""Yields every completed assessment, fetching 100 at a time."""
params = {"limit": 100, "status": "completed"}
while True:
response = requests.get(
"https://my.hospet.app/api/connect/v1/one_time_assessments",
headers={"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}"},
params=params,
)
page = response.json()
yield from page["data"]
if not page["has_more"]:
break
params["starting_after"] = page["data"][-1]["id"]
for assessment in completed_assessments():
print(assessment["id"])Rate limitsLink to Rate limits
Limits keep the API fast for every practice and protect your clients’ inboxes.
| Limit | Allowance |
|---|---|
| Requests per API key | 100 per minute |
| Requests per IP address | 300 per minute, counted before the key is checked |
| Assessments created per practice | 200 per day (UTC) |
Going over a limit returns 429 with a Retry-After header, giving the number of seconds to wait. Wait that long, then retry.
Per-minute limits reset at the start of each minute, and the daily limit at midnight UTC. Only assessments that are actually created count toward the daily limit: idempotent retries and rejected requests don’t.
If your practice needs to send more than 200 assessments a day, email mike@hospet.app.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30{
"error": {
"type": "rate_limit_error",
"code": "rate_limited",
"message": "Too many requests. Please slow down and try again shortly.",
"param": null,
"doc_url": "https://support.hospet.app/developers/#error-code-rate_limited",
"request_id": "3e4d5c6b-7a89-4f01-b2c3-d4e5f6a7b8c9"
}
}VersioningLink to Versioning
The version is part of the URL: /api/connect/v1. Webhook events record the version they were built with in api_version.
We add to v1 without notice: new endpoints, new optional parameters, new fields in responses and events, new error codes and new event types. Build your integration to ignore fields and event types it doesn’t recognize.
Anything that could break an integration, such as removing or renaming a field, only happens in a new version. We’ll announce it in the changelog ahead of time.
Planning an integrationLink to Planning an integration
These examples show how practice management software usually connects to Hospet. Each one lists the endpoints and events it uses. Build them in this order, since each one relies on the IDs stored by the one before:
- Assessments before visits: send an assessment for an appointment and save the results to the patient.
- Linking patients to pets: find out which Hospet client and pet match your client and patient.
- A “View in Hospet” button: open the pet in Hospet Connect from the patient record.
- Keeping in sync: notice when clients share pets, stop sharing them or remove your practice, and catch up after downtime.
What to store
Store Hospet’s IDs on your own records. They never change, so you can rely on them to find the right client and pet every time.
| Your record | Store | Where it comes from |
|---|---|---|
| Client | The Hospet client ID, starting with acct_ | client_id on a claimed assessment, or id on a client |
| Patient | The Hospet pet ID, starting with pet_ | pet_id on a claimed assessment, or id on one of a client’s pets |
| Assessment you sent | Its ID, starting with ota_ | The response when you create it |
Going the other way, put your own IDs in metadata when you create an assessment, such as your patient ID. Every response and event about that assessment then tells you which of your records it belongs to. Use IDs, not names or other personal details.
Webhooks or polling
Webhooks tell your software the moment something happens. If it can’t receive requests from the internet, for example because it runs on the practice’s own server, it can check the API on a schedule instead. Either way, check against the API regularly, because webhooks can be missed while your endpoint is down. See Keeping in sync.
Assessments before visitsLink to Assessments before visits
Ask the owner to complete a quality of life assessment before an appointment, so the results are ready when the pet arrives. For example, send one when an appointment is booked for a senior or chronically ill pet, or from a button on the patient record.
- Create the assessment with Create a one-time assessment. Include the owner’s name and email, whatever you know about the pet, and your patient ID in
metadata. Send the species and breed as the names in your records, withspecies_nameandbreed_name. Send anIdempotency-Keybased on the appointment, so a retry or a double click can’t email the owner twice. - Send it. By default, Hospet emails the owner a link from your practice. To send it in your own reminder email or text message instead, set
send_emailtofalseand send theurlfrom the response. Treat that link like a password: anyone who has it can open the owner’s results. - Save the results. When the owner submits it,
one_time_assessment.completedarrives with the category scores inresultsand the PDF indata.file. Save the PDF to the patient yourmetadatapoints to. See Files in events.
Hospet also emails the results to the owner and to your practice. An assessment expires if the owner hasn’t completed it within 30 days of the latest email. Your team can resend it from Hospet Connect, unless it was created with send_email: false. You can create up to 200 assessments a day.
After submitting, the owner can save the assessment to a free Hospet account. That shares the pet with your practice, and gives you the most reliable way to link the patient to the pet.
POST /api/connect/v1/one_time_assessments HTTP/1.1
Host: my.hospet.app
Authorization: Bearer hsp_sk_…
Idempotency-Key: appointment-APPT-48213-assessment
Content-Type: application/json
{
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pet": {
"name": "Biscuit",
"species_name": "Dog",
"breed_name": "Beagle"
},
"message": "Please complete this before Biscuit’s visit on Tuesday.",
"metadata": {
"patient_id": "PMS-10442",
"appointment_id": "APPT-48213"
}
}Linking patients to petsLink to Linking patients to pets
To show Hospet information on a patient record, or open the pet from it, you need to know which Hospet pet is which patient. Don’t match on pet names alone: an owner may have two cats called Max, and names get spelled differently. Use one of these instead.
From an assessment you sent
This link is exact, so use it whenever you can.
- Create the assessment with your patient ID in
metadata, as in Assessments before visits. - When the owner saves the completed assessment to their Hospet account, the pet is shared with your practice and
one_time_assessment.claimedarrives. It includes yourmetadata, plus the Hospetclient_idandpet_id. - Store
pet_idon the patient named inmetadata, andclient_idon that patient’s client.
The owner may save the assessment to a pet already in their Hospet account. Then pet_id is that pet, so check whether another of your patients is already linked to it.
Without webhooks, look for claims by retrieving completed assessments that don’t have a pet_id yet, for example once a day for a few weeks after they’re completed.
Clients who found you another way
Owners can also add your practice with your referral link or QR code. Then client.added arrives, and the client appears in List clients, with the owner’s email and the pets they share, but none of your IDs.
- Match the client to your records by
owner.email, and store theirid. - Suggest a patient for each pet by name and species, and have your team confirm it before you store the pet’s
id.
To skip matching by hand, send the owner an assessment. When they save it, they choose which of their Hospet pets it’s for, and you get an exact link.
Pet IDs stay the same if an owner stops sharing a pet and later shares it again, so keep them when a pet stops being shared.
{
"id": "evt_9Rk2mT7xQ4vL1pB8",
"object": "event",
"type": "one_time_assessment.claimed",
"api_version": "v1",
"created_at": "2026-10-21T09:20:31Z",
"livemode": true,
"data": {
"object": {
"id": "ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
"object": "one_time_assessment",
"status": "completed",
"client_id": "acct_5nR8tK2wQ7mV4xB9cL3dF6hJ",
"pet_id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
"metadata": {
"patient_id": "PMS-10442"
},
"client_account_created_at": "2026-10-21T09:20:31Z"
}
}
}A “View in Hospet” buttonLink to A “View in Hospet” button
Once a patient is linked to a pet, add a View in Hospet button to the patient record. It opens the pet in Hospet Connect, already signed in, so your team doesn’t need the practice’s Hospet login.
- Link the button to a page in your own software, not to Hospet. Protect that page with your software’s own sign-in.
- When the page is opened, create a login link with the patient’s stored
pet_id. - Redirect the browser to the link’s
url.
Create a new link on every click. Each one works once, for 60 seconds, so don’t put links on pages ahead of time, store them or email them. Make the request from your server: your API key must never reach the browser.
Links sign in as the person who created your API key, so everyone who clicks the button is signed in as them. If that person uses two-factor authentication, Hospet asks for their code before opening the pet, which may not suit a button the whole team uses.
If the owner has stopped sharing the pet, creating the link fails with parameter_invalid on pet_id. Show a message such as “This pet is no longer shared with your practice” rather than an error page.
# config/routes.rb: get "/patients/:id/hospet", to: "hospet_links#show"
# The "View in Hospet" button links here, not to Hospet. Your own sign-in
# protects it, and the API key never leaves your server.
class HospetLinksController < ApplicationController
before_action :authenticate_staff!
def show
patient = Patient.find(params[:id])
if patient.hospet_pet_id.blank?
return redirect_to patient, alert: "This patient isn't linked to Hospet yet."
end
# A new link on every click: each one works once, for 60 seconds.
uri = URI("https://my.hospet.app/api/connect/v1/login_links")
request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
request.body = { pet_id: patient.hospet_pet_id }.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
body = JSON.parse(response.body)
if response.code == "201"
redirect_to body["url"], allow_other_host: true
else
# parameter_invalid on pet_id: the client no longer shares this pet with you.
redirect_to patient, alert: "Hospet couldn't open this pet: #{body.dig("error", "message")}"
end
end
end// The "View in Hospet" button links here, not to Hospet. Your own sign-in
// (requireStaff) protects it, and the API key never leaves your server.
app.get("/patients/:id/hospet", requireStaff, async (req, res) => {
const patient = await findPatient(req.params.id);
if (!patient?.hospetPetId) {
return res.status(404).send("This patient isn't linked to Hospet yet.");
}
// A new link on every click: each one works once, for 60 seconds.
const response = await fetch("https://my.hospet.app/api/connect/v1/login_links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ pet_id: patient.hospetPetId }),
});
const body = await response.json();
if (response.status === 201) return res.redirect(body.url);
// parameter_invalid on pet_id: the client no longer shares this pet with you.
res.status(400).send(`Hospet couldn't open this pet: ${body.error.message}`);
});import os
import requests
from flask import abort, redirect
# The "View in Hospet" button links here, not to Hospet. Your own sign-in
# (staff_required) protects it, and the API key never leaves your server.
@app.get("/patients/<patient_id>/hospet")
@staff_required
def view_in_hospet(patient_id):
patient = find_patient(patient_id)
if not patient or not patient.hospet_pet_id:
abort(404, "This patient isn't linked to Hospet yet.")
# A new link on every click: each one works once, for 60 seconds.
response = requests.post(
"https://my.hospet.app/api/connect/v1/login_links",
headers={"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}"},
json={"pet_id": patient.hospet_pet_id},
timeout=10,
)
body = response.json()
if response.status_code == 201:
return redirect(body["url"])
# parameter_invalid on pet_id: the client no longer shares this pet with you.
abort(400, f"Hospet couldn't open this pet: {body['error']['message']}")Keeping in syncLink to Keeping in sync
Owners decide what they share with your practice, and can change it at any time. Keep your records up to date with webhooks, and check against the API regularly to catch anything you missed.
With webhooks
client.added: a new client. Link them by email, and suggest patients for their pets.client.updated: they shared a pet or stopped sharing one. Comparepetswith what you have. A linked pet that’s missing is no longer shared: keep its ID, but hide Hospet information and the View in Hospet button for it.client.removed: they removed your practice. Keep their IDs in case they add you again, and mark them as no longer connected.one_time_assessment.completedandone_time_assessment.claimed: save results and link patients, as in the examples above.
Events can arrive more than once and in any order, so skip event IDs you’ve already handled. Existing endpoints don’t receive event types Hospet adds later until you turn them on in the endpoint’s settings.
A daily check
Once a day, page through List clients and compare it with what you’ve stored. Clients you’ve linked who aren’t in the list have removed your practice. For the rest, compare their pets with the pets you’ve linked, and update names: renaming a pet doesn’t send an event.
The check also catches up after your endpoint was down. If deliveries keep failing for about 3 days, Hospet turns the endpoint off and emails your practice. Once it’s fixed, turn it back on from its page, run the check, and list assessments completed since the outage.
Without webhooks
If your software can’t receive webhooks, check the API on a schedule instead:
- Every few minutes, list assessments with
status=completedand handle any you haven’t seen. The list is newest first by when each assessment was created, so page back until you pass the oldest one you’re still waiting on. - Look for claims on completed assessments, as in Linking patients to pets.
- Run the daily check for clients.
Stay within the rate limits of 100 requests a minute for each API key.
# Queued by your webhook receiver for each event with a valid signature (see
# Receiving events). Find records by ID, never by name, and skip events you've
# already handled: retries and duplicates reuse the event ID.
class HospetEventJob < ApplicationJob
def perform(payload)
event = JSON.parse(payload)
return if HospetEvent.exists?(event_id: event["id"])
object = event.dig("data", "object")
case event["type"]
when "one_time_assessment.completed"
# Save the PDF in data.file to the patient (see Files in events).
save_results(object, event.dig("data", "file"))
when "one_time_assessment.claimed"
# Your patient ID from metadata, now linked to the Hospet pet and client.
patient = Patient.find_by(id: object.dig("metadata", "patient_id"))
patient&.update!(hospet_pet_id: object["pet_id"])
patient&.client&.update!(hospet_client_id: object["client_id"])
when "client.added", "client.updated"
client = Client.find_by(hospet_client_id: object["id"]) ||
Client.find_by(email: object.dig("owner", "email"))
# Store the client's ID, mark which linked pets are shared, and suggest
# matches for new pets for your team to confirm.
sync_hospet_client(client, object) if client
when "client.removed"
# Keep the IDs, in case they add your practice again.
Client.where(hospet_client_id: object["id"]).update_all(hospet_connected: false)
end
HospetEvent.create!(event_id: event["id"])
end
end// Run by your webhook receiver for each event with a valid signature (see
// Receiving events), after it has responded. Find records by ID, never by
// name, and skip events you've already handled: retries and duplicates reuse
// the event ID.
export async function handleHospetEvent(event) {
if (await db.hospetEvents.exists(event.id)) return;
const object = event.data.object;
switch (event.type) {
case "one_time_assessment.completed":
// Save the PDF in data.file to the patient (see Files in events).
await saveResults(object, event.data.file);
break;
case "one_time_assessment.claimed": {
// Your patient ID from metadata, now linked to the Hospet pet and client.
const patient = await db.patients.find(object.metadata.patient_id);
if (patient) {
await db.patients.update(patient.id, { hospetPetId: object.pet_id });
await db.clients.update(patient.clientId, { hospetClientId: object.client_id });
}
break;
}
case "client.added":
case "client.updated": {
const client =
(await db.clients.findByHospetId(object.id)) ?? (await db.clients.findByEmail(object.owner.email));
// Store the client's ID, mark which linked pets are shared, and suggest
// matches for new pets for your team to confirm.
if (client) await syncHospetClient(client, object);
break;
}
case "client.removed":
// Keep the IDs, in case they add your practice again.
await db.clients.markHospetDisconnected(object.id);
break;
}
await db.hospetEvents.insert(event.id);
}# Run by your webhook receiver for each event with a valid signature (see
# Receiving events), after it has responded. Find records by ID, never by
# name, and skip events you've already handled: retries and duplicates reuse
# the event ID.
def handle_hospet_event(event):
if db.hospet_events.exists(event["id"]):
return
obj = event["data"]["object"]
event_type = event["type"]
if event_type == "one_time_assessment.completed":
# Save the PDF in data.file to the patient (see Files in events).
save_results(obj, event["data"].get("file"))
elif event_type == "one_time_assessment.claimed":
# Your patient ID from metadata, now linked to the Hospet pet and client.
patient = db.patients.find(obj["metadata"].get("patient_id"))
if patient:
db.patients.update(patient.id, hospet_pet_id=obj["pet_id"])
db.clients.update(patient.client_id, hospet_client_id=obj["client_id"])
elif event_type in ("client.added", "client.updated"):
client = db.clients.find_by_hospet_id(obj["id"]) or db.clients.find_by_email(obj["owner"]["email"])
# Store the client's ID, mark which linked pets are shared, and suggest
# matches for new pets for your team to confirm.
if client:
sync_hospet_client(client, obj)
elif event_type == "client.removed":
# Keep the IDs, in case they add your practice again.
db.clients.mark_hospet_disconnected(obj["id"])
db.hospet_events.insert(event["id"])One-time assessmentsLink to One-time assessments
A one-time assessment is Hospet’s full quality of life assessment, sent to a pet owner by email. They complete it in their browser, without a Hospet account, in about 10 minutes. When they submit it, the results are emailed to them and to your practice, and sent to your webhook endpoints.
An assessment starts open and becomes completed when the client submits it. If they don’t submit it within 30 days of the latest email, it becomes expired. Your team can resend an open or expired assessment from the One-Time Assessments page in Hospet Connect, up to 3 emails in total, which restarts the 30 days. Assessments created with send_email: false can’t be resent from Hospet.
After submitting, the client can save the assessment to a free Hospet account. When they do, the pet is shared with your practice and client_account_created_at is set. The status stays completed.
Saving also sets client_id and pet_id to the Hospet client and pet it became, and sends the one_time_assessment.claimed webhook. Put your own patient ID in metadata when you create the assessment, and you can link the patient to the Hospet pet without matching by name. Use pet_id to create login links.
The API sees every assessment your practice sends, including ones sent from the dashboard. source tells you which.
The one-time assessment objectLink to The one-time assessment object
A quality of life assessment your practice sent to a pet owner. The owner completes it in their browser, without a Hospet account.
Attributes
- idstring
Unique identifier for the assessment. Starts with
ota_. - objectstring
Always
one_time_assessment. - statusenum
Where the assessment is in its lifecycle.
openWaiting for the client. The link works.expiredThe client didn’t submit it within 30 days of the latest email, so the link no longer works.completedThe client submitted it andresultsis filled in. Stayscompletedif the client later saves it to a Hospet account.
- sourceenum
Where the assessment was created.
apiThrough this API.dashboardFrom the One-Time Assessments page in Hospet Connect.
- urlstring
The link the client opens to complete the assessment. Anyone with the link can open it and, once it’s submitted, see the results, so share it only with the client.
The client the assessment was saved to, starting with
acct_.nulluntil the client saves it to a Hospet account, which setsclient_account_created_atand sends the claimed webhook. It stays set if they later remove your practice.- ownerobject
The pet owner the assessment was sent to.
Show child attributesHide child attributes
- first_namestring
The owner’s first name.
The owner’s last name.
- emailstring
Where the invite and the results are emailed. Stored in lowercase.
The Hospet pet the assessment was saved to, starting with
pet_.nulluntil the client saves it to a Hospet account. Store it with your patient record, matched usingmetadata, and use it to create login links. It stays set if they later stop sharing the pet, and isnullagain if they delete it.- petobject
The pet being assessed. The client fills in anything you left blank, and can correct what you entered, before they start, so these show the latest values.
Show child attributesHide child attributes
The pet’s name.
The pet’s species.
Show child attributesHide child attributes
- idstring
Starts with
species_. See List species. - namestring
For example
Canine.
The pet’s breed.
Show child attributesHide child attributes
- idstring
Starts with
breed_. See List breeds. - namestring
For example
Beagle.
Your personal note, included in the email to the client.
- metadatahash
Key-value pairs you attached, such as your patient ID. See Metadata.
- emails_sentinteger
How many invite emails Hospet has sent, up to 3.
0when created withsend_email: false. When the latest invite email was sent. ISO 8601, in UTC.
- expires_attimestamp
When the link stops working: 30 days after the latest email, or after creation if no email was sent. Doesn’t apply once completed. ISO 8601, in UTC.
When the client submitted the assessment. ISO 8601, in UTC.
When the client saved the assessment to a Hospet account. From then on the pet is shared with your practice and appears in your Client List. ISO 8601, in UTC.
- created_attimestamp
When the assessment was created. ISO 8601, in UTC.
nulluntil the client submits the assessment.Show child attributesHide child attributes
- categoriesarray of objects
One entry per category, in the same order as the assessment.
Show child attributesHide child attributes
- idinteger
The category’s ID.
- namestring
For example
Behavior,DiscomfortorNourishment. - scoreinteger
The total of the client’s answers in this category.
- max_scoreinteger
The highest possible total for this category.
- percentageinteger
scoreas a percentage ofmax_score, rounded down. Higher is better. 50 or less may mean the pet needs extra support in that area. - skippedboolean
Whether the category was left out of this assessment.
- pdf_urlstring
Where to download the PDF. Needs your API key.
{
"id": "ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
"object": "one_time_assessment",
"status": "completed",
"source": "api",
"url": "https://my.hospet.app/assessment/x7Yk2mQp9RvT3sW8nB4cL6dF",
"client_id": "acct_5nR8tK2wQ7mV4xB9cL3dF6hJ",
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pet_id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
"pet": {
"name": "Biscuit",
"species": {
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"name": "Canine"
},
"breed": {
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"name": "Beagle"
}
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442"
},
"emails_sent": 1,
"last_emailed_at": "2026-10-20T14:03:11Z",
"expires_at": "2026-11-19T14:03:11Z",
"completed_at": "2026-10-21T09:12:45Z",
"client_account_created_at": "2026-10-21T09:20:31Z",
"created_at": "2026-10-20T14:03:11Z",
"results": {
"categories": [
{
"id": 1,
"name": "Behavior",
"score": 31,
"max_score": 40,
"percentage": 77,
"skipped": false
},
{
"id": 2,
"name": "Discomfort",
"score": 14,
"max_score": 20,
"percentage": 70,
"skipped": false
},
{
"id": 3,
"name": "Nourishment",
"score": 17,
"max_score": 20,
"percentage": 85,
"skipped": false
},
{
"id": 4,
"name": "Elimination",
"score": 18,
"max_score": 20,
"percentage": 90,
"skipped": false
},
{
"id": 5,
"name": "Movement",
"score": 9,
"max_score": 20,
"percentage": 45,
"skipped": false
},
{
"id": 6,
"name": "Hygiene",
"score": 8,
"max_score": 10,
"percentage": 80,
"skipped": false
},
{
"id": 7,
"name": "Household",
"score": 19,
"max_score": 25,
"percentage": 76,
"skipped": false
}
],
"pdf_url": "https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH/pdf"
}
}Create a one-time assessmentLink to Create a one-time assessment
/one_time_assessmentsCreates an assessment and, unless you turn it off, emails the client a link to complete it. The email comes from Hospet with your practice’s name, and replies go to your practice’s primary contact email.
Sending the link yourself
With send_email: false, Hospet doesn’t email the client. Send them the url from the response by text message or through your own client portal. The link expires 30 days after you create the assessment.
Headers
A unique value, such as a UUID, so a retried request can’t create a second assessment or send a second email. See Idempotent requests.
Body parameters
The pet owner to send the assessment to.
Show child parametersHide child parameters
Up to 100 characters. Used to greet the client.
Up to 100 characters.
Where Hospet sends the invite and the results.
What you already know about the pet. The client fills in anything you leave out before they start.
Show child parametersHide child parameters
The pet’s name.
A species ID from List species, starting with
species_. Or sendspecies_name.A breed ID from List breeds, starting with
breed_. Must belong tospecies_id. If you send onlybreed_id, the species is set from the breed. Or sendbreed_name.Instead of
species_id: the species as your records name it, such asDogorCanine. Case and extra spaces are ignored. If it doesn’t match, the client chooses. See Species and breeds.Instead of
breed_id: the breed as your records name it, such asLabrador Retriever. It’s looked for within the species, and if you send onlybreed_name, the species is set from the breed. If it doesn’t match, the client chooses.
A personal note for the client, included in the email. Up to 1,000 characters.
Your own key-value pairs, such as a patient ID, returned on the assessment and in webhook events. Up to 50 keys. Keys can be up to 40 characters and values up to 500.
Set to
falseto skip the invite email and send theurlto the client yourself. The results are still emailed to the client and to your practice when they submit it.Defaults to
true.
Returns
The new one-time assessment, with status 201. If you repeat a request with the same Idempotency-Key, you get the original assessment back with status 200.
curl https://my.hospet.app/api/connect/v1/one_time_assessments \
-H "Authorization: Bearer $HOSPET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pet": {
"name": "Biscuit",
"species_name": "Dog",
"breed_name": "Beagle"
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442"
}
}'require "net/http"
require "json"
require "securerandom"
uri = URI("https://my.hospet.app/api/connect/v1/one_time_assessments")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = SecureRandom.uuid
request.body = JSON.generate({
owner: {
first_name: "Pat",
last_name: "Smith",
email: "pat@example.com",
},
pet: {
name: "Biscuit",
species_name: "Dog",
breed_name: "Beagle",
},
message: "Please complete this before Tuesday’s visit.",
metadata: {
patient_id: "PMS-10442",
},
})
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
assessment = JSON.parse(response.body)
puts assessment["url"]const response = await fetch("https://my.hospet.app/api/connect/v1/one_time_assessments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
owner: {
first_name: "Pat",
last_name: "Smith",
email: "pat@example.com",
},
pet: {
name: "Biscuit",
species_name: "Dog",
breed_name: "Beagle",
},
message: "Please complete this before Tuesday’s visit.",
metadata: {
patient_id: "PMS-10442",
},
}),
});
const assessment = await response.json();
console.log(assessment.url);import os
import uuid
import requests
response = requests.post(
"https://my.hospet.app/api/connect/v1/one_time_assessments",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com",
},
"pet": {
"name": "Biscuit",
"species_name": "Dog",
"breed_name": "Beagle",
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442",
},
},
)
assessment = response.json()
print(assessment["url"]){
"id": "ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
"object": "one_time_assessment",
"status": "open",
"source": "api",
"url": "https://my.hospet.app/assessment/x7Yk2mQp9RvT3sW8nB4cL6dF",
"client_id": null,
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pet_id": null,
"pet": {
"name": "Biscuit",
"species": {
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"name": "Canine"
},
"breed": {
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"name": "Beagle"
}
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442"
},
"emails_sent": 1,
"last_emailed_at": "2026-10-20T14:03:11Z",
"expires_at": "2026-11-19T14:03:11Z",
"completed_at": null,
"client_account_created_at": null,
"created_at": "2026-10-20T14:03:11Z",
"results": null
}Retrieve a one-time assessmentLink to Retrieve a one-time assessment
/one_time_assessments/:idGets an assessment, including its results once the client has submitted it.
Path parameters
The assessment’s ID, starting with
ota_.
Returns
The one-time assessment.
curl https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH \
-H "Authorization: Bearer $HOSPET_API_KEY"require "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
assessment = JSON.parse(response.body)
puts assessment["status"]const response = await fetch("https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH", {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
const assessment = await response.json();
console.log(assessment.status);import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
)
assessment = response.json()
print(assessment["status"]){
"id": "ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
"object": "one_time_assessment",
"status": "completed",
"source": "api",
"url": "https://my.hospet.app/assessment/x7Yk2mQp9RvT3sW8nB4cL6dF",
"client_id": null,
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pet_id": null,
"pet": {
"name": "Biscuit",
"species": {
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"name": "Canine"
},
"breed": {
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"name": "Beagle"
}
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442"
},
"emails_sent": 1,
"last_emailed_at": "2026-10-20T14:03:11Z",
"expires_at": "2026-11-19T14:03:11Z",
"completed_at": "2026-10-21T09:12:45Z",
"client_account_created_at": null,
"created_at": "2026-10-20T14:03:11Z",
"results": {
"categories": [
{
"id": 1,
"name": "Behavior",
"score": 31,
"max_score": 40,
"percentage": 77,
"skipped": false
},
{
"id": 2,
"name": "Discomfort",
"score": 14,
"max_score": 20,
"percentage": 70,
"skipped": false
},
{
"id": 3,
"name": "Nourishment",
"score": 17,
"max_score": 20,
"percentage": 85,
"skipped": false
},
{
"id": 4,
"name": "Elimination",
"score": 18,
"max_score": 20,
"percentage": 90,
"skipped": false
},
{
"id": 5,
"name": "Movement",
"score": 9,
"max_score": 20,
"percentage": 45,
"skipped": false
},
{
"id": 6,
"name": "Hygiene",
"score": 8,
"max_score": 10,
"percentage": 80,
"skipped": false
},
{
"id": 7,
"name": "Household",
"score": 19,
"max_score": 25,
"percentage": 76,
"skipped": false
}
],
"pdf_url": "https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH/pdf"
}
}List one-time assessmentsLink to List one-time assessments
/one_time_assessmentsLists your practice’s assessments, newest first, including ones sent from the dashboard.
Query parameters
How many to return, from 1 to 100.
Defaults to
10.An assessment ID. Returns the assessments created before it, so pass the last ID of the previous page.
Only return assessments with this status.
openWaiting for the client.expiredThe link expired before the client submitted it.completedSubmitted by the client.
Only return assessments sent to this email address.
Returns
A list of one-time assessments.
curl -G https://my.hospet.app/api/connect/v1/one_time_assessments \
-H "Authorization: Bearer $HOSPET_API_KEY" \
-d limit=1 \
-d status=completedrequire "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/one_time_assessments")
uri.query = URI.encode_www_form(limit: 1, status: "completed")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
assessments = JSON.parse(response.body)
puts assessments["has_more"]const params = new URLSearchParams({ limit: "1", status: "completed" });
const response = await fetch(`https://my.hospet.app/api/connect/v1/one_time_assessments?${params}`, {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
const assessments = await response.json();
console.log(assessments.has_more);import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/one_time_assessments",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
params={
"limit": 1,
"status": "completed",
},
)
assessments = response.json()
print(assessments["has_more"]){
"object": "list",
"url": "/api/connect/v1/one_time_assessments",
"has_more": true,
"data": [
{
"id": "ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
"object": "one_time_assessment",
"status": "completed",
"source": "api",
"url": "https://my.hospet.app/assessment/x7Yk2mQp9RvT3sW8nB4cL6dF",
"client_id": null,
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pet_id": null,
"pet": {
"name": "Biscuit",
"species": {
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"name": "Canine"
},
"breed": {
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"name": "Beagle"
}
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442"
},
"emails_sent": 1,
"last_emailed_at": "2026-10-20T14:03:11Z",
"expires_at": "2026-11-19T14:03:11Z",
"completed_at": "2026-10-21T09:12:45Z",
"client_account_created_at": null,
"created_at": "2026-10-20T14:03:11Z",
"results": {
"categories": [
{
"id": 1,
"name": "Behavior",
"score": 31,
"max_score": 40,
"percentage": 77,
"skipped": false
},
{
"id": 2,
"name": "Discomfort",
"score": 14,
"max_score": 20,
"percentage": 70,
"skipped": false
},
{
"id": 3,
"name": "Nourishment",
"score": 17,
"max_score": 20,
"percentage": 85,
"skipped": false
},
{
"id": 4,
"name": "Elimination",
"score": 18,
"max_score": 20,
"percentage": 90,
"skipped": false
},
{
"id": 5,
"name": "Movement",
"score": 9,
"max_score": 20,
"percentage": 45,
"skipped": false
},
{
"id": 6,
"name": "Hygiene",
"score": 8,
"max_score": 10,
"percentage": 80,
"skipped": false
},
{
"id": 7,
"name": "Household",
"score": 19,
"max_score": 25,
"percentage": 76,
"skipped": false
}
],
"pdf_url": "https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH/pdf"
}
}
]
}Download the PDFLink to Download the PDF
/one_time_assessments/:id/pdfDownloads the completed assessment as a PDF: a score summary, then every question with the client’s answer. It’s the same file the result emails attach and the webhook sends.
Path parameters
The assessment’s ID, starting with
ota_.
Returns
The PDF, with Content-Type: application/pdf. If the client hasn’t submitted the assessment yet, you get a 400 error with the code assessment_not_completed.
curl https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH/pdf \
-H "Authorization: Bearer $HOSPET_API_KEY" \
-o assessment-biscuit-smith-20261021.pdfrequire "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH/pdf")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
File.binwrite("assessment-biscuit-smith-20261021.pdf", response.body)import { writeFile } from "node:fs/promises";
const response = await fetch("https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH/pdf", {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
await writeFile("assessment-biscuit-smith-20261021.pdf", Buffer.from(await response.arrayBuffer()));import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH/pdf",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
)
with open("assessment-biscuit-smith-20261021.pdf", "wb") as f:
f.write(response.content)HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="assessment-biscuit-smith-20261021.pdf"
Content-Length: 183422ClientsLink to Clients
A client is a pet owner who has added your practice to their support team in Hospet, for example with your referral link or by saving a one-time assessment you sent them to a Hospet account. They appear in the Client List in Hospet Connect.
Clients choose which of their pets to share with your practice, and only those pets are included. They can share more pets, stop sharing them, or remove your practice at any time.
The client.added, client.updated and client.removed webhooks tell you about these changes as they happen. Use these endpoints to load the clients you already have, or to catch up after your webhook endpoint was off.
Match clients to your records by owner.email, then keep the client’s id. It stays the same if they remove your practice and add it again.
The client objectLink to The client object
A pet owner who has added your practice to their support team in Hospet, with the pets they share with your practice.
Attributes
- idstring
Unique identifier for the client. Starts with
acct_. Stays the same if they remove your practice and add it again. - objectstring
Always
client. - ownerobject
The pet owner. Match this to your client records by
email.Show child attributesHide child attributes
- first_namestring
The owner’s first name.
The owner’s last name.
- emailstring
The email address the owner signs in to Hospet with.
- petsarray of objects
The pets the client shares with your practice, sorted by name. Their other pets aren’t included. Empty if they don’t share any yet, and always empty on
client.removed.Show child attributesHide child attributes
- idstring
Unique identifier for the pet. Starts with
pet_. Use it to tell pets apart between events. - namestring
The pet’s name.
The pet’s species.
Show child attributesHide child attributes
- idstring
Starts with
species_. See List species. - namestring
For example
Canine.
The pet’s breed.
Show child attributesHide child attributes
- idstring
Starts with
breed_. See List breeds. - namestring
For example
Beagle.
{
"id": "acct_5nR8tK2wQ7mV4xB9cL3dF6hJ",
"object": "client",
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pets": [
{
"id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
"name": "Biscuit",
"species": {
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"name": "Canine"
},
"breed": {
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"name": "Beagle"
}
}
]
}Retrieve a clientLink to Retrieve a client
/clients/:idcurl https://my.hospet.app/api/connect/v1/clients/acct_5nR8tK2wQ7mV4xB9cL3dF6hJ \
-H "Authorization: Bearer $HOSPET_API_KEY"require "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/clients/acct_5nR8tK2wQ7mV4xB9cL3dF6hJ")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
client = JSON.parse(response.body)
puts client["pets"]const response = await fetch("https://my.hospet.app/api/connect/v1/clients/acct_5nR8tK2wQ7mV4xB9cL3dF6hJ", {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
const client = await response.json();
console.log(client.pets);import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/clients/acct_5nR8tK2wQ7mV4xB9cL3dF6hJ",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
)
client = response.json()
print(client["pets"]){
"id": "acct_5nR8tK2wQ7mV4xB9cL3dF6hJ",
"object": "client",
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pets": [
{
"id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
"name": "Biscuit",
"species": {
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"name": "Canine"
},
"breed": {
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"name": "Beagle"
}
}
]
}List clientsLink to List clients
/clientsLists the pet owners who have added your practice to their support team, newest first.
Query parameters
How many to return, from 1 to 100.
Defaults to
10.A client ID. Returns the clients after it, so pass the last ID of the previous page.
Only return the client with this email address.
Returns
curl -G https://my.hospet.app/api/connect/v1/clients \
-H "Authorization: Bearer $HOSPET_API_KEY" \
-d limit=1require "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/clients")
uri.query = URI.encode_www_form(limit: 1)
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
clients = JSON.parse(response.body)
puts clients["has_more"]const params = new URLSearchParams({ limit: "1" });
const response = await fetch(`https://my.hospet.app/api/connect/v1/clients?${params}`, {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
const clients = await response.json();
console.log(clients.has_more);import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/clients",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
params={
"limit": 1,
},
)
clients = response.json()
print(clients["has_more"]){
"object": "list",
"url": "/api/connect/v1/clients",
"has_more": true,
"data": [
{
"id": "acct_5nR8tK2wQ7mV4xB9cL3dF6hJ",
"object": "client",
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pets": [
{
"id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
"name": "Biscuit",
"species": {
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"name": "Canine"
},
"breed": {
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"name": "Beagle"
}
}
]
}
]
}Login linksLink to Login links
A login link takes someone from a patient record in your software straight to the pet in Hospet Connect, already signed in. Add a button such as “View in Hospet” to the record. When it’s clicked, your server creates a login link and redirects the browser to its url.
Links work once, for 60 seconds. Create one each time someone clicks. Don’t put links on pages ahead of time, or store, log or email them.
The link signs in as the person who created your API key, while they’re still on your practice’s Hospet Connect account. Otherwise it signs in as the account owner. Anyone else signed in to Hospet in that browser is signed out first. If that person has two-factor authentication turned on, Hospet asks for their verification code before opening the pet.
Because your API key can sign in to Hospet Connect, keep it on your server, as with every API request. Never put it in a browser, an app, or the link itself.
The login link objectLink to The login link object
A link that signs someone in to Hospet Connect and opens a pet. It works once, and only for a minute.
Attributes
- objectstring
Always
login_link. - urlstring
Send the person here. It signs them in and opens the pet. Don’t store it, log it or show it on a page for later: it’s as good as a password until it’s used or expires.
- pet_idstring
The pet it opens, starting with
pet_. - created_attimestamp
When the link was created. ISO 8601, in UTC.
- expires_attimestamp
When the link stops working, 60 seconds after it was created. ISO 8601, in UTC.
{
"object": "login_link",
"url": "https://my.hospet.app/connect/login/9fQ2wLm7Xk4Rt8Vb3Nc6Hd1Jz5Ps7Ty2Ka9Ge4Mu",
"pet_id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
"created_at": "2026-10-21T15:30:00Z",
"expires_at": "2026-10-21T15:31:00Z"
}Create a login linkLink to Create a login link
/login_linksCreates a link that signs someone in to Hospet Connect and opens a pet, so they can go straight from a patient record in your software to the pet in Hospet. Create it when they click, then redirect them to its url. It works once, for 60 seconds.
Body parameters
The pet to open, starting with
pet_. Find it in the client’spetsfrom Retrieve a client, or inpet_idon a one-time assessment the client saved to Hospet. The client must share the pet with your practice.
Returns
The new login link, with status 201. Each request creates a new link, so this endpoint ignores Idempotency-Key. If a request fails, just try again: links that aren’t used expire.
curl https://my.hospet.app/api/connect/v1/login_links \
-H "Authorization: Bearer $HOSPET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"pet_id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ"
}'require "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/login_links")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
request["Content-Type"] = "application/json"
request.body = JSON.generate({
pet_id: "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
})
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
login_link = JSON.parse(response.body)
puts login_link["url"]const response = await fetch("https://my.hospet.app/api/connect/v1/login_links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
pet_id: "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
}),
});
const login_link = await response.json();
console.log(login_link.url);import os
import requests
response = requests.post(
"https://my.hospet.app/api/connect/v1/login_links",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
json={
"pet_id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
},
)
login_link = response.json()
print(login_link["url"]){
"object": "login_link",
"url": "https://my.hospet.app/connect/login/9fQ2wLm7Xk4Rt8Vb3Nc6Hd1Jz5Ps7Ty2Ka9Ge4Mu",
"pet_id": "pet_2hT7kM9qW4xR6vB3nC8dL5fJ",
"created_at": "2026-10-21T15:30:00Z",
"expires_at": "2026-10-21T15:31:00Z"
}Species & breedsLink to Species & breeds
Use these to fill in pet.species_id and pet.breed_id when you create an assessment. Both are optional. Whatever you leave out, the client chooses before they start.
You don’t have to use IDs. Send pet.species_name and pet.breed_name instead, with the names from your practice software, and Hospet finds the matching species and breed. Case and extra spaces are ignored, and Dog and Cat are accepted for Canine and Feline. Otherwise a name has to match Hospet’s exactly: Labrador Retriever matches, but Lab doesn’t.
A name that doesn’t match is left blank for the client to choose, rather than rejected. Check pet.species and pet.breed in the response to see what matched. If many of your breed names don’t match, map them to IDs once with these endpoints, and send the IDs instead.
The species objectLink to The species object
{
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"object": "species",
"name": "Canine"
}List speciesLink to List species
/speciescurl https://my.hospet.app/api/connect/v1/species \
-H "Authorization: Bearer $HOSPET_API_KEY"require "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/species")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
species = JSON.parse(response.body)
puts species["data"]const response = await fetch("https://my.hospet.app/api/connect/v1/species", {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
const species = await response.json();
console.log(species.data);import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/species",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
)
species = response.json()
print(species["data"]){
"object": "list",
"url": "/api/connect/v1/species",
"has_more": false,
"data": [
{
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"object": "species",
"name": "Canine"
},
{
"id": "species_9Tq3Lw7Xm2Rk5Vn8Bc4Jd1Fz",
"object": "species",
"name": "Feline"
}
]
}The breed objectLink to The breed object
A breed within a species.
Attributes
- idstring
Unique identifier for the breed. Starts with
breed_. Pass this aspet.breed_id. - objectstring
Always
breed. - namestring
For example
BeagleorDomestic Shorthair. - species_idstring
The ID of the species this breed belongs to.
{
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"object": "breed",
"name": "Beagle",
"species_id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ"
}List breedsLink to List breeds
/species/:species_id/breedsLists the breeds for a species.
Path parameters
A species ID from List species, starting with
species_.
Returns
A list of breeds. Everything is returned at once, so has_more is always false.
curl https://my.hospet.app/api/connect/v1/species/species_4Kd9mQ2xR7tV1nB8cL5wP3zJ/breeds \
-H "Authorization: Bearer $HOSPET_API_KEY"require "net/http"
require "json"
uri = URI("https://my.hospet.app/api/connect/v1/species/species_4Kd9mQ2xR7tV1nB8cL5wP3zJ/breeds")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("HOSPET_API_KEY")}"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
breeds = JSON.parse(response.body)
puts breeds["data"]const response = await fetch("https://my.hospet.app/api/connect/v1/species/species_4Kd9mQ2xR7tV1nB8cL5wP3zJ/breeds", {
headers: {
Authorization: `Bearer ${process.env.HOSPET_API_KEY}`,
},
});
const breeds = await response.json();
console.log(breeds.data);import os
import requests
response = requests.get(
"https://my.hospet.app/api/connect/v1/species/species_4Kd9mQ2xR7tV1nB8cL5wP3zJ/breeds",
headers={
"Authorization": f"Bearer {os.environ['HOSPET_API_KEY']}",
},
)
breeds = response.json()
print(breeds["data"]){
"object": "list",
"url": "/api/connect/v1/species/species_4Kd9mQ2xR7tV1nB8cL5wP3zJ/breeds",
"has_more": false,
"data": [
{
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"object": "breed",
"name": "Beagle",
"species_id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ"
},
{
"id": "breed_2Mz8Rq4Tx6Kw9Vn1Bc5Lh3Jp",
"object": "breed",
"name": "Beagle Mix",
"species_id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ"
}
]
}WebhooksLink to Webhooks
Webhooks tell your system when something happens in Hospet, such as a client submitting an assessment, so you don’t have to keep checking. Hospet sends an HTTPS POST with a JSON event to each webhook endpoint that’s subscribed to that event type.
When an event arrives, your endpoint should:
- Check the signature, to be sure it came from Hospet.
- Respond with a
2xxstatus within 15 seconds. - Do the real work, such as saving a PDF to the patient’s record, in the background, and skip event IDs it has already handled.
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | HospetConnect-Webhooks/1.0 |
Hospet-Signature | The signature. See Verifying signatures. |
Hospet-Event-Id | The event’s id, so you can ignore duplicates without parsing the body. |
Hospet-Event-Type | The event’s type. |
POST /webhooks/hospet HTTP/1.1
Content-Type: application/json
User-Agent: HospetConnect-Webhooks/1.0
Hospet-Signature: t=1792573966,v1=9a47e6fd02bf7f6039bd7e9910def2fcfb18aad39f827d6c605a76e691bf44b1
Hospet-Event-Id: evt_3fJk9QmW2xR7tLp4
Hospet-Event-Type: one_time_assessment.completedEndpoints and signing secretsLink to Endpoints and signing secrets
Add endpoints with Add Endpoint on the API & Webhooks page, in the profile menu at the top right of Hospet Connect. Admins of an approved Hospet Connect account can add up to 5.
The URL must use HTTPS and be reachable from the internet. Addresses on private networks, such as localhost or 10.0.0.1, aren’t accepted.
Each endpoint chooses which event types it receives. New endpoints get all of them. When Hospet adds an event type, existing endpoints don’t receive it until you turn it on in the endpoint’s settings, so a receiver never gets events it wasn’t built for.
Each endpoint has its own signing secret, starting with whsec_. To see it, click Reveal on the endpoint’s page. Roll Secret replaces it, and the old secret stops working straight away, so update your receiver at the same time. Deliveries that fail while you switch over are retried.
Verifying signaturesLink to Verifying signatures
Every request has a Hospet-Signature header. Check it before trusting the event, so nobody else can send your endpoint fake results.
The header has a timestamp and one or more signatures:
Hospet-Signature: t=1760961600,v1=603d65e7…
- Split the header on
,, then each part on the first=.tis when the request was signed, in Unix seconds.v1is a signature, and there may be more than one. - Join
t, a period and the raw request body:{t}.{body}. Use the exact bytes you received. Parsing the JSON and serializing it again changes them. - Compute an HMAC-SHA256 of that string, using your whole signing secret, including
whsec_, as the key. Hex-encode the result. - Compare it with each
v1value using a constant-time comparison. Accept the request if any match. - Reject the request if
tis more than 5 minutes away from your server’s clock, so an old request can’t be replayed. Retries are signed again with a newt.
Respond with 400 when the check fails.
Test your code
With the secret whsec_test_4eC39HqLyjWDarjtT1zdp7dc and the body {"id":"evt_test","object":"event","type":"one_time_assessment.completed"}, the header is:
t=1760961600,v1=603d65e7e9407e74c82ac6b53082234a3945c7e45fb85a1f9bc365584068ae71
Turn off the timestamp check while you try this, since the example is old.
# Check the test vector (or a real event) by hand with openssl.
secret='whsec_test_4eC39HqLyjWDarjtT1zdp7dc'
header='t=1760961600,v1=603d65e7e9407e74c82ac6b53082234a3945c7e45fb85a1f9bc365584068ae71'
printf '%s' '{"id":"evt_test","object":"event","type":"one_time_assessment.completed"}' > body.json
timestamp=$(printf '%s' "$header" | sed -E 's/.*t=([0-9]+).*/\1/')
signature=$(printf '%s' "$header" | sed -E 's/.*v1=([0-9a-f]+).*/\1/')
expected=$(printf '%s.' "$timestamp" | cat - body.json | openssl dgst -sha256 -hmac "$secret" | sed 's/^.* //')
if [ "$expected" = "$signature" ]; then echo "Valid"; else echo "Not valid"; firequire "openssl"
# Returns true when a webhook really came from Hospet.
# `payload` is the raw request body, exactly as received.
def valid_hospet_signature?(payload, header, secret, tolerance: 300)
parts = header.to_s.split(",").map { |part| part.split("=", 2) }
timestamp = parts.find { |key, _| key == "t" }&.last.to_i
signatures = parts.select { |key, _| key == "v1" }.map(&:last)
return false if timestamp.zero? || signatures.empty?
return false if (Time.now.to_i - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
signatures.any? { |signature| OpenSSL.secure_compare(expected, signature) }
endimport { createHmac, timingSafeEqual } from "node:crypto";
// Returns true when a webhook really came from Hospet.
// `payload` is the raw request body (a Buffer), not parsed JSON.
export function isValidHospetSignature(payload, header, secret, tolerance = 300) {
const parts = String(header ?? "").split(",").map((part) => part.split("="));
const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value);
if (!timestamp || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
const expected = Buffer.from(
createHmac("sha256", secret).update(`${timestamp}.`).update(payload).digest("hex"),
);
return signatures.some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && timingSafeEqual(given, expected);
});
}import hashlib
import hmac
import time
def is_valid_hospet_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
"""Returns True when a webhook really came from Hospet. `payload` is the raw request body."""
parts = [part.split("=", 1) for part in (header or "").split(",") if "=" in part]
timestamps = [value for key, value in parts if key == "t"]
signatures = [value for key, value in parts if key == "v1"]
if not timestamps or not timestamps[0].isdigit() or not signatures:
return False
timestamp = int(timestamps[0])
if abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + payload
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)# config/routes.rb: post "/webhooks/hospet", to: "hospet_webhooks#create"
class HospetWebhooksController < ActionController::API
def create
payload = request.raw_post
header = request.headers["Hospet-Signature"]
secret = ENV.fetch("HOSPET_WEBHOOK_SECRET")
return head :bad_request unless valid_hospet_signature?(payload, header, secret)
event = JSON.parse(payload)
if event["type"] == "one_time_assessment.completed"
# Respond quickly: hand the work to a background job, and skip
# event IDs you've already processed (retries reuse the same ID).
SaveHospetResultsJob.perform_later(payload)
end
head :ok
end
private
def valid_hospet_signature?(payload, header, secret, tolerance: 300)
parts = header.to_s.split(",").map { |part| part.split("=", 2) }
timestamp = parts.find { |key, _| key == "t" }&.last.to_i
signatures = parts.select { |key, _| key == "v1" }.map(&:last)
return false if timestamp.zero? || signatures.empty?
return false if (Time.now.to_i - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
signatures.any? { |signature| ActiveSupport::SecurityUtils.secure_compare(expected, signature) }
end
endimport express from "express";
import { isValidHospetSignature } from "./verify-hospet-signature.mjs";
const app = express();
// express.raw keeps the exact bytes for the signature check. Raise the size
// limit too: events carry the PDF, and Express allows only 100 KB by default.
app.post("/webhooks/hospet", express.raw({ type: "application/json", limit: "5mb" }), (req, res) => {
const header = req.get("Hospet-Signature");
if (!isValidHospetSignature(req.body, header, process.env.HOSPET_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
if (event.type === "one_time_assessment.completed") {
// Respond quickly: queue the work, and skip event IDs you've already
// processed (retries reuse the same ID).
queueResults(event);
}
res.sendStatus(200);
});
app.listen(4567);import json
import os
from flask import Flask, abort, request
from verify_hospet_signature import is_valid_hospet_signature
app = Flask(__name__)
@app.post("/webhooks/hospet")
def hospet_webhook():
payload = request.get_data() # the raw bytes, for the signature check
header = request.headers.get("Hospet-Signature", "")
if not is_valid_hospet_signature(payload, header, os.environ["HOSPET_WEBHOOK_SECRET"]):
abort(400)
event = json.loads(payload)
if event["type"] == "one_time_assessment.completed":
# Respond quickly: queue the work, and skip event IDs you've already
# processed (retries reuse the same ID).
queue_results(event)
return "", 200The event objectLink to The event object
What Hospet POSTs to your webhook endpoints.
Attributes
- idstring
Unique identifier for the event. Starts with
evt_. The same on every retry, so use it to ignore duplicates. - objectstring
Always
event. - typestring
What happened. See Event types.
- api_versionstring
The API version used to build
data.object. - created_attimestamp
When the event happened. Doesn’t change on retries. ISO 8601, in UTC.
- livemodeboolean
falsefor test events sent with the Send Test Event button. - dataobject
The event’s contents.
Show child attributesHide child attributes
- objectobject
The object the event is about, as it was when the event happened. Its type depends on the event, such as a one-time assessment or a client. See Event types.
A file that comes with the event, such as the results PDF on
one_time_assessment.completed. Only present on event types that include one.Show child attributesHide child attributes
- objectstring
Always
file. - filenamestring
A suggested file name.
- content_typestring
The file’s media type, such as
application/pdf. - sizeinteger
The decoded file size in bytes.
- sha256string
Hex SHA-256 of the decoded file, to check it arrived intact.
- content_base64string
The file, base64 encoded with no line breaks.
{
"id": "evt_3fJk9QmW2xR7tLp4",
"object": "event",
"type": "one_time_assessment.completed",
"api_version": "v1",
"created_at": "2026-10-21T09:12:46Z",
"livemode": true,
"data": {
"object": {
"id": "ota_8kQ2vX9rT4mN7pL1bC3dF6gH",
"object": "one_time_assessment",
"status": "completed",
"source": "api",
"url": "https://my.hospet.app/assessment/x7Yk2mQp9RvT3sW8nB4cL6dF",
"client_id": null,
"owner": {
"first_name": "Pat",
"last_name": "Smith",
"email": "pat@example.com"
},
"pet_id": null,
"pet": {
"name": "Biscuit",
"species": {
"id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
"name": "Canine"
},
"breed": {
"id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
"name": "Beagle"
}
},
"message": "Please complete this before Tuesday’s visit.",
"metadata": {
"patient_id": "PMS-10442"
},
"emails_sent": 1,
"last_emailed_at": "2026-10-20T14:03:11Z",
"expires_at": "2026-11-19T14:03:11Z",
"completed_at": "2026-10-21T09:12:45Z",
"client_account_created_at": null,
"created_at": "2026-10-20T14:03:11Z",
"results": {
"categories": [
{
"id": 1,
"name": "Behavior",
"score": 31,
"max_score": 40,
"percentage": 77,
"skipped": false
},
{
"id": 2,
"name": "Discomfort",
"score": 14,
"max_score": 20,
"percentage": 70,
"skipped": false
},
{
"id": 3,
"name": "Nourishment",
"score": 17,
"max_score": 20,
"percentage": 85,
"skipped": false
},
{
"id": 4,
"name": "Elimination",
"score": 18,
"max_score": 20,
"percentage": 90,
"skipped": false
},
{
"id": 5,
"name": "Movement",
"score": 9,
"max_score": 20,
"percentage": 45,
"skipped": false
},
{
"id": 6,
"name": "Hygiene",
"score": 8,
"max_score": 10,
"percentage": 80,
"skipped": false
},
{
"id": 7,
"name": "Household",
"score": 19,
"max_score": 25,
"percentage": 76,
"skipped": false
}
],
"pdf_url": "https://my.hospet.app/api/connect/v1/one_time_assessments/ota_8kQ2vX9rT4mN7pL1bC3dF6gH/pdf"
}
},
"file": {
"object": "file",
"filename": "assessment-biscuit-smith-20261021.pdf",
"content_type": "application/pdf",
"size": 183422,
"sha256": "9f2c4e1b7a0d3f5e8c6b2a1d4e7f0c3b5a8d1e4f7c0b3a6d9e2f5c8b1a4d7e0f",
"content_base64": "JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQp4nO1d…"
}
}
}Event typesLink to Event types
Each event’s type says what happened, as object.action. Hospet adds event types over time and announces them in the changelog. So that new ones never break your receiver:
- Check
typebefore acting on an event, and respond with2xxto types you don’t handle, so they aren’t retried. - Only read
data.fileon event types that include one. - Ignore fields you don’t recognize. New fields can be added to events and objects without a new API version.
A client submits a one-time assessment, whether your practice sent it from Hospet Connect or the API. Sent once per assessment.
data.object: The one-time assessmentdata.file: The completed assessment as a PDF, the same one the result emails attach.A client saves a completed one-time assessment to their Hospet account, which also shares the pet with your practice.
client_idandpet_idsay which Hospet client and pet it became: store them with the patient record you put inmetadata. Sent once per assessment.data.object: The one-time assessmentA pet owner adds your practice to their support team, for example with your referral link or by saving a one-time assessment you sent them to a Hospet account.
petslists the pets they share with you, which may be none yet.data.object: The clientA client changes. Today that’s when they share a pet with your practice or stop sharing one, including by deleting a shared pet from Hospet. More changes may send it later, so compare
data.objectwith what you have rather than assuming what changed.data.object: The clientA client removes your practice from their support team.
petsis empty, since nothing is shared with you any more, and Retrieve a client returns a404.data.object: The client
Files in eventsLink to Files in events
Some event types include a file in data.file, such as the results PDF on one_time_assessment.completed. Decode content_base64, check it against size and sha256, then save it, for example to the patient’s record.
Events with a PDF are usually under 1 MB. Make sure your server accepts requests that size: Express accepts only 100 KB by default, and nginx 1 MB (raise client_max_body_size).
You can also download an assessment’s PDF at any time with the API.
# event.json is a webhook body you saved.
jq -r '.data.file.content_base64' event.json | base64 --decode > assessment.pdf
# Should match .data.file.sha256
shasum -a 256 assessment.pdfrequire "base64"
require "digest"
file = event["data"]["file"]
pdf = Base64.strict_decode64(file["content_base64"])
raise "The PDF didn't arrive intact" unless Digest::SHA256.hexdigest(pdf) == file["sha256"]
# Name the file yourself, e.g. after the assessment ID.
File.binwrite("#{event["data"]["object"]["id"]}.pdf", pdf)import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
const { file } = event.data;
const pdf = Buffer.from(file.content_base64, "base64");
if (createHash("sha256").update(pdf).digest("hex") !== file.sha256) {
throw new Error("The PDF didn't arrive intact");
}
// Name the file yourself, e.g. after the assessment ID.
await writeFile(`${event.data.object.id}.pdf`, pdf);import base64
import hashlib
file = event["data"]["file"]
pdf = base64.b64decode(file["content_base64"], validate=True)
if hashlib.sha256(pdf).hexdigest() != file["sha256"]:
raise ValueError("The PDF didn't arrive intact")
# Name the file yourself, e.g. after the assessment ID.
with open(f"{event['data']['object']['id']}.pdf", "wb") as f:
f.write(pdf)Delivery and retriesLink to Delivery and retries
A delivery succeeds when your endpoint responds with a 2xx status within 15 seconds. Anything else counts as a failure, including redirects, which aren’t followed.
Failed deliveries are tried again on this schedule, for about 3 days in all:
| Attempt | When |
|---|---|
| 1 | Right away |
| 2 | 1 minute after attempt 1 |
| 3 | 5 minutes after attempt 2 |
| 4 | 30 minutes after attempt 3 |
| 5 | 2 hours after attempt 4 |
| 6 | 5 hours after attempt 5 |
| 7 | 10 hours after attempt 6 |
| 8 | 24 hours after attempt 7 |
| 9 | 24 hours after attempt 8 |
Events can arrive more than once, and not always in order. Use the event id, also sent in the Hospet-Event-Id header, to skip events you’ve already processed.
If the last retry fails and the endpoint hasn’t accepted any event in the past 3 days, Hospet turns the endpoint off and emails your practice’s primary contact and account admins. Turn it back on from the endpoint’s page once it’s fixed. Events aren’t sent while it’s off, so catch up with the API, for example with List one-time assessments and status=completed, or List clients.
The endpoint’s page in Hospet Connect lists recent deliveries with the response your endpoint gave. Click Retry to send one again.
A delivery that’s waiting for its next retry has Retry Now instead, which sends it straight away. If that fails too, the automatic retries carry on as before: it doesn’t use one up or change when the next one happens.
TestingLink to Testing
Send Test Event on an endpoint’s page sends a sample event with made-up details, and any file the event type includes. If the endpoint receives more than one event type, choose which to send. Test events have livemode: false, and their data.object ID contains _test_, such as ota_test_ for a one-time assessment or acct_test_ for a client. They’re sent once, without retries.
Your endpoint has to be reachable from the internet, even for testing. To try a receiver running on your own computer, use a tunnelling tool that gives it a public HTTPS address, or send it a signed sample yourself with this script.
# Sends a signed sample event to a receiver running on your own machine.
secret="$HOSPET_WEBHOOK_SECRET"
body='{"id":"evt_local_test","object":"event","type":"one_time_assessment.completed","livemode":false,"data":{"object":{"id":"ota_local_test"}}}'
timestamp=$(date +%s)
signature=$(printf '%s.%s' "$timestamp" "$body" | openssl dgst -sha256 -hmac "$secret" | sed 's/^.* //')
curl http://localhost:4567/webhooks/hospet \
-H "Content-Type: application/json" \
-H "Hospet-Signature: t=$timestamp,v1=$signature" \
-d "$body"ChangelogLink to Changelog
First release of v1: create, retrieve and list one-time assessments, download their PDFs, retrieve and list clients, create login links, look up species and breeds, and receive one_time_assessment.completed,one_time_assessment.claimed, client.added, client.updated andclient.removed webhooks.