Skip to main content

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.

PyPI Python Licence

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

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:00 is null. .NET's DateTime.MinValue, used instead of null for absent dates. parse_api_datetime returns None.
  • 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". Check Period.has_known_free_slots before trusting the number.
  • Two clock formats. Appointments use THHMM, session periods use bare HHMM.
  • A session date is never a start time. Its time component is always midnight; the appointment's own time field supplies the clock. Appointment.starts_at combines them.
  • Two procedure-code vocabularies. An appointment's platelet code is PLT; the donor's own registered procedureCode for the same thing is PL1. Never compare one to the other.
  • Donation history is truncated. hasFurtherDonations tells you so. The list length is a lower bound; AccountDetails.donation_credit is the authoritative lifetime count.
  • Messages arrive for every blood group. Filter with MessageBundle.for_blood_group().
  • Donation type codes 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 in model_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:

  1. On PyPI, go to Your projects → Publishing (or, before the first release, Account settings → Publishing → Add a pending publisher).

  2. Enter exactly:

    Field Value
    PyPI project name nhs-give-blood
    Owner KRoperUK
    Repository name give-blood-py
    Workflow name release-please.yml
    Environment 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

nhs_give_blood-0.1.0.tar.gz (37.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nhs_give_blood-0.1.0-py3-none-any.whl (37.5 kB view details)

Uploaded Python 3

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

Hashes for nhs_give_blood-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ce8a340545393c7ed9a7bfbfaa40728de4209b5b4314c02bb4163e9cecc812da
MD5 9a3f42214ebe50d438ebe89eb85b6ef0
BLAKE2b-256 5c9df5b3dd54fcdc94dc249cb840fced53001af9a3e87cf66e0d97bfc44847c0

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for nhs_give_blood-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b8ccd2ebf79814e611eafe12b502383098fd05ccbd06f53e36d1c9ca995dc5a4
MD5 e5b29cf34310dfe29a9e35e79765559b
BLAKE2b-256 d2637d955d512a20b72d8bef8ab34ab65a079a929294c695668eeea55f160034

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page