Webhooks
Requires the deliver_webhooks command to be run on a schedule.
Webhooks tell another application when something changes in Baby Buddy. Each endpoint is a URL that receives one request per change, and the receiving application decides what to do with it.
What an endpoint receives
The body names the change and carries nothing about its contents:
{
"created": "2026-09-23T18:04:11.512341+00:00",
"id": "6f0d1f1e-2d67-4a1e-9a1e-3f9d0c0f0a11",
"object_id": "42",
"type": "feeding.created"
}
type is the record kind and what happened to it, so feeding.created,
feeding.updated and feeding.deleted are all distinct events. object_id
identifies the record. Anything that wants the details reads them back through
the API with its own credentials.
That is all it is told. An endpoint is a URL that somebody configured once and then left alone, and it is not given the name of a child, how much was fed or where anyone was. It does see which record kinds changed and when they changed, which is enough to tell that a household is awake -- so an endpoint is still a place that knows something about a family, and it is worth treating as one.
Checking that a request came from Baby Buddy
Every request carries four headers:
| Header | Contents |
|---|---|
X-BabyBuddy-Event-Id |
The same value as id in the body, for the sake of a receiver that has not parsed it yet |
X-BabyBuddy-Event-Type |
The same value as type in the body |
X-BabyBuddy-Timestamp |
The request time, in whole seconds since the epoch |
X-BabyBuddy-Signature |
v1= followed by an HMAC-SHA256 hex digest |
The signature covers the timestamp and the body, joined with a period. It does
not cover the headers, so read id and type out of the body -- that copy is
the one the signature vouches for.
import hashlib
import hmac
received = request.headers["X-BabyBuddy-Signature"].removeprefix("v1=")
timestamp = request.headers["X-BabyBuddy-Timestamp"]
body = request.body # exactly as it arrived; re-encoding parsed JSON
# changes the bytes, and the digest with them
expected = hmac.new(
secret.encode(),
timestamp.encode() + b"." + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received): # not ==, so guessing does not
raise PermissionError("signature mismatch") # get easier as it closes in
Three things make that check worth something, and two of them are on this side of the connection:
- Check the timestamp against the current time and refuse anything too far off -- a few minutes is usual. Signing the timestamp stops someone moving it to dress an old body up as a fresh one. It does not stop them sending a captured request again exactly as it stands: the digest is the same and it verifies forever.
- Remember the event ids you have already acted on, and ignore a repeat.
Two attempts carry the same
id, so this also covers a retry. - Compare with
compare_digestas above.
Without the first two, a captured request is one that can be used again.
Redirects are not followed. A signature would otherwise end up at whatever host the endpoint points at next.
Sending changes
Events are queued as records change and are sent by a command:
python manage.py deliver_webhooks
Run it from cron or a systemd timer, and each run sends what is due and exits.
For anything that should hear about a change within seconds, and for a Docker install, which has no cron to add to, the command can keep running instead:
python manage.py deliver_webhooks --every 1
That checks the queue once a second from one process. Checking an empty queue is one small query, so a second costs next to nothing; starting the command anew from a loop every second would start Django every second. In Docker it runs from a second container that shares the configuration and database with the app, with this as its command. A run that fails — the database briefly unreachable, or not migrated yet while the app starts — is reported and the next one goes ahead, and only a run that sent something is written to the log.
Use https for anything on another
machine: over plain http the secret and the body travel in the clear, and the
signature then proves nothing about where they have been. Anything that is not delivered is tried
again on the next run, waiting twice as long as the previous attempt: one, two,
four and eight minutes. After five attempts the event is left alone and stays
in the database as a record of what happened. An event that is delivered is
removed, so what remains is only what is still waiting or has been given up on.
One endpoint that is down costs only its own events: they are the ones that fail, and everything else still goes out in the same run. What a slow endpoint costs is time, since the run works through the list in order and waits up to ten seconds for each.
Configuring an endpoint
Endpoints are added under the webhooks section of /admin/. Each has a name, a
URL and a secret; the secret is generated when the endpoint is created and can
be replaced at any time. Deactivating an endpoint stops it being told anything, including events
that were still queued when it went off; those wait until it is turned back on.
From another application
An application can also set up its own endpoint through the API,
at /api/webhook-endpoints/, with the same permissions as the admin area: a
user who can add webhook endpoints there can add them here, and caregivers and
read-only users can do neither.
curl -X POST https://baby.example.com/api/webhook-endpoints/ \
-H "Authorization: Token <api key>" \
-H "Content-Type: application/json" \
-d '{"name": "My phone", "url": "https://receiver.example.com/hook/123"}'
The secret is the one field that is never read back. Send secret along if the
receiving application issued one — it has to be at least 16 characters — or
leave it out and Baby Buddy generates it and returns it in the response to that
request, the only time it appears. Replacing it later is a PATCH with a new
secret; switching the endpoint off is a PATCH with "active": false.
What is announced
Changes to children and to the entries that can be recorded for them: BMI, diaper changes, feedings, head circumference, height, medication, notes, pumping, sleep, temperature, timers, tummy time and weight. Percentiles are left out because they are computed rather than written, so announcing them would send an event for every measurement that merely moved a curve.
There is one place where nothing is announced: QuerySet.update() writes rows
without calling save(), so no signal fires. It is used in migrations and tests
only. A bulk write elsewhere would go unannounced.
An endpoint receives every one of these. type is enough to keep what you
want and drop the rest at the receiving end, so there is no filter to configure
here and no way to end up with an endpoint that is quietly not being told about
something you expected it to hear.
What this does not do
Webhooks are a notification, not a delivery guarantee. Two runs of the command
at the same time can send the same change twice, and the same change goes out
again on every attempt that comes back with a failure. X-BabyBuddy-Event-Id
is what tells a repeat from a second change, so keep the ones you have seen if
you cannot afford to act on one twice. Baby Buddy sends one
request per change and gives up after five attempts, and it does not keep a
history of what an endpoint was told. An endpoint that was switched off and on
again has missed whatever happened in between, and the API is what tells it
where things stand now.