Skip to main content

API Reference

Generated from the controllers and Form Requests under backend/app/Http/*/Api/V1/. Use php artisan openapi:generate to produce the machine-readable OpenAPI 3 JSON at storage/app/api-docs/openapi.json, served from /docs/openapi.json.

Base URL: /api/v1 Content type: application/json Auth: Authorization: Bearer <sanctum-token> (where applicable)

Conventions

  • Lists are paginated Laravel-style: { data, links, meta }.
  • Single resources are wrapped: { data: { … } }.
  • Errors follow { message, errors }422 for validation, 404 for missing, 403 for tenant issues.

System

GET /system/health

Health check. Public.

Response

{
"service": "vetty-api",
"status": "ok",
"env": "local",
"clients": ["clinic_saas_web", "pet_owner_mobile", "tenant_scoped_api"]
}

Auth

POST /auth/login

Body

FieldTypeRules
emailstringrequired, email
passwordstringrequired, min:8

ResponseUserResource with eager-loaded current_tenant and tenants.

POST /auth/owner/register

Body

FieldTypeRules
namestringrequired
emailstringrequired, unique:users
passwordstringrequired, min:8, confirmed
phonestringnullable
tenant_idintegerrequired, exists:tenants,id

Response

{
"data": {
"user": { /* UserResource */ },
"client_id": 42
}
}

Tenants

POST /tenants

Provisions a new clinic and its admin user.

Body

FieldTypeRules
namestringrequired
slugstringrequired, unique:tenants
emailstringrequired, unique:tenants
phonestringnullable
addressstringnullable
subscription_planenumoptional, one of trial,starter,growth,premium,enterprise
owner_namestringrequired
owner_emailstringrequired, unique:users
passwordstringrequired, min:8

Response

{
"data": {
"tenant": { /* TenantResource */ },
"user": { /* UserResource */ }
}
}

Clinic (tenant-scoped)

All endpoints in this block are prefixed with /tenant/{tenant} and wrapped in ResolveTenantMembership.

GET /tenant/{tenant}/dashboard

Returns { tenant: {…}, stats: { pets_count, clients_count, appointments_count, pending_invoices_count } }.

GET /tenant/{tenant}/clients

Paginated list of ClientResource (50 per page).

GET /tenant/{tenant}/pets

Paginated list of PetResource (20 per page), with client preloaded.

POST /tenant/{tenant}/pets

Body: see StorePetRequest in Requests & Resources. Response: 201 PetResource with loaded client.

POST /tenant/{tenant}/patients/onboard

Same as POST /pets above — alias endpoint used by the owner-portal onboarding flow.

PATCH /tenant/{tenant}/patients/{pet}

Partial update of a pet. All fields optional; rules follow UpdatePatientDetailsRequest.

GET /tenant/{tenant}/appointments

Paginated (20 per page). AppointmentResource with pet, client, veterinarian eager-loaded.

POST /tenant/{tenant}/appointments

Body: StoreAppointmentRequest. 201 with AppointmentResource.

PATCH /tenant/{tenant}/appointments/{appointment}

Body

FieldTypeRules
statusenumrequired, one of scheduled,confirmed,checked_in,completed,cancelled,no_show

Only the status is patched.

GET /tenant/{tenant}/invoices

Paginated (20 per page). Each InvoiceResource has pet, client, payments preloaded.

POST /tenant/{tenant}/invoices

Body: StoreInvoiceRequest. If payment_amount is included, a Payment is created and the invoice status + balance_due are updated.

POST /tenant/{tenant}/invoices/{invoice}/payments

Body

FieldTypeRules
amountnumberrequired, > 0
payment_methodenumrequired, one of cash,card,upi,bank_transfer,online
reference_numberstringnullable

Creates the Payment, subtracts from invoice.balance_due, flips invoice.status to partial or paid.

Owner portal

All /owner/me/* endpoints scope on the authenticated user — the bearer token identifies the owner; no tenant routing is required.

GET /owner/me/pets

Returns paginated PetResource for pets whose client row has owner_user_id = $request->user()->id.

POST /owner/me/pets

Create a pet under the owner's clinic (the first Client row linking the owner to a tenant).

Body (see StoreOwnerPetRequest)

FieldTypeRules
namestringrequired
speciesstringrequired
breedstringnullable
sexenumnullable, male|female|unknown
date_of_birthdatenullable
colorstringnullable
microchip_idstringnullable

Response — 201 PetResource.

PATCH /owner/me/pets/{pet}

Partial update. The backend verifies the pet belongs to the authenticated owner. Fields identical to the store request; all optional.

GET /owner/me/pets/{pet}/vaccinations

List vaccination records for the owned pet. Returns VaccinationResource collection (not paginated — usually small).

POST /owner/me/pets/{pet}/vaccinations

Body

FieldTypeRules
namestringrequired
administered_atdaterequired
next_due_atdatenullable
notesstringnullable
lot_numberstringnullable

Response — 201 VaccinationResource.

GET /owner/me/appointments

Returns paginated AppointmentResource where the pet's client belongs to the owner.

GET /owner/me/invoices

Returns paginated InvoiceResource filtered by owner.

POST /owner/me/invoices/{invoice}/razorpay/order

Create a Razorpay order for an unpaid invoice the owner owns. The vetty app passes the returned payload straight into the Razorpay Flutter checkout SDK.

Response

{
"data": {
"invoice_id": 42,
"key": "rzp_test_XXXXXXXXXXXXXX",
"order_id": "order_Nabc123def456",
"amount": 125000,
"currency": "INR",
"name": "Paws & Claws Veterinary",
"description": "Invoice INV-2026-00042",
"prefill": { "name": "Asha Rao", "email": "asha@example.com", "contact": "+919999999999" }
}
}

amount is in the smallest currency unit (paise for INR). Returns 409 if the invoice is already paid / voided / an estimate, 503 if Razorpay keys aren't configured, and 404 if the authenticated user doesn't own the invoice.

POST /owner/me/invoices/{invoice}/razorpay/verify

Called by the vetty app after the Razorpay SDK reports success. Re-checks the HMAC server-side, records a captured Payment row, and recomputes balance_due / status from the ledger.

Body

FieldTypeRules
razorpay_order_idstringrequired — the order id from the previous call
razorpay_payment_idstringrequired — returned by the Razorpay SDK on success
razorpay_signaturestringrequired — the HMAC the SDK returns

Returns 422 on signature mismatch, 200 with the updated InvoiceResource on success. Idempotent: the unique index on provider_payment_id means a webhook that raced the client-verify won't double-bill.

POST /webhooks/razorpay

Public (no auth) webhook receiver. Verifies the X-Razorpay-Signature header against RAZORPAY_WEBHOOK_SECRET and handles payment.captured / payment.failed. Returns 500 on handler errors so Razorpay retries.

GET /owner/me/shares

List active share codes the owner has generated.

POST /owner/me/shares

Create a new share code for one of the owner's pets.

Body

FieldTypeRules
pet_idintegerrequired, must belong to the owner
notestringnullable (free-text reminder to the owner)
expires_in_minutesintegernullable, 5–1440; defaults to the app's configured TTL

Response

{
"data": {
"id": 17,
"code": "A1B2C3D4",
"pet_id": 5,
"expires_at": "2026-04-19T15:30:00Z",
"note": "Handoff to Paws & Claws"
}
}

POST /owner/me/shares/{share}/revoke

Marks the share as revoked so vetdoctor can no longer redeem it. 200 with updated PetShareResource.

Providers directory

GET /providers

Public search endpoint used by vetty's Nearby tab to discover clinics, groomers, breeders and trainers.

Query

FieldTypeNotes
qstringFree-text match on tenant name / description
business_typeenumclinic|groomer|breeder|trainer|all
latitudefloatRequires longitude + radius_km
longitudefloatRequires latitude + radius_km
radius_kmfloatkm radius for the haversine filter

Response: paginated TenantResource with each tenant's public profile fields.

GET /providers/{tenant}

Public profile for a single provider — the resolved TenantResource. Used by the vetty app when the user taps a result.

Shares (cross-app)

POST /shares/redeem

Called by vetdoctor after scanning a QR. Validates the share code and reparents the pet onto the scanning clinic's tenant (a new Client row linking the owner to the target tenant is created if none exists).

Body (see RedeemPetShareRequest)

FieldTypeRules
codestringrequired, 8 chars, must be active and unexpired
tenant_idintegerrequired, must be the scanning clinic's tenant
user_idintegerrequired, the scanning clinic user

Response — 200 { data: { pet: PetResource, client_id, message } }.

GET /shares/{code}

Peek at a share code without redeeming it (used by the scan flow to preview before confirming). Returns the target pet + owner name but does not mutate anything.

Health journal

Lightweight pet-observation log. Owners write weights, appetite / energy / stool grades, symptom tags, and notes. Clinicians read the same log during consultation (clinic-sourced entries are distinguishable via source: 'clinic').

GET /owner/me/pets/{pet}/health-logs

Lists entries newest first. Optional ?since=YYYY-MM-DD filters to entries logged on or after that date. Returns a paginated PetHealthLogResource collection.

POST /owner/me/pets/{pet}/health-logs

Body (see StorePetHealthLogRequest). At least one of weight_kg, appetite, energy_level, stool_quality, water_intake_ml_per_kg, symptoms, or notes must be present.

FieldTypeRules
logged_atdatetimeoptional, defaults to now()
weight_kgdecimaloptional, ≤ 9999.99
appetiteenumnormal|reduced|none|increased
energy_levelenumnormal|low|lethargic|hyperactive
stool_qualityenumnormal|soft|diarrhea|constipated|bloody
water_intake_ml_per_kgintoptional, 0–500
symptomsstring[]e.g. ['Coughing', 'Limping']
notesstringfree text, ≤ 2000 chars

When the new weight_kg is the freshest reading, the controller bubbles it onto pets.weight_kg so dashboards and passport PDFs pick it up without a join.

DELETE /owner/me/pets/{pet}/health-logs/{healthLog}

Soft-constraint: only the author can delete (logged_by_user_id must match $request->user()). 204 on success, 404 if the entry belongs to another writer.

Clinic-side

GET /tenant/{tenant}/patients/{pet}/health-logs (read) and POST /tenant/{tenant}/patients/{pet}/health-logs (write) mirror the owner routes, gated on medical_record.read / .create permissions. The controller auto-detects source from the path ('clinic' when the request path contains /tenant/).

Lost-pet public tag

POST /owner/me/pets/{pet}/public-profile

Primary-owner-only. Enables the public profile (minting an 8-char slug on first call) and stores the share-able fields.

FieldTypeRules
microchip_idstringoptional, ≤ 40
owner_contact_phonestringoptional, ≤ 40
reward_notesstringoptional, ≤ 1000

Response{ data: { slug, enabled, public_url, microchip_id, owner_contact_phone, reward_notes, lost_mode, lost_since } }.

DELETE /owner/me/pets/{pet}/public-profile

Disables the tag (slug is retained for later re-enable). Also clears lost_mode_enabled_at.

POST /owner/me/pets/{pet}/public-profile/lost

Marks the pet as actively missing. Auto-enables the tag if it wasn't already. Response includes the public_url to share.

DELETE /owner/me/pets/{pet}/public-profile/lost

Marks the pet as found. Sets lost_mode_enabled_at = null.

GET /p/{slug} (public, HTML)

Public-facing landing page. Mobile-first layout, pet photo, species/breed, microchip ID (if opted in), reward notes, 📞 Call owner button.

GET /api/v1/p/{slug} (public, JSON)

Same data as the HTML view but as JSON — for clients that want to render their own UI on top of a scan.

Pet documents

Per-pet file locker (receipts, lab reports, X-rays, insurance, prescriptions, other). Files live on the private local disk; downloads go through short-lived signed URLs.

GET /owner/me/pets/{pet}/documents

Optional ?category= filter on receipt|lab_report|xray|insurance|prescription|passport|other. Paginated PetDocumentResource collection — each row carries a fresh signed download_url that is valid for 5 minutes from the time of the response.

POST /owner/me/pets/{pet}/documents (multipart)

Upload a file. Primary owner or full-access caregiver only.

FieldTypeRules
filefilerequired, ≤ 10MB, mimes: pdf/jpg/jpeg/png/heic/webp/doc/docx
categoryenumsee above; default other
titlestringoptional, ≤ 180
document_datedateoptional
notesstringoptional, ≤ 2000

GET /owner/me/pets/{pet}/documents/{document}

Returns a single PetDocumentResource.

GET /owner/me/pets/{pet}/documents/{document}/download?signature=...

Serves the raw file. Gated on Laravel's signed middleware — the signature alone authorises the download, so this link is shareable (briefly) without a bearer token. Controller re-verifies that the document belongs to the supplied pet.

DELETE /owner/me/pets/{pet}/documents/{document}

Removes both the database row and the underlying file. Primary owner or full-access caregiver.

GET /owner/me/pets/{pet}/passport.pdf

Generates a pet passport PDF on demand (identification band + owner + vaccination history, formatted for airlines / boarding kennels). No caching — always reflects the latest vaccination list. Returns application/pdf.

Caregivers

Primary owners can invite family or sitters as caregivers on a pet record. Invitees receive an email with a token; once redeemed, they can read (or read+write, depending on access_level) the pet without sharing the owner's credentials.

GET /owner/me/pets/{pet}/caregivers

Primary-owner-only. Returns all caregiver rows (active, pending, expired, revoked) with status_label and access_level.

POST /owner/me/pets/{pet}/caregivers

FieldTypeRules
invited_emailstringrequired, email, not the owner's own email
invited_namestringoptional, ≤ 180
access_levelenumrequired, view|full
notestringoptional, shown in the invite email

Dispatches SendCaregiverInviteEmailJob via the notification driver. Returns a PetCaregiverResource with the newly minted invite.

POST /owner/me/pets/{pet}/caregivers/{caregiver}/revoke

Primary-owner-only. Sets revoked_at = now(). 200 on success.

POST /caregivers/accept (auth:sanctum)

Invitee endpoint. Body: { "token": "<48-char token>" }. Returns 404 on bad token, 410 on revoked / expired, 409 on already-accepted. On success, binds caregiver_user_id to the authenticated user and returns { pet: PetResource, access_level }.

GET /owner/me/caregivers/shared-with-me

Invitee-side view — lists pets that have been shared with the authenticated user (accepted + not revoked). Powers the "Shared with me" section in the vetty Pets tab.

Marketplace order requests

Owners ping a pharmacy or retail tenant with a refill, a reorder, or a custom item request. The seller accepts and fulfils it outside the app (pickup or delivery); there is no in-app cart or payment — the seller drops a regular invoice via the normal billing flow when the order is ready, and the customer pays via Razorpay as with any other invoice.

GET /owner/me/orders

Lists every order the signed-in owner (or their caregiver) has placed. Optional ?status=pending|accepted|fulfilled|rejected|cancelled and ?pet_id=123 filters. Paginated (30 per page), newest first.

GET /owner/me/pets/{pet}/prescriptions

All prescriptions issued against the pet across tenants. Used by the vetty UI to render the refill picker.

GET /owner/me/pets/{pet}/prescriptions/{prescription}/refill-draft

Returns a pre-composed { kind: 'refill', source_prescription_id, item_summary, notes, pet_id } derived from the prescription's line items — the client merges in target_tenant_id, delivery choice, and submits.

POST /owner/me/pets/{pet}/orders

FieldTypeRules
target_tenant_idintrequired, must belong to a pharmacy or retail tenant
kindenumrequired, refill | reorder | custom
source_prescription_idintrequired when kind=refill; must belong to this pet
item_summarystringrequired for reorder/custom, optional for refill (derived)
delivery_preferenceenumrequired, pickup | delivery
delivery_addressstringrequired when delivery_preference=delivery
notesstringoptional, max 2000

Creates the order with status pending and dispatches SendOrderRequestEmailJob to the seller's contact email. Returns an OrderRequestResource.

GET /owner/me/orders/{order}

Fetches one order with pet, target_tenant, source_prescription.items, and assignee loaded. Only the requesting user can read their own orders (404 otherwise).

POST /owner/me/orders/{order}/cancel

Owner bails out before fulfilment. 422 if the order is already fulfilled / rejected / cancelled; otherwise flips status to cancelled and stamps cancelled_at.

GET /api/v1/tenant/{tenant}/orders (seller)

Inbox for pharmacy / retail tenants. Gated on order.read. Default filter is the open queue (status in (pending, accepted)); ?status=all returns history, ?status=<specific> pins to one.

POST /api/v1/tenant/{tenant}/orders/{order}/accept (seller)

Pending → accepted. Gated on order.manage. Stamps accepted_at and records the handling user as assigned_to_user_id.

POST /api/v1/tenant/{tenant}/orders/{order}/reject (seller)

Pending → rejected. Gated on order.manage. Body: { "reason": "..." } (optional). Reason is shown to the owner in the vetty order detail.

POST /api/v1/tenant/{tenant}/orders/{order}/fulfill (seller)

Accepted → fulfilled. Gated on order.manage. Terminal — the owner received the items; further changes (refunds, returns) happen via the regular invoice surface, not this row.

Permissions added to PermissionRegistry:

  • order.read — View incoming order requests
  • order.manage — Accept, reject, or fulfil incoming order requests

Seeded on the tenant_admin (via *), pharmacy, and retail_counter starter roles. A clinic-only tenant has no roles with these permissions by default — reasonable since clinics don't fulfil pharmacy orders.

Error shape

All endpoints return errors in a consistent shape:

{
"message": "The given data was invalid.",
"errors": {
"email": ["The email must be a valid email address."]
}
}

See app/OpenApi/OpenApiSpec.phpErrorResponse schema.

Swagger / OpenAPI

Generate + serve:

php artisan openapi:generate
# outputs storage/app/api-docs/openapi.json

# exposed at
GET /docs/openapi.json

Load the JSON into any Swagger / Redoc viewer for an interactive tryout.