Skip to content

/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

  1. Log into the chapter's shared OC host-admin account (see a Central Committee member for credentials — not documented here). Not the puget-sound-sra collective's own settings — Personal Tokens live under an individual/organizational account, not a collective.
  2. 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).
  3. 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.
  4. Copy the generated token — OC only shows it once.
  5. Update OC_PERSONAL_TOKEN in both ~/pssbot-prod/.env and ~/pssbot-test/.env on the droplet (scp a local .env.prod/.env.test over, or edit in place), then restart the relevant systemd service(s) so the bot picks it up.
  6. Optional sanity check before restarting the live bot: run _fetch_oc_order_details against any known order id with the new token set locally and confirm token_invalid comes back False and email is 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

  1. Parse payload → extract name, amount, date, interval, contribution ID
  2. 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 — no Local_donations row is created.
  3. Attempt member match:
  4. Fuzzy match donation_name against $People.all_aliases_no_PII
  5. High-confidence single match → auto-link and note it
  6. Ambiguous or no match → leave unlinked
  7. Create new Local_donations row:
  8. donation_name — from data.fromCollective.name
  9. trans_date — from data.order.createdAt
  10. contribution_ID — from data.order.id
  11. amount — tip-excluded amount from the GraphQL lookup, or data.order.totalAmount ÷ 100 if that lookup failed
  12. transaction_ID — per-charge transaction legacyId from the GraphQL lookup, if available
  13. effective_date — computed per Effective Date Logic below; leave null if member unmatched
  14. donation_typedata.order.interval == null → "open collective one time"; otherwise → "open collective recurring"
  15. person — linked $People row id if matched, null if not
  16. refunded — False
  17. 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).


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

  1. Fetch Local_donations row by id
  2. If already linked: confirm override — ⚠️ This donation is linked to @carol. Relink to @alice? [Yes] [Cancel]
  3. On confirm: PATCH Local_donations.person → new $People row id
  4. 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 days Example: 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_date Example: 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 aiohttp or FastAPI — both are compatible with discord.py's async event loop.
  • OC sends no signing headers. Auth is a secret token in the URL path; set OC_WEBHOOK_SECRET and 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.
  • /donation commands should be run from #records-workstream — this channel serves as the operational home for secretary/treasurer commands. Notifications post there by default.
  • effective_date vs trans_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_ID match against a past linked donation — the same recurring subscription continuing; (2) exact match of the donor's OC account email (only available with OC_PERSONAL_TOKEN — see above) against $People.last_LC_email (checked first — a formula column mirroring last_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) then preferred_email_address / initial_email_address / newsletter_email; (3) exact (case-insensitive) match of donation_name against a past linked donation's donation_name; (4) fuzzy match donation_name against $People.all_aliases, with the winning alias classified after the fact as discord_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 (not all_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

  1. Refund handling — should there be a /donation refund [id] command to set refunded=True? Or is that Grist-direct?
  2. Cashapp donations — these don't go through OC. Is there a manual /donation add entry flow needed, or is Grist-direct sufficient for cashapp?
  3. Recurring renewal deduplicationdata.order.id is constant across all charges for a recurring subscription; data.firstPayment is false on renewals. Confirm whether renewal charges should create new Local_donations rows (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.