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:

  1. 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.
  2. Call the API Your software sends requests with the key to create and read your practice’s information. Start with Authentication.
  3. 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.

Base URL
https://my.hospet.app/api/connect/v1
Your first request
curl 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.

Authenticated request
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"])
401 responseJSON
{
  "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.

  • paramstringNullable

    The parameter the error is about, using dots for nested ones, such as owner.email.

  • doc_urlstringNullable

    A link to this error code’s entry in these docs.

  • Also sent as the X-Request-Id header. Include it if you contact us about a request.

  • errorsarray of objects

    For validation errors, every problem found, each with param, code and message. The top-level fields describe the first one.

HTTP status codes

HTTP status codes
StatusMeaning
200 OKIt worked.
201 CreatedThe assessment was created.
400 Bad RequestA parameter is missing or invalid, the JSON couldn’t be read, or an idempotency key was reused.
401 UnauthorizedThe API key is missing, not valid, or revoked.
403 ForbiddenThe key is valid, but your practice can’t use the API yet.
404 Not FoundNothing with that ID exists in your practice.
429 Too Many RequestsYou hit a rate limit. Wait for the number of seconds in Retry-After.
500 Server ErrorSomething went wrong on Hospet’s end. These are rare, and safe to retry with the same idempotency key.

Error types

Error types
TypeMeaning
authentication_errorThe request didn’t include a valid API key.
permission_errorThe key is valid, but it isn’t allowed to do this.
invalid_request_errorSomething about the request is wrong: a missing or invalid parameter, or an ID that doesn’t exist.
idempotency_errorAn idempotency key was reused with different parameters.
rate_limit_errorToo many requests in a short time.
api_errorA problem on Hospet’s end.

Error codes

  • api_key_missing401
    The request has no Authorization: Bearer header.
  • api_key_invalid401
    The key doesn’t match any key in Hospet. Check for missing characters.
  • api_key_revoked401
    Someone revoked this key in Hospet Connect. Create a new one.
  • provider_not_approved403
    Your practice’s Hospet Connect account is waiting for approval, or is no longer active.
  • parameter_missing400
    A required parameter is missing. param names it.
  • parameter_invalid400
    A parameter has a value Hospet can’t accept. param names it and message says why.
  • parameter_unknown400
    The request included a parameter the API doesn’t accept, such as a typo in a field name. param names it.
  • invalid_json400
    The request body isn’t valid JSON. Check the Content-Type header too.
  • resource_missing404
    Nothing with that ID exists in your practice. param names the ID when it came from a parameter.
  • assessment_not_completed400
    The PDF isn’t available until the client submits the assessment.
  • idempotency_key_reused400
    This idempotency key was used before with different parameters. Use a new key.
  • rate_limited429
    Too many requests. Retry after the number of seconds in Retry-After.
  • daily_create_limit429
    Your practice has created the most one-time assessments allowed in a day. Try again tomorrow, or contact us to raise the limit.
  • internal_error500
    Something went wrong on Hospet’s end. Retry with the same idempotency key.
Validation errorJSON
{
  "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.

POST/one_time_assessments
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"])
Replayed responseHTTP
HTTP/1.1 200 OK
Content-Type: application/json
Idempotent-Replayed: true
Key reused with different parametersJSON
{
  "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.

Request bodyJSON
{
  "owner": {
    "first_name": "Pat",
    "email": "pat@example.com"
  },
  "metadata": {
    "patient_id": "PMS-10442",
    "appointment_id": "A-88231"
  }
}
In the responseJSON
{
  "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.

List objectJSON
{
  "object": "list",
  "url": "/api/connect/v1/one_time_assessments",
  "has_more": true,
  "data": [
    "…"
  ]
}
Fetching every page
# 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_8kQ2vX9rT4mN7pL1bC3dF6gH
require "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.

Rate limits
LimitAllowance
Requests per API key100 per minute
Requests per IP address300 per minute, counted before the key is checked
Assessments created per practice200 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.

429 responseHTTP
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
BodyJSON
{
  "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:

  1. Assessments before visits: send an assessment for an appointment and save the results to the patient.
  2. Linking patients to pets: find out which Hospet client and pet match your client and patient.
  3. A “View in Hospet” button: open the pet in Hospet Connect from the patient record.
  4. 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.

Hospet IDs to store
Your recordStoreWhere it comes from
ClientThe Hospet client ID, starting with acct_client_id on a claimed assessment, or id on a client
PatientThe Hospet pet ID, starting with pet_pet_id on a claimed assessment, or id on one of a client’s pets
Assessment you sentIts 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.

  1. 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, with species_name and breed_name. Send an Idempotency-Key based on the appointment, so a retry or a double click can’t email the owner twice.
  2. 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_email to false and send the url from the response. Treat that link like a password: anyone who has it can open the owner’s results.
  3. Save the results. When the owner submits it, one_time_assessment.completed arrives with the category scores in results and the PDF in data.file. Save the PDF to the patient your metadata points 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.

Creating an assessment for an appointmentHTTP
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.

  1. Create the assessment with your patient ID in metadata, as in Assessments before visits.
  2. When the owner saves the completed assessment to their Hospet account, the pet is shared with your practice and one_time_assessment.claimed arrives. It includes your metadata, plus the Hospet client_id and pet_id.
  3. Store pet_id on the patient named in metadata, and client_id on 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.

  1. Match the client to your records by owner.email, and store their id.
  2. 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.

Event when the owner saves an assessment (shortened)JSON
{
  "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.

  1. Link the button to a page in your own software, not to Hospet. Protect that page with your software’s own sign-in.
  2. When the page is opened, create a login link with the patient’s stored pet_id.
  3. 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.

The button’s 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. Compare pets with 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.completed and one_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=completed and 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.

Handling events
# 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 and results is filled in. Stays completed if 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.

  • client_idstringNullable

    The client the assessment was saved to, starting with acct_. null until the client saves it to a Hospet account, which sets client_account_created_at and 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
    • The owner’s first name.

    • last_namestringNullable

      The owner’s last name.

    • emailstring

      Where the invite and the results are emailed. Stored in lowercase.

  • pet_idstringNullable

    The Hospet pet the assessment was saved to, starting with pet_. null until the client saves it to a Hospet account. Store it with your patient record, matched using metadata, and use it to create login links. It stays set if they later stop sharing the pet, and is null again 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
    • namestringNullable

      The pet’s name.

    • speciesobjectNullable

      The pet’s species.

      Show child attributesHide child attributes
    • breedobjectNullable

      The pet’s breed.

      Show child attributesHide child attributes
  • messagestringNullable

    Your personal note, included in the email to the client.

  • Key-value pairs you attached, such as your patient ID. See Metadata.

  • How many invite emails Hospet has sent, up to 3. 0 when created with send_email: false.

  • last_emailed_attimestampNullable

    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.

  • completed_attimestampNullable

    When the client submitted the assessment. ISO 8601, in UTC.

  • client_account_created_attimestampNullable

    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.

  • resultsobjectNullable

    null until 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, Discomfort or Nourishment.

      • scoreinteger

        The total of the client’s answers in this category.

      • max_scoreinteger

        The highest possible total for this category.

      • percentageinteger

        score as a percentage of max_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.

ExampleJSON
{
  "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

POST/one_time_assessments

Creates 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

Body parameters

  • ownerobjectRequired

    The pet owner to send the assessment to.

    Show child parametersHide child parameters
    • first_namestringRequired

      Up to 100 characters. Used to greet the client.

    • last_namestringOptional

      Up to 100 characters.

    • emailstringRequired

      Where Hospet sends the invite and the results.

  • petobjectOptional

    What you already know about the pet. The client fills in anything you leave out before they start.

    Show child parametersHide child parameters
    • namestringOptional

      The pet’s name.

    • species_idstringOptional

      A species ID from List species, starting with species_. Or send species_name.

    • breed_idstringOptional

      A breed ID from List breeds, starting with breed_. Must belong to species_id. If you send only breed_id, the species is set from the breed. Or send breed_name.

    • species_namestringOptional

      Instead of species_id: the species as your records name it, such as Dog or Canine. Case and extra spaces are ignored. If it doesn’t match, the client chooses. See Species and breeds.

    • breed_namestringOptional

      Instead of breed_id: the breed as your records name it, such as Labrador Retriever. It’s looked for within the species, and if you send only breed_name, the species is set from the breed. If it doesn’t match, the client chooses.

  • messagestringOptional

    A personal note for the client, included in the email. Up to 1,000 characters.

  • metadatahashOptional

    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.

  • send_emailbooleanOptional

    Set to false to skip the invite email and send the url to 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.

POST/one_time_assessments
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"])
ResponseJSON
{
  "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

GET/one_time_assessments/:id

Gets an assessment, including its results once the client has submitted it.

Path parameters

  • idstringRequired

    The assessment’s ID, starting with ota_.

Returns

The one-time assessment.

GET/one_time_assessments/:id
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"])
ResponseJSON
{
  "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

GET/one_time_assessments

Lists your practice’s assessments, newest first, including ones sent from the dashboard.

Query parameters

  • limitintegerOptional

    How many to return, from 1 to 100.

    Defaults to 10.

  • starting_afterstringOptional

    An assessment ID. Returns the assessments created before it, so pass the last ID of the previous page.

  • statusenumOptional

    Only return assessments with this status.

    • openWaiting for the client.
    • expiredThe link expired before the client submitted it.
    • completedSubmitted by the client.
  • emailstringOptional

    Only return assessments sent to this email address.

Returns

A list of one-time assessments.

GET/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=completed
require "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"])
ResponseJSON
{
  "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

GET/one_time_assessments/:id/pdf

Downloads 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

  • idstringRequired

    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.

GET/one_time_assessments/:id/pdf
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.pdf
require "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)
ResponseHTTP
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="assessment-biscuit-smith-20261021.pdf"
Content-Length: 183422

ClientsLink 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
    • The owner’s first name.

    • last_namestringNullable

      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.

    • speciesobjectNullable

      The pet’s species.

      Show child attributesHide child attributes
    • breedobjectNullable

      The pet’s breed.

      Show child attributesHide child attributes
ExampleJSON
{
  "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

GET/clients/:id

Gets a client and the pets they share with your practice.

Path parameters

  • idstringRequired

    The client’s ID, starting with acct_.

Returns

The client. If they haven’t added your practice to their support team, or have removed it, you get a 404 error with the code resource_missing.

GET/clients/:id
curl 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"])
ResponseJSON
{
  "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

GET/clients

Lists the pet owners who have added your practice to their support team, newest first.

Query parameters

  • limitintegerOptional

    How many to return, from 1 to 100.

    Defaults to 10.

  • starting_afterstringOptional

    A client ID. Returns the clients after it, so pass the last ID of the previous page.

  • emailstringOptional

    Only return the client with this email address.

Returns

A list of clients.

GET/clients
curl -G https://my.hospet.app/api/connect/v1/clients \
  -H "Authorization: Bearer $HOSPET_API_KEY" \
  -d limit=1
require "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"])
ResponseJSON
{
  "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"
          }
        }
      ]
    }
  ]
}

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

A species you can set on a pet.

Attributes

  • idstring

    Unique identifier for the species. Starts with species_. Pass this as pet.species_id.

  • objectstring

    Always species.

  • namestring

    For example Canine or Feline.

ExampleJSON
{
  "id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ",
  "object": "species",
  "name": "Canine"
}

List speciesLink to List species

GET/species

Lists every species you can set on a pet. This list rarely changes, so you can cache it.

Returns

A list of species. Everything is returned at once, so has_more is always false.

GET/species
curl 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"])
ResponseJSON
{
  "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 as pet.breed_id.

  • objectstring

    Always breed.

  • namestring

    For example Beagle or Domestic Shorthair.

  • The ID of the species this breed belongs to.

ExampleJSON
{
  "id": "breed_7Hx2Qm9Kt4Wr1Vb6Nc3Lp8Dz",
  "object": "breed",
  "name": "Beagle",
  "species_id": "species_4Kd9mQ2xR7tV1nB8cL5wP3zJ"
}

List breedsLink to List breeds

GET/species/:species_id/breeds

Lists the breeds for a species.

Path parameters

Returns

A list of breeds. Everything is returned at once, so has_more is always false.

GET/species/:species_id/breeds
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"])
ResponseJSON
{
  "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:

  1. Check the signature, to be sure it came from Hospet.
  2. Respond with a 2xx status within 15 seconds.
  3. 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

Webhook request headers
HeaderValue
Content-Typeapplication/json
User-AgentHospetConnect-Webhooks/1.0
Hospet-SignatureThe signature. See Verifying signatures.
Hospet-Event-IdThe event’s id, so you can ignore duplicates without parsing the body.
Hospet-Event-TypeThe event’s type.
Webhook requestHTTP
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.completed

Endpoints 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…

  1. Split the header on ,, then each part on the first =. t is when the request was signed, in Unix seconds. v1 is a signature, and there may be more than one.
  2. 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.
  3. Compute an HMAC-SHA256 of that string, using your whole signing secret, including whsec_, as the key. Hex-encode the result.
  4. Compare it with each v1 value using a constant-time comparison. Accept the request if any match.
  5. Reject the request if t is more than 5 minutes away from your server’s clock, so an old request can’t be replayed. Retries are signed again with a new t.

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.

Checking the signature
# 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"; fi
require "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) }
end
import { 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)
Receiving events
# 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
end
import 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 "", 200

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

  • The API version used to build data.object.

  • created_attimestamp

    When the event happened. Doesn’t change on retries. ISO 8601, in UTC.

  • livemodeboolean

    false for 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.

    • fileobjectNullable

      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.

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

      • The file, base64 encoded with no line breaks.

Example event (PDF shortened)JSON
{
  "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 type before acting on an event, and respond with 2xx to types you don’t handle, so they aren’t retried.
  • Only read data.file on 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 assessment

    data.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_id and pet_id say which Hospet client and pet it became: store them with the patient record you put in metadata. Sent once per assessment.

    data.object: The one-time assessment

  • A 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. pets lists the pets they share with you, which may be none yet.

    data.object: The client

  • A 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.object with what you have rather than assuming what changed.

    data.object: The client

  • A client removes your practice from their support team. pets is empty, since nothing is shared with you any more, and Retrieve a client returns a 404.

    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.

Saving the PDF
# 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.pdf
require "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:

Retry schedule
AttemptWhen
1Right away
21 minute after attempt 1
35 minutes after attempt 2
430 minutes after attempt 3
52 hours after attempt 4
65 hours after attempt 5
710 hours after attempt 6
824 hours after attempt 7
924 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.

Signed sample to localhostShell
# 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.

API reference