There are two ways to hear about changes. The events feed is the record: you ask what changed since your last cursor. Webhooks ring your server when something changes, and each ring is one event from that same feed. If a webhook is ever missed, the feed still has the event.
curl "https://api.buddypad.builtbymo.dev/v1/events" \
-H "Authorization: Bearer $BUDDYPAD_TOKEN"
{
"data": [
{
"id": "1",
"type": "pair.started",
"actor_id": "00000000-0000-4000-8000-00000000a002",
"object_type": "pair",
"object_id": "6d8afa93-4409-4e49-a788-76e4a84a75f5",
"pair_id": "6d8afa93-4409-4e49-a788-76e4a84a75f5",
"occurred_at": "2026-11-02T07:42:58.347663+00:00"
},
{
"id": "3",
"type": "pair.started",
"actor_id": "00000000-0000-4000-8000-00000000a003",
"object_type": "pair",
"object_id": "e6317f9e-5358-4c3d-8352-80fc7f6cb815",
"pair_id": "e6317f9e-5358-4c3d-8352-80fc7f6cb815",
"occurred_at": "2026-11-02T07:42:58.347663+00:00"
}
],
"next_cursor": "e1.WyIxNTQyIiwiNzQiXQ"
}
Keep next_cursor and pass it back as ?after= next time. It's never null, so you can always continue. To start from now and skip the past, use ?after=head.
You get every event once and in order, even when two changes commit at almost the same moment. Events stay for 30 days. If events after your cursor have aged out, you get 410 cursor_expired, so read the current state again and start over from head.
Events are thin: a type, ids, who did it and when. To see the goal or comment, fetch it. You get it only if you can still see it, so a goal your buddy stopped sharing doesn't linger in your feed.
item.created item.updated item.completed item.deleted item.shared item.unshared comment.created reaction.set nudge.received checkin.received checkin.answered suggestion.received suggestion.accepted suggestion.declined pair.started pair.ended
You need a token with the webhooks permission. The URL must be https and reach a public address. You can have up to 10.
curl -X POST https://api.buddypad.builtbymo.dev/v1/webhooks \
-H "Authorization: Bearer $BUDDYPAD_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/buddypad", "event_types": ["item.completed", "nudge.received"]}'
{
"id": "c44090e3-648d-53eb-a7ed-af825c0a14e3",
"url": "https://example.com/buddypad",
"active": true,
"secret": "whsec_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
"created_at": "2026-11-02T07:43:01.052089+00:00",
"updated_at": "2026-11-02T07:43:01.052089+00:00",
"event_types": [
"item.completed",
"nudge.received"
],
"failing_since": null,
"paused_reason": null,
"last_delivered_at": null,
"previous_secret_expires_at": null
}
Save secret now, because it's shown this once. You'll only get events that happen after this call. For anything earlier, read the feed. You can also add and manage webhooks in the app, under Connected tools.
Each delivery is a POST with one event as its JSON body, the same JSON one row of the feed returns, and these headers:
BuddyPad-Event-Id: 41
BuddyPad-Signature: t=1700000000,v1=4a9b8fee2d85...
Answer with any 2xx within 10 seconds. BuddyPad ignores the body of your answer. It doesn't follow redirects, so a 3xx counts as a failure.
The signature is an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your whole whsec_... secret. Check it on the raw bytes before you parse the JSON. Reject anything older than 5 minutes.
// Verify a BuddyPad webhook in Node 18 or later. No dependencies.
// Pass the raw request body, exactly as it arrived, before any JSON parsing.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(secret, signatureHeader, rawBody, { nowSeconds = Date.now() / 1000, toleranceSeconds = 300 } = {}) {
const parts = String(signatureHeader ?? '').split(',').map((part) => part.trim().split('='));
const t = Number(parts.find(([key]) => key === 't')?.[1]);
if (!Number.isInteger(t) || Math.abs(nowSeconds - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
// After a rotation the header carries two v1 values for 24 hours. Any match is good.
return parts.filter(([key]) => key === 'v1').some(([, value]) => {
const given = Buffer.from(value ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
# Verify a BuddyPad webhook in Python 3. Standard library only.
# Pass the raw request body as bytes, exactly as it arrived, before any JSON parsing.
import hashlib
import hmac
import time
def verify_webhook(secret, signature_header, raw_body, now=None, tolerance_seconds=300):
parts = [part.strip().split("=", 1) for part in (signature_header or "").split(",")]
timestamps = [part[1] for part in parts if len(part) == 2 and part[0] == "t"]
if not timestamps or not timestamps[0].isdigit():
return False
t = timestamps[0]
current = time.time() if now is None else now
if abs(current - int(t)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
# After a rotation the header carries two v1 values for 24 hours. Any match is good.
return any(len(part) == 2 and part[0] == "v1" and hmac.compare_digest(part[1], expected) for part in parts)
These are the same two files our test suite runs against the real signer.
Delivery is at least once. A delivery can arrive twice if your answer got lost, so skip any BuddyPad-Event-Id you've already handled.
Deliveries for one webhook go in order, one at a time. A failed event holds back the ones after it while it retries, after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours and 8 hours.
BuddyPad gives up on an event once its next try would land more than 24 hours after the event happened. The log marks it gave_up and the next event goes straight away. The event is still in the feed.
After 3 days of failures with no success the webhook pauses, and the app says so. When your server is back, resume it:
curl -X POST https://api.buddypad.builtbymo.dev/v1/webhooks/$WEBHOOK_ID/resume \
-H "Authorization: Bearer $BUDDYPAD_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
Resuming starts from now. Read the feed to catch up on what you missed.
POST /v1/webhooks/{webhook_id}/test sends a signed event of type webhook.test within about a minute. You can send one a minute. It also works on a paused webhook, which is how you check a fix before resuming. The result shows up in GET /v1/webhooks/{webhook_id}/deliveries.
POST /v1/webhooks/{webhook_id}/rotate-secret gives you a new secret. The old one keeps working for 24 hours, and during that time each delivery carries two v1 values, so your receiver verifies with either. Switch your receiver to the new one within that time.