/donation — Webhook & Command Spec¶
Phase 2. Implemented.
Overview¶
Donations come in via Open Collective. The flow is:
Open Collective webhook → bot ingest → write to Grist (Local_donations)
→ post to Discord channel → organizer links to $People → Grist updated
This is primarily a webhook-driven flow, not a slash command flow. The slash command surface is minimal — just enough for organizers to link and manage records that arrive via webhook.
Local_donations Schema (confirmed)¶
| Field | Type | Notes |
|---|---|---|
donation_id |
Text (UUID) | Auto-generated |
donation_type |
Choice | "open collective one time", "open collective recurring", "cashapp", "waiver" |
donation_name |
Text | Name as submitted with donation |
trans_date |
DateTime | Defaults to NOW() |
transaction_ID |
Text | Per-charge OC transaction legacyId — looked up via GraphQL post-ingest, see below; blank if the lookup fails |
amount |
Numeric | Defaults to 25 |
effective_date |
Date | Computed on ingest — see Effective Date Logic below |
person |
Reference("People") | Null until linked |
notes |
Text | |
refunded |
Bool | |
duration_days_ |
Numeric | Defaults to 365 — drives expires |
expires |
Formula (Date) | DATEADD(effective_date, days=duration_days_) — read-only |
contribution_ID |
Text | OC order/contribution ID (data.order.id) — write target for ingest; constant across every charge of a recurring subscription (does not identify the individual charge) |
donor_email |
Text | Donor's OC account email — only populated when OC_PERSONAL_TOKEN is configured (see GraphQL lookup section below); blank otherwise |
Open Collective Webhook Ingest¶
Webhook Configuration¶
OC fires order.processed on every completed payment — both initial and recurring charges. Configure in OC dashboard (Settings → Webhooks → New Webhook) with activity set to "All" or "Contributions".
Endpoint: POST /webhooks/opencollective/<OC_WEBHOOK_SECRET> on the bot's HTTP server (separate from Discord gateway — bot runs both). Configure OC to POST to the full URL including the token.
OC webhook authentication: OC sends no HMAC secret or signing headers. Auth is via a secret token embedded in the URL path (OC_WEBHOOK_SECRET env var). Requests to the base path or with a wrong token return 404.
Note on OC email notifications: OC also sends email notifications to join@ on each transaction. These are redundant once the webhook is reliable — do not build a parallel email-parsing path. The webhook is the source of truth.
Payload Fields (confirmed via live test)¶
| Field | Path in payload | Notes |
|---|---|---|
| Event type | type |
"order.processed" |
| Contribution ID | data.order.id |
Numeric; constant for recurring — write to contribution_ID |
| Contributor name | data.fromCollective.name |
Display name as entered; use for member matching |
| Amount (cents) | data.order.totalAmount |
Integer cents — divide by 100 for dollar amount |
| Amount (formatted) | data.order.formattedAmount |
e.g. "$1.00" — use for display |
| Transaction date | data.order.createdAt |
ISO 8601 UTC |
| One-time vs recurring | data.order.interval |
null = one-time; "month"/"year" = recurring |
| First recurring charge | data.firstPayment |
true on first charge; false on renewals |
No per-charge transaction ID is present in the payload — data.order.id is the only ID and is constant across all charges for a recurring subscription. No email field is available on the webhook payload itself (see below for where email actually comes from).
Post-ingest GraphQL lookup¶
The webhook payload alone can't give us three things: an amount that excludes any platform tip (totalAmount bakes the tip in with no way to separate it back out), an ID that identifies the individual charge rather than the whole subscription (data.order.id is constant across every renewal), or the donor's email. All were confirmed by capturing real webhook payloads and cross-checking against OC's GraphQL API (https://api.opencollective.com/graphql/v2).
_fetch_oc_order_details in cogs/donation.py queries that API by order.id right after the webhook fires, requesting the order's amount (tip-excluded), its transactions ledger, and fromAccount { ... on Individual { email } } }. Each order/charge has a paired DEBIT/CREDIT entry per component (CONTRIBUTION, ADDED_FUNDS, PLATFORM_TIP, PAYMENT_PROCESSOR_FEE); the CREDIT/CONTRIBUTION entry's legacyId is the actual per-charge money-received transaction, written to transaction_ID. If the lookup fails for any reason, ingest falls back to the webhook's own tip-inclusive totalAmount and leaves transaction_ID blank — it never blocks on this.
Email requires OC_PERSONAL_TOKEN. fromAccount.email resolves to null for unauthenticated requests — this collective's transaction amounts are public, but a contributor's email is not. It only comes back populated for a caller with host-admin permissions on the collective. OC_PERSONAL_TOKEN (optional env var) is a Personal Access Token generated from the chapter's shared OC host-admin account's own "For developers" → Personal Tokens page (not the collective's, which only offers OAuth app registration) — see a Central Committee member for that account's login if you need to rotate the token. Sent as a Personal-Token header (_oc_graphql_headers in cogs/donation.py). Unset means the query still runs, email just comes back null, and email drops out of the matching signals below with no other effect.
If the token stops working, OC returns HTTP 401 with {"error": {...}} (confirmed live — a distinct shape from the normal {"data": ...} response). _fetch_oc_order_details detects this specifically and logs at ERROR, and handle_oc_webhook also posts to #bot-alerts (rate-limited to once an hour so a dead token doesn't spam it on every donation) — both point at the renewal steps below. This fails silently otherwise: donations still ingest fine, they just quietly lose the email-matching signal, so the loud alert matters.
Renewing the OC Personal Token¶
- Log into the chapter's shared OC host-admin account (see a Central Committee member for credentials — not documented here). Not the
puget-sound-sracollective's own settings — Personal Tokens live under an individual/organizational account, not a collective. - Go to that account's For developers page → Personal Tokens section (distinct from OAuth Apps, which is the only thing shown under the collective's own settings).
- Click Create Personal Token. Give it a name (e.g. "pssbot"), leave scopes at whatever lets it read account/transaction data (no elevated/2FA-gated scopes needed — pssbot only reads
fromAccount.email), and no expiration unless you want to force periodic rotation. - Copy the generated token — OC only shows it once.
- Update
OC_PERSONAL_TOKENin both~/pssbot-prod/.envand~/pssbot-test/.envon the droplet (scpa local.env.prod/.env.testover, or edit in place), then restart the relevant systemd service(s) so the bot picks it up. - Optional sanity check before restarting the live bot: run
_fetch_oc_order_detailsagainst any known order id with the new token set locally and confirmtoken_invalidcomes backFalseandemailis populated.
Non-contribution orders are skipped entirely. OC's order.processed webhook also fires for "Added Funds" entries — used e.g. to record the national org's dues share passthrough to the local collective, not an actual donation. These have no CREDIT/CONTRIBUTION transaction in the GraphQL lookup (only ADDED_FUNDS). If the lookup succeeds but finds no CONTRIBUTION-kind transaction, ingest logs and returns without creating a Local_donations row. This only applies when the lookup itself succeeded — a lookup failure still falls back to recording the webhook's own values, since kind can't be determined in that case.
Ingest Flow¶
- Parse payload → extract name, amount, date, interval, contribution ID
- Query OC's GraphQL API for the tip-excluded amount and per-charge transaction ID (see above); fall back to the webhook's own values if that lookup fails. If the lookup succeeds but finds no
CONTRIBUTION-kind transaction (e.g. an "Added Funds" passthrough), skip — noLocal_donationsrow is created. - Attempt member match:
- Fuzzy match
donation_nameagainst$People.all_aliases_no_PII - High-confidence single match → auto-link and note it
- Ambiguous or no match → leave unlinked
- Create new
Local_donationsrow: donation_name— fromdata.fromCollective.nametrans_date— fromdata.order.createdAtcontribution_ID— fromdata.order.idamount— tip-excluded amount from the GraphQL lookup, ordata.order.totalAmount÷ 100 if that lookup failedtransaction_ID— per-charge transaction legacyId from the GraphQL lookup, if availableeffective_date— computed per Effective Date Logic below; leave null if member unmatcheddonation_type—data.order.interval == null→ "open collective one time"; otherwise → "open collective recurring"person— linked$Peoplerow id if matched, null if notrefunded— False- Post notification to configurable donations channel:
💰 New donation received — Open Collective
Name: "Alice Smith"
Amount: $50
Type: One-time
Date: Jun 14 2025
Member match: ✅ @alice / ⚠️ unmatched
/donation link [id] @member — link to a member
/donation info [id] — view details
Split for multiple members¶
If the amount is an exact multiple (2–4×) of the standard $25 yearly Local Comrade dues, the notification adds an optional "Split for N members" button — e.g. a $50 donation covering someone's own dues plus a spouse's, entered as one payment. It's always just a suggestion: a generous single donor whose amount happens to land on a multiple is exactly as valid, and the normal confirm/link/search buttons stay available alongside it.
Clicking it opens a UserSelect requiring exactly N members. On submit (_do_split in cogs/donation.py): the original row is repointed to the first selected person at amount / N, and one new Local_donations row is created per remaining person, each copying the shared OC details (contribution_ID, transaction_ID, donor_email, trans_date, donation_type) off the original and getting its own effective_date computed independently (per Effective Date Logic below, which depends on that specific person's dues state).
/donation link [id] @member¶
Signature¶
/donation link [donation_id] @member
Access¶
Representative+
Purpose¶
Links an unmatched (or incorrectly matched) donation to a $People row.
Parameters¶
donation_id— short identifier from the Discord notification (bot maintains a cache of recent donation row ids, or operator can look up via/donation list)@member— Discord mention, resolved to$People
Flow¶
- Fetch
Local_donationsrow by id - If already linked: confirm override —
⚠️ This donation is linked to @carol. Relink to @alice? [Yes] [Cancel] - On confirm: PATCH
Local_donations.person→ new$Peoplerow id - Ephemeral confirmation + update the original Discord notification message to show resolved state
/donation list [optional:unmatched]¶
Signature¶
/donation list
/donation list unmatched
Access¶
Representative+
Purpose¶
Lists recent donations. unmatched filter shows only rows where person is null.
Response¶
Ephemeral embed, most recent first:
💰 Recent donations
#1 Jun 14 Alice Smith $50 one-time ✅ @alice
#2 Jun 12 "bob jones" $25 recurring ⚠️ unmatched
#3 Jun 10 Carol Williams $100 one-time ✅ @carol
/donation link 2 @member to link unmatched entries
/donation info [id]¶
Signature¶
/donation info [id]
Access¶
Representative+
Purpose¶
Returns full detail on a single donation record including linked member's dues status.
Response¶
💰 Donation #2 — detail
Name submitted: "bob jones"
Amount: $25
Type: recurring
Transaction date: Jun 12 2025
Effective date: Jun 12 2025
Refunded: No
Linked member: ⚠️ unmatched
Run /donation link 2 @member to link.
If linked:
Linked member: @bob
Bob's dues expiration: Jun 12 2026
LC dues current: ✅ Yes
Pulls dues_expiration and LC_dues_current from $People — pre-computed, no bot logic needed.
/donation history @member¶
Signature¶
/donation history @member
Access¶
Central Committee
Purpose¶
Lists every donation linked to a member — their full giving history in one place, most recent first — with a running total net of refunds.
Response¶
💰 Donation history — @bob
#7 Aug 1 2026 bob jones $25 recurring
#2 Jun 12 2025 bob jones $25 recurring ⚠️ refunded
2 donation(s) · $25 total (net of refunds)
Effective Date Logic¶
effective_date is not simply the transaction date. The bot applies this rule on every OC webhook ingest where the member is matched:
- If the donation is early (before
$People.dues_expiration):effective_date = $People.last_LC_effective_date + 365 daysExample: last donated 9/15/24, new donation arrives 9/1/25 → effective_date = 9/15/25 - If the donation is on or after
dues_expiration:effective_date = trans_dateExample: last donated 8/25/24, new donation arrives 9/1/25 → effective_date = 9/1/25
Purpose: members who renew slightly early don't get penalized — their next due date extends from the previous one, not from the early payment date.
If the member cannot be matched at ingest time, leave effective_date null. The organizer sets it manually after linking via /donation link.
Builder Notes¶
- The bot needs to run an HTTP server alongside the Discord gateway for webhook receipt. Use
aiohttpor FastAPI — both are compatible withdiscord.py's async event loop. - OC sends no signing headers. Auth is a secret token in the URL path; set
OC_WEBHOOK_SECRETand configure OC to POST to the full URL. - Donation IDs in Discord notifications: use the Grist row id (integer) as the short identifier — simple and stable.
/donationcommands should be run from#records-workstream— this channel serves as the operational home for secretary/treasurer commands. Notifications post there by default.effective_datevstrans_date: See Effective Date Logic section — the bot must compute this correctly on ingest. Do not default to trans_date.- Member matching checks four signals in priority order, each still surfaced as a suggestion rather than an auto-link, and each labeled with how it matched in the Discord notification (e.g. "matched member email exactly", "matched discord username (fuzzy)") so an organizer confirming the suggestion knows how much to trust it: (1) exact
contribution_IDmatch against a past linked donation — the same recurring subscription continuing; (2) exact match of the donor's OC account email (only available withOC_PERSONAL_TOKEN— see above) against$People.last_LC_email(checked first — a formula column mirroringlast_LC_name/last_LC_type, the donor email off the person's most recent linked donation, so it wins over a coincidental match on a general contact field) thenpreferred_email_address/initial_email_address/newsletter_email; (3) exact (case-insensitive) match ofdonation_nameagainst a past linked donation'sdonation_name; (4) fuzzy matchdonation_nameagainst$People.all_aliases, with the winning alias classified after the fact asdiscord_username,discord_display, or a generic alias for the reason label. The actual matched email address itself is never included in the Discord notification — only that email was the signal used.all_aliases(notall_aliases_no_PII) is used for the fuzzy pass because OC display names are real names that may not appear in the no-PII field. - OC's placeholder names for donors who didn't type one ("Guest", "Incognito" —
_GENERIC_DONOR_NAMES) never drive signals (3) or (4): a past donation named "Guest" means nothing about which Guest. Identity signals (1) and (2) are unaffected since they aren't fooled by a shared display name. - When a high-confidence identity signal (contribution ID or either email tier) resolves to more than one person (≤4), the notification shows exactly who and adds a one-click confirm button per candidate, instead of a bare "ambiguous match" — but only when the amount doesn't also look like a multi-person split (see below), since confirming a single candidate there would wrongly link the whole amount to just them.
- When the amount is a suspected split (see below) and the ambiguous email/contribution signal resolves to exactly that many people, the split member picker is prefilled with them (
UserSelect.default_values) rather than left blank — still just a prefill, the organizer confirms or changes it.
Open Questions¶
- Refund handling — should there be a
/donation refund [id]command to setrefunded=True? Or is that Grist-direct? - Cashapp donations — these don't go through OC. Is there a manual
/donation addentry flow needed, or is Grist-direct sufficient for cashapp? - Recurring renewal deduplication —
data.order.idis constant across all charges for a recurring subscription;data.firstPaymentisfalseon renewals. Confirm whether renewal charges should create newLocal_donationsrows (expected) or update the existing row.
Note on OC OAuth: evaluated and ruled out — most members donate as OC guests without accounts, so OAuth self-linking would cover a negligible fraction of donations.