nhs-give-blood
Async Python client for the NHS Give Blood (NHSBT) donor API — the private API behind the official NHS Give Blood mobile app.
What does this do?
It logs in as a blood donor and reads their account: eligibility dates, blood group, donation credits, awards, upcoming appointments, donation history, and venue/session availability. It can also book, reschedule and cancel appointments.
It powers give-blood-hass, a Home Assistant integration — documented at give-blood-hass.kroper.uk.
Unofficial. NHSBT publishes no contract for these endpoints, does not support this client, and may
change or break it at any time. Endpoints and payloads here were recovered from the public Android
app (com.savant.mobile.nhs.nhsgiveblood 4.9.1) — see docs/api-reference.md
for the derivation, including how to reproduce the analysis.
Contents
- Install
- Quick start
- Usage guide
- API reference
- Data quirks worth knowing
- Booking, and why it needs care
- CLI
- Troubleshooting
- Development
Install
pip install nhs-give-blood
From source:
git clone https://github.com/KRoperUK/give-blood-py
cd give-blood-py
poetry install
Requires Python 3.12+.
Quick start
import asyncio
import aiohttp
from nhs_give_blood import GiveBloodClient
async def main() -> None:
async with aiohttp.ClientSession() as session:
client = GiveBloodClient(session, username="you@example.com", password="…")
snapshot = await client.async_get_snapshot()
account = snapshot.account
print(f"{account.blood_group}, {account.donation_credit} credits")
print(f"Eligible from {account.can_donate_from:%d %b %Y}")
if appointment := snapshot.next_appointment:
venue = appointment.venue.display_name if appointment.venue else "?"
print(f"Next: {appointment.starts_at:%a %d %b %H:%M} at {venue}")
asyncio.run(main())
Usage guide
The session is yours
GiveBloodClient never creates or closes an aiohttp.ClientSession. Pass one in and own its
lifetime. In Home Assistant, pass async_get_clientsession(hass).
Authentication
The API issues a ~30-minute RS256 JWT access token plus an opaque refresh token. It sends no
expires_in; expiry is read from the token's own exp claim.
You can construct the client three ways:
# Credentials only — logs in on first use.
GiveBloodClient(session, username=..., password=...)
# Stored tokens only — no password held in memory, but dies when the refresh token does.
GiveBloodClient(session, token_bundle=TokenBundle.from_mapping(saved))
# Both — the resilient option for long-running processes.
GiveBloodClient(session, username=..., password=..., token_bundle=...)
With both, the auth ladder is: reuse a fresh token → refresh → password login. NHSBT rotates refresh tokens, so a stale stored one is routine rather than fatal; the fallback is what keeps an unattended process alive across it.
Persist rotated tokens with the on_token_update callback (sync or async):
def save(bundle: TokenBundle) -> None:
Path("tokens.json").write_text(json.dumps(bundle.as_dict()))
client = GiveBloodClient(session, username=..., password=..., on_token_update=save)
TokenBundle.as_dict() / .from_mapping() are the storage contract — those field names are stable
public API.
Reading
One aggregate read for everything a dashboard needs:
snapshot = await client.async_get_snapshot()
async_get_snapshot() fetches the account payload (mandatory) and then, concurrently, appointments,
messages, feature flags, the failover banner and donation history. Supplementary failures degrade
rather than propagate — a flaky feature-flag endpoint leaves snapshot.features is None and adds
"features" to snapshot.degraded. Check snapshot.partial to tell a clean read from a degraded one.
Or call endpoints individually:
account = await client.async_get_account_details()
appointments = await client.async_get_future_appointments()
history = await client.async_get_donation_history()
awards = await client.async_get_awards()
messages = await client.async_get_messages()
features = await client.async_get_feature_flags(account.blood_group)
failover = await client.async_get_failover()
Finding somewhere to donate
response = await client.async_search_venues("SW1A 1AA", procedure_code="WB")
for result in response.results:
print(result.venue.display_name, result.venue_distance, result.date_of_next_session)
sessions = await client.async_get_sessions_at_venue("ABCD1", procedure_code="WB")
slots = await client.async_get_session_slots(
sessions[0].session_id,
session_date=sessions[0].session_date,
start_time=sessions[0].periods[0].start_time,
end_time=sessions[0].periods[0].end_time,
)
A 200 response with a non-empty error_code is a normal "no results, here's why" answer — check it
before treating an empty results list as an outage.
API reference
GiveBloodClient
| Method | Returns | Notes |
|---|---|---|
async_get_snapshot(include_donations=True) |
DonorSnapshot |
Aggregate read; degrades gracefully |
async_get_account_details() |
AccountDetails |
The widest payload in the API |
async_get_future_appointments() |
list[Appointment] |
|
async_get_donation_history() |
DonationHistory |
Truncated by the API; see has_further_donations |
async_get_awards() |
AwardsData |
|
async_get_messages() |
MessageBundle |
Returns all blood groups; filter client-side |
async_get_feature_flags(blood_group=None) |
FeatureFlags |
Varies by client version |
async_get_failover() |
FailoverBanner |
is_active means booking is down |
async_get_version_check(platform, version) |
VersionCheck |
|
async_search_venues(search_criteria, …) |
VenueSearchResponse |
Postcode or place name |
async_get_sessions_at_venue(venue_id, …) |
list[Session] |
|
async_get_session_slots(session_id, …) |
SessionSlots |
|
async_validate_token() |
bool |
False on rejection; raises on unreachable |
async_login() |
AccountDetails | None |
Login already embeds the account |
async_logout() |
None |
|
async_book_appointment(…) |
dict |
Real-world write |
async_reschedule_appointment(…) |
dict |
Real-world write, atomic |
async_cancel_appointment(appointment_id) |
None |
Real-world write, irreversible |
Exceptions
All inherit GiveBloodError.
| Exception | When | Consumer should |
|---|---|---|
GiveBloodConnectionError |
Host unreachable, socket died, timeout | Retry later |
GiveBloodApiError |
Unexpected non-2xx | Inspect .status |
GiveBloodRateLimitError |
HTTP 429 | Back off; honour .retry_after |
GiveBloodAuthError |
Auth failed | Branch on .reauth_required / .transient |
GiveBloodInvalidCredentialsError |
Username/password rejected | Prompt for new credentials |
GiveBloodTokenExpiredError |
Token dead and unrefreshable | Prompt for new credentials |
GiveBloodBookingError |
Booking refused | Show .validation_errors |
Branch on the booleans, not the class:
except GiveBloodAuthError as err:
if err.transient:
raise UpdateFailed("Auth service down, will retry") from err
raise ConfigEntryAuthFailed("Credentials no longer valid") from err
Data quirks worth knowing
These bite anyone reading the raw API. The models normalise all of them.
0001-01-01T00:00:00is null. .NET'sDateTime.MinValue, used instead ofnullfor absent dates.parse_api_datetimereturnsNone.- Datetimes are naive but not UTC. They are venue-local wall-clock time. Every datetime this
library returns is timezone-aware in
Europe/London. freeSlots: "-1"is not zero. It is a string, and -1 means "not disclosed". CheckPeriod.has_known_free_slotsbefore trusting the number.- Two clock formats. Appointments use
THHMM, session periods use bareHHMM. - A session date is never a start time. Its time component is always midnight; the appointment's
own
timefield supplies the clock.Appointment.starts_atcombines them. - Two procedure-code vocabularies. An appointment's platelet code is
PLT; the donor's own registeredprocedureCodefor the same thing isPL1. Never compare one to the other. - Donation history is truncated.
hasFurtherDonationstells you so. The list length is a lower bound;AccountDetails.donation_creditis the authoritative lifetime count. - Messages arrive for every blood group. Filter with
MessageBundle.for_blood_group(). - Donation
typecodes are undocumented. A/B/L/R observed. NHSBT publishes no mapping, so the code is passed through verbatim rather than guessed at. - Unknown fields are kept, not rejected. Models use
extra="allow", so a new API field cannot break parsing; it lands inmodel_extra.
Booking, and why it needs care
async_book_appointment, async_reschedule_appointment and async_cancel_appointment change a real
appointment at a real NHS clinic. A cancelled slot is released immediately and may be taken by someone
else; a wasted slot is a wasted donation.
Two deliberate design choices follow from that:
- Writes are never retried after an ambiguous failure (timeout, 5xx). A replayed booking POST could create a duplicate appointment, which is worse than a reported failure.
- Rescheduling is one atomic call, not cancel-then-book, which can lose the slot in between.
Check async_get_failover().is_active before a write: when NHSBT has the booking system down, writes
fail.
CLI
export NHS_GIVE_BLOOD_EMAIL="you@example.com"
export NHS_GIVE_BLOOD_PASSWORD="…"
give-blood whoami # account, credits, eligibility
give-blood appointments # upcoming
give-blood history # past donations
give-blood snapshot # everything, as JSON
give-blood venues "SW1A 1AA"
give-blood sessions ABCD1
Write commands refuse to run without --yes:
give-blood cancel APPT123 --yes
Prefer environment variables over --password: shell history is not a secret store. A local .env is
read automatically when python-dotenv is installed.
Troubleshooting
GiveBloodInvalidCredentialsError on login. Check the credentials in the app first. NHSBT locks
accounts after repeated failures, and this client deliberately does not retry logins, so a lockout
here means something else is retrying.
GiveBloodTokenExpiredError in a long-running process. The stored refresh token was rejected and
there were no credentials to fall back on. Construct the client with both.
Auth failures that come and go. Check err.transient. A 5xx or 429 from the auth service is
transient; treating it as a credential problem will produce spurious reauth prompts.
Empty venue search results. Read response.error_code — the API answers "no sessions in range"
with HTTP 200 and an error code, not an empty list alone.
Bookings failing with a validation error. Read err.validation_errors. The commonest causes are a
startTime/endTime pair the API did not advertise for that session, and a clashing existing
appointment (SessionSlots.clashing_appointments).
Everything failing at once. Check async_get_failover(). NHSBT takes the booking system down for
maintenance and the app shows a banner rather than an error.
Development
poetry install
poetry run pytest
poetry run ruff check . && poetry run ruff format --check .
poetry run mypy
pre-commit install
Tests never touch the network: aresponses mocks the API and every fixture under tests/fixtures/ is
a sanitised capture containing only synthetic identities.
If you re-capture from a live account, run it through the sanitiser before it goes anywhere near git:
python scripts/sanitise_capture.py /tmp/capture.json tests/fixtures/thing.json
tests/test_fixture_hygiene.py, scripts/check_pii.py (pre-commit), bandit and detect-secrets all
guard this, but review the diff yourself too. Live responses from this API are saturated with donor
PII.
Licence
MIT. Not affiliated with, endorsed by, or supported by NHS Blood and Transplant.
Releasing
Releases are automated. A Conventional Commit on main makes
release-please open a release pull request; merging it
tags the version, writes the changelog, and publishes to PyPI via
trusted publishing — no API token is stored in this
repository.
One-time PyPI setup, if this is ever re-established from scratch:
-
On PyPI, go to Your projects → Publishing (or, before the first release, Account settings → Publishing → Add a pending publisher).
-
Enter exactly:
Field Value PyPI project name nhs-give-bloodOwner KRoperUKRepository name give-blood-pyWorkflow name release-please.ymlEnvironment name pypi
The pypi GitHub environment restricts deployments to main and v* tags, so a fork or a feature
branch cannot publish.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nhs_give_blood-0.1.0.tar.gz.
File metadata
- Download URL: nhs_give_blood-0.1.0.tar.gz
- Upload date:
- Size: 37.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ce8a340545393c7ed9a7bfbfaa40728de4209b5b4314c02bb4163e9cecc812da
|
|
| MD5 |
9a3f42214ebe50d438ebe89eb85b6ef0
|
|
| BLAKE2b-256 |
5c9df5b3dd54fcdc94dc249cb840fced53001af9a3e87cf66e0d97bfc44847c0
|
Provenance
The following attestation bundles were made for nhs_give_blood-0.1.0.tar.gz:
Publisher:
release-please.yml on KRoperUK/give-blood-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nhs_give_blood-0.1.0.tar.gz -
Subject digest:
ce8a340545393c7ed9a7bfbfaa40728de4209b5b4314c02bb4163e9cecc812da - Sigstore transparency entry: 2851444735
- Sigstore integration time:
-
Permalink:
KRoperUK/give-blood-py@e697c7f483a5c557e226f3c2d8955605b08a93cf -
Branch / Tag:
refs/heads/main - Owner: https://github.com/KRoperUK
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@e697c7f483a5c557e226f3c2d8955605b08a93cf -
Trigger Event:
push
-
Statement type:
File details
Details for the file nhs_give_blood-0.1.0-py3-none-any.whl.
File metadata
- Download URL: nhs_give_blood-0.1.0-py3-none-any.whl
- Upload date:
- Size: 37.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b8ccd2ebf79814e611eafe12b502383098fd05ccbd06f53e36d1c9ca995dc5a4
|
|
| MD5 |
e5b29cf34310dfe29a9e35e79765559b
|
|
| BLAKE2b-256 |
d2637d955d512a20b72d8bef8ab34ab65a079a929294c695668eeea55f160034
|
Provenance
The following attestation bundles were made for nhs_give_blood-0.1.0-py3-none-any.whl:
Publisher:
release-please.yml on KRoperUK/give-blood-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nhs_give_blood-0.1.0-py3-none-any.whl -
Subject digest:
b8ccd2ebf79814e611eafe12b502383098fd05ccbd06f53e36d1c9ca995dc5a4 - Sigstore transparency entry: 2851444788
- Sigstore integration time:
-
Permalink:
KRoperUK/give-blood-py@e697c7f483a5c557e226f3c2d8955605b08a93cf -
Branch / Tag:
refs/heads/main - Owner: https://github.com/KRoperUK
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@e697c7f483a5c557e226f3c2d8955605b08a93cf -
Trigger Event:
push
-
Statement type: