# ADOPTR API for rescues (v1)

Read ADOPTR's upcoming departures and your own animals from your own app, hold seats, and turn held seats into
animals with their papers. Build on the sandbox, then go live.

This file is the guide at https://adoptr.uk/developers, and as Markdown at https://adoptr.uk/developers.md. The
same API as OpenAPI 3.1: https://adoptr.uk/api/v1/openapi.json.

## Start

1. **Get a test key.** Sign in as your rescue at https://adoptr.uk/rescue/api and make one. Not working with ADOPTR
   yet? Email contact@adoptr.uk.
2. **Make your first call.** List the sandbox's departures with the request here.
3. **Pass the go-live checklist, then go live.** Twelve calls your app makes on the sandbox. When all are ticked,
   ask for a live key on the same page.

```sh
curl https://sandbox.adoptr.uk/api/v1/departures \
  -H "Authorization: Bearer $ADOPTR_API_KEY"
```

```js
const res = await fetch("https://sandbox.adoptr.uk/api/v1/departures", {
  headers: { Authorization: `Bearer ${process.env.ADOPTR_API_KEY}` },
});
const { departures } = await res.json();
```

```json 200 OK
{
  "departures": [
    { "leaves_on": "2026-12-01", "status": "open", "seats_free": 18, "deadlines": { "closes_on": "2026-11-24" } }
  ]
}
```

The whole answer is under Departures.

## Build with an AI agent

Using Claude, Cursor or another coding agent? Give it the instructions for agents
(https://adoptr.uk/developers/agent.md, or "Copy instructions" on the guide). They tell it what to read, how to keep
the key safe and how to pass the go-live checklist on the sandbox. It stops before going live: a person at your
rescue asks for the live key.

- `https://adoptr.uk/developers.md`: this guide as Markdown.
- `https://adoptr.uk/llms.txt`: the index for agents.
- `https://adoptr.uk/api/v1/openapi.json`: OpenAPI 3.1, for Postman or a generated client.

## How a booking goes

1. **See the dates.** `GET /departures`: deadlines and seats free.
2. **Hold seats.** `POST /holds`, before you know the animals.
3. **Add each animal.** `POST /holds/{id}/animals`: one seat becomes one animal.
4. **ADOPTR takes it from there.** Papers, the CHED, the route and the family's tracking link.

## Keys

Every call sends your key: `Authorization: Bearer <key>`. A key belongs to your rescue: every answer holds only your
animals and your seats, never another rescue's.

- **Test keys** (`adk_test_…`) work only on the sandbox. Make them yourself at https://adoptr.uk/rescue/api.
- **Live keys** (`adk_live_…`) work only on adoptr.uk. Ask for one on the same page once your app passes the
  go-live checklist; ADOPTR approves it and says what it may do. It is shown once, when you open it.
- Every key reads. A key holds and gives back seats only with `hold`, and adds animals only with `confirm`: animals
  go on held seats, so a key with `confirm` has `hold` too.
- **Keep the key on your server**, in an environment variable such as `ADOPTR_API_KEY`. Anyone holding it can book
  seats for your rescue. The API doesn't answer browsers, so it can't live in a web page or a phone app. If a key
  leaks, revoke it on the same page and make another.

## Sandbox

`https://sandbox.adoptr.uk/api/v1`, with a test key. Made-up departures, put back as new every night at 03:00 UTC.
Nothing there reaches a real family, van or border.

- Four departures: a **full** one (`seats_free` 0), one with the dogs' **Brucella window** open today, an **empty**
  one weeks away, and a **closed** one (past `closes_on`).
- Three animals of your own, there as soon as you make your first test key: one ready, one without a passport, one
  on the waiting list. Their microchips start 999.
- Hold seats on the **empty** departure: the others are full, closed, or already too late for an animal sent
  without its rabies vaccine.

## Departures

`GET /departures`: the departures listed on adoptr.uk that haven't left, and any still on the road with your animals,
soonest first. A departure is named by the day it leaves Romania (`leaves_on`).

- `status`: `open`; `closed` (past `closes_on`, no animal can join); `on_the_road`; `done`; `not_listed` (not on
  adoptr.uk, shown because your animals are on it).
- `confirmed`: enough animals booked for the van to go on this day; until then the day is an estimate.
- `deadlines.closes_on`: the last day an animal can join. `rabies_by`: the last day a rabies vaccine counts.
  `brucella_window_from`/`_to`: when to take dogs' Brucella samples; `last_brucella_sample`: the last day a sample
  is accepted.
- `seats_free`: seats anyone can still ask for, counted as adoptr.uk counts its places for the UK: the departure's
  UK places less the animals going to the UK, the seats held and the places kept for rescues; never more than the
  van's empty crates (animals going to the EU take crates too).
- `yours`: your animals on it, your seats held (each hold with its `id` and `expires_at`), and places ADOPTR keeps for
  you not filled yet.

```json 200 OK
{
  "departures": [
    {
      "leaves_on": "2026-12-01",
      "uk_entry": "2026-12-03",
      "last_drop_off": "2026-12-04",
      "status": "open",
      "confirmed": false,
      "deadlines": {
        "closes_on": "2026-11-24",
        "rabies_by": "2026-11-10",
        "brucella_window_from": "2026-11-09",
        "brucella_window_to": "2026-11-13",
        "last_brucella_sample": "2026-11-13"
      },
      "zones": [],
      "seats_free": 18,
      "yours": { "animals": 2, "seats_held": 0, "holds": [], "places_kept": 0 }
    }
  ]
}
```

### One departure

`GET /departures/{leaves_on}`: the same fields in `departure`, and `animals`: yours on it.

```sh
curl https://sandbox.adoptr.uk/api/v1/departures/2026-12-01 \
  -H "Authorization: Bearer $ADOPTR_API_KEY"
```

## Your animals

`GET /animals`: your animals on departures still ahead or on the road, and on the waiting list (`leaves_on` null),
by id. Filters: `leaves_on=YYYY-MM-DD`, `papers=ready|missing|blocked`. Pages: `limit` (1 to 200, 100 by default),
then `after=<next_after>` while `next_after` isn't null.

`GET /animals/{id}`: one of yours, in `animal`.

- `papers`: `ready`, `missing` (still to come, `to_do` says what) or `blocked` (can't make this departure); `null`,
  with `to_do` empty, for an animal on the waiting list.
- The adopter's address, phone and email are never sent back, only their name and postcode.

```sh
curl "https://sandbox.adoptr.uk/api/v1/animals?papers=missing&limit=50" \
  -H "Authorization: Bearer $ADOPTR_API_KEY"
```

```json 200 OK
{
  "animals": [
    {
      "id": 2,
      "name": "Max",
      "species": "dog",
      "sex": "male",
      "chip": "999000000100001",
      "born_on": "2025-07-01",
      "leaves_on": "2026-12-01",
      "destination": "GB",
      "papers": "missing",
      "to_do": [{ "item": "passport", "state": "missing", "note": "needed" }],
      "passport": null,
      "rabies": { "on": "2026-07-16", "valid_until": "2027-07-16" },
      "brucella": { "sampled_on": "2026-11-10", "result": "negative" },
      "adopter": { "name": "Priya Desai", "postcode": "BS8 4PP", "address_confirmed": true },
      "ched_ref": null,
      "apha": null
    }
  ],
  "next_after": null
}
```

## Seats

Your key does these only when ADOPTR allowed them: `hold` for holding and giving back seats, `confirm` for adding
animals. Send an `Idempotency-Key` with each (see Retries).

### Hold seats

`POST /holds`: hold seats on a departure that is `open`, before its animals are known.

- `leaves_on` (required): the day the van leaves Romania.
- `seats` (required): 1 to 99, within `seats_free` and your rescue's seat limit: the seats your app holds at once on
  one departure (5 unless ADOPTR set another). A seat that becomes an animal no longer counts, so you can hold again.
- `expires_at`: when the seats go back, an ISO time with its offset. 7 days if left out; at most 14 days, never
  after the end of the departure's `closes_on` (midnight, London time), at least an hour ahead. In the last hour of
  `closes_on` the departure takes no new hold (`departure_closed`).
- `note`: for you and the ADOPTR team, up to 500 characters.

```sh
curl -X POST https://sandbox.adoptr.uk/api/v1/holds \
  -H "Authorization: Bearer $ADOPTR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"leaves_on":"2026-12-28","seats":3,"note":"for the litter"}'
```

```js
const res = await fetch("https://sandbox.adoptr.uk/api/v1/holds", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ADOPTR_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({ leaves_on: "2026-12-28", seats: 3, note: "for the litter" }),
});
```

```json 201 Created
{
  "hold": { "id": 41, "leaves_on": "2026-12-28", "seats": 3, "expires_at": "2026-12-04T18:00:00.000Z" },
  "seats_free": 15
}
```

### Your holds

`GET /holds`: your holds still counting, as `{ "holds": [ { "id", "leaves_on", "seats", "expires_at" } ] }`.

### Give seats back

`DELETE /holds/{id}`: gives back every seat of a hold. Animals already added stay.

```json 200 OK
{ "released": { "id": 41, "seats": 2 } }
```

### Add an animal

`POST /holds/{id}/animals`: one seat of the hold becomes this animal, going to the UK. A hold of several seats keeps
the rest; the answer's `hold` is its id, or null once its last seat is taken.

- Required: `name`, `species` (`dog` or `cat`), `chip` (15 digits; spaces are dropped). Everything else can follow
  later through ADOPTR; `to_do` in the answer says what's still missing.
- Days are `YYYY-MM-DD`. `born_on`, `microchipped_on`, `rabies_on` and `brucella_sampled_on` can't be in the
  future. A field the API doesn't know is refused.
- Brucella (`brucella_sampled_on`, `brucella_tube`, `brucella_result`): once the sample is taken, in the
  departure's Brucella window. Leave them out until then.
- Refused with `papers_blocked` (and `to_do`) when the animal already can't make this departure, for example a
  rabies vaccine too recent: the seat is kept.
- ADOPTR asks the adopter to confirm their address before the CHED, as for every animal.

```json
{
  "name": "Pufi",
  "species": "dog",
  "sex": "male",
  "chip": "999000000777001",
  "born_on": "2025-03-12",
  "microchipped_on": "2025-05-01",
  "passport": "RO-9777001",
  "rabies_on": "2026-07-01",
  "rabies_vaccine": "Rabisin",
  "rabies_batch": "A123",
  "rabies_valid_until": "2027-07-01",
  "adopter": "Charlotte Evans",
  "adopter_address": "4 Bramble Way, Taunton",
  "adopter_postcode": "TA1 1AA",
  "adopter_phone": "+44 7700 900777",
  "adopter_email": "charlotte@example.com"
}
```

```json 201 Created
{
  "animal": {
    "id": 57,
    "name": "Pufi",
    "papers": "missing",
    "to_do": [{ "item": "brucella", "state": "missing", "note": "sample by 11 Dec" }]
  },
  "hold": 41
}
```

The `animal` is the whole animal, as `GET /animals/{id}` gives it (shortened here). Once its last seat is an
animal, the hold is gone: `GET /holds` no longer lists it, and its id answers `not_found`.

## Retries

Send an `Idempotency-Key` header with every `POST` and `DELETE`: 1 to 100 printable characters, no spaces, yours to
choose (a UUID, for example). Sent again with the same key and the same body, byte for byte, within a day, the API
answers as the first time and does nothing twice, a refusal such as `not_enough_seats` included. A call refused
before it starts (a body that isn't valid, a bad key, too large) isn't kept: put it right and send it with the same
key. Use a new key for a new action, and the same key when you retry one.

- The same key with another body: `idempotency_conflict`.
- While the first call is still running: `idempotency_running`. Send it again after `Retry-After` seconds.

## Errors and limits

Errors are `{ "error": { "code", "message" } }`. Branch on `code`; the `message` is for people and can change.
Some errors say more: `seats_free` (with `not_enough_seats`), `limit` and `held` (with `over_seat_limit`),
`to_do` (with `papers_blocked`).

| Status | `code` | When |
| --- | --- | --- |
| 400 | `bad_request` | A day, a filter or a field isn't valid |
| 401 | `unauthorized` | No key, or a key that isn't valid or was revoked |
| 401 | `wrong_environment` | A test key on adoptr.uk, or a live key on the sandbox |
| 403 | `forbidden` | The key can't do this |
| 404 | `not_found` | Not there, or not yours |
| 409 | `departure_closed` | Not open: not listed, left, or past `closes_on` |
| 409 | `not_enough_seats` | Fewer seats free than asked (`seats_free` says how many) |
| 409 | `over_seat_limit` | Over your seats held at once on one departure (`limit`, `held`) |
| 409 | `hold_expired` | The hold ended (`expires_at` passed), or its last seat was taken meanwhile |
| 409 | `chip_taken` | The microchip is already registered with ADOPTR |
| 409 | `idempotency_running` | The first call with this `Idempotency-Key` is still running: wait `Retry-After` seconds |
| 413 | `too_large` | The body is over 16 KB |
| 422 | `papers_blocked` | The animal can't make this departure (`to_do` says why) |
| 422 | `idempotency_conflict` | The key was used for another request |
| 429 | `rate_limited` | Over the limits: wait `Retry-After` seconds |

Per key: 60 reads and 20 writes a minute, 5,000 calls a day.

ADOPTR keeps your calls 30 days, to help when something goes wrong: what was asked and any error, without the
adopter's address, phone and email.

## Go-live checklist

Your app makes these twelve calls on the sandbox, with your test keys. Each ticks itself at
https://adoptr.uk/rescue/api when the sandbox sees it; the ticks stay when the sandbox is reset, and start again once
a live key is approved, so each app passes with its own calls. A key that only
reads needs the first four; one that holds seats, the first nine; one that adds animals, all twelve. Hold on the
empty departure. Your app should also show your people what each answer means, not only make the call.

1. `GET /departures` answers 200.
2. `GET /departures/{leaves_on}` answers 200.
3. `GET /animals?limit=1`, then again with `after` set to `next_after`.
4. `GET /animals/{id}` answers 200.
5. `POST /holds` on the empty departure answers 201, sent with an `Idempotency-Key`.
6. The same `POST /holds`, sent again with the same key and body, gets the same answer.
7. `POST /holds` on the full departure (`seats_free` 0) answers 409 `not_enough_seats`.
8. `POST /holds` on the closed departure answers 409 `departure_closed`.
9. `DELETE /holds/{id}` answers 200. Then hold 3 seats again, for the next three.
10. `POST /holds/{id}/animals` with only `name`, `species` and a new `chip` answers 201.
11. `POST /holds/{id}/animals` with `rabies_on` before `microchipped_on` answers 422 `papers_blocked`.
12. `POST /holds/{id}/animals` with the chip of one of your sandbox animals answers 409 `chip_taken`.

## Versions

New fields can appear in v1 at any time: ignore those you don't know. A change that would break your app comes as
`/api/v2`, and v1 keeps working at least 6 months after it.
