/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) |
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.
Post-ingest GraphQL lookup¶
The webhook payload alone can't give us two things: an amount that excludes any platform tip (totalAmount bakes the tip in with no way to separate it back out), or an ID that identifies the individual charge rather than the whole subscription (data.order.id is constant across every renewal). Both were confirmed by capturing real webhook payloads and cross-checking against OC's public GraphQL API (https://api.opencollective.com/graphql/v2, no API key needed — this collective's own transaction data is public).
_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) and its transactions ledger. 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.
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
/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.
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 is name-only: fuzzy match
donation_nameagainst$People.all_aliases. OC contributor email is not available — the GraphQLAccounttype has no email field, and most members donate as guests without OC accounts.all_aliases(notall_aliases_no_PII) is used because OC display names are real names that may not appear in the no-PII field.
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.