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)

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

  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

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.


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 is name-only: fuzzy match donation_name against $People.all_aliases. OC contributor email is not available — the GraphQL Account type has no email field, and most members donate as guests without OC accounts. all_aliases (not all_aliases_no_PII) is used because OC display names are real names that may not appear in the no-PII field.

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.