Skip to content

/donation — Webhook & Command Spec

Not yet implemented. This page is a spec for a planned feature.

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 OC transaction ID — write target for ingest
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 Formula (Text) Stub returning "" — do not write to this, use transaction_ID

Open Collective Webhook Ingest

Webhook Configuration

Open Collective supports outbound webhooks on transaction events. Configure in OC dashboard to POST to bot's ingest endpoint on: - transaction.created — new donation received

Endpoint: POST /webhooks/opencollective on the bot's HTTP server (separate from Discord gateway — bot runs both).

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

Open Collective webhook payload includes at minimum: - Transaction amount - Transaction date - Contributor name (as entered in OC) - Contributor email (if available) - Transaction type (one-time vs recurring) - Transaction ID

Ingest Flow

  1. Validate webhook signature (OC provides a secret for HMAC verification — store in DO secrets)
  2. Parse payload → extract name, email, amount, date, type
  3. Attempt member match:
  4. Query $People where initial_email_address or preferred_email_address matches OC email (case-insensitive)
  5. If no email match: fuzzy match donation_name against $People.all_aliases_no_PII
  6. High-confidence single match → auto-link and note it
  7. Ambiguous or no match → leave unlinked
  8. Create new Local_donations row:
  9. donation_name — from OC contributor name
  10. trans_date — from OC transaction date
  11. effective_date — computed per Effective Date Logic below; leave null if member unmatched
  12. donation_type — map from OC transaction type: one-time → "open collective one time", recurring → "open collective recurring"
  13. person — linked $People row id if matched, null if not
  14. refunded — False
  15. 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 webhook secret stored in DO environment variables, same as Grist and Discord tokens.
  • 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.
  • Email matching uses initial_email_address and preferred_email_address — both are PII fields. Confirm whether the no-PII Grist user can read these for matching purposes, or whether email matching must be done by a higher-privilege process.

Open Questions

  1. Email field access — can the no-PII bot user read preferred_email_address and initial_email_address for donation matching? If not, email matching falls back to name-only fuzzy match, which is less reliable.
  2. OC recurring donation events — does OC fire a webhook on each recurring charge, or only on initial signup? If only on signup, monthly charges won't be auto-logged. Confirm with OC documentation.
  3. Refund handling — should there be a /donation refund [id] command to set refunded=True? Or is that Grist-direct?
  4. Cashapp donations — these don't go through OC. Is there a manual /donation add entry flow needed, or is Grist-direct sufficient for cashapp?