Skip to main content

Foundation Stripe webhooks

GreekManage's Foundation module is mostly Stripe-driven. When a donor's card is charged, the platform does not poll Stripe to find out — it waits for Stripe to send a webhook event. The webhook is what flips donations from Pending to Completed, fires receipt generation, updates donor lifetime totals, and ticks campaign progress bars. Understanding the webhook path is the difference between a clean Stripe integration and ghost donations stuck in Pending.

What a webhook is, briefly

A webhook is an HTTP POST that Stripe sends to your foundation's webhook URL whenever something happens — a payment succeeded, a subscription was cancelled, a charge was refunded, and so on. The URL is per-foundation: every foundation in your platform has its own webhook endpoint, identified by the foundation's UUID. Stripe signs each delivery so the platform can verify it really came from Stripe.

If your webhook is configured correctly, you do not have to think about any of this; everything just works. If it is broken — wrong secret, wrong URL, unreachable platform — your donations will charge successfully on Stripe's side but stay Pending in GreekManage.

Configuration

You configure webhooks in two places: Stripe's dashboard, and the Foundation Settings page.

Stripe dashboard side

In your Stripe dashboard, add a webhook endpoint that points at your GreekManage platform's foundation webhook URL. The URL takes this shape:

https://your-deployment.example.com/api/foundation/stripe-webhook/{foundation_id}/

The foundation_id is your foundation's UUID — your platform admin can give you the exact URL.

Subscribe the endpoint to these events (at minimum):

  • payment_intent.succeeded
  • payment_intent.payment_failed
  • charge.refunded
  • invoice.payment_succeeded
  • invoice.payment_failed
  • customer.subscription.deleted

Stripe will show you a signing secret (starts with whsec_). Copy it.

GreekManage side

On Foundation → Settings → Payment Processor, paste the signing secret into the Webhook Signing Secret field and save. The secret is encrypted at rest and used to verify every incoming webhook signature.

If the signing secret you paste here does not match what Stripe has on file for that endpoint, every webhook delivery will be rejected with a 400, donations will sit in Pending, and your campaign progress bars will not move.

Idempotency ledger

Every webhook that arrives — verified or not — is recorded in an idempotency ledger before it is processed. Each row in the ledger carries:

  • The Stripe event ID
  • The Stripe event type
  • The foundation it belongs to
  • The full event payload (for diagnostics)
  • A status: Received, Processed, or Failed
  • An error message, if processing failed

The ledger does two things:

  1. Prevents double-processing. If Stripe redelivers the same event (which it does aggressively on initial 4xx/5xx responses, and again on manual replay from the dashboard), the second delivery sees that the event ID is already in the ledger and returns immediately without re-running any business logic. This means it is safe for Stripe to retry — donor lifetime totals will not be double-counted, receipts will not be re-emailed, fund progress will not double-tick.
  2. Provides an audit trail. If a donation seems stuck in Pending, the ledger is the first place to look — was the payment_intent.succeeded event ever received? Did it process? If it failed, what was the error?

There is no admin UI for the ledger today; it is accessible via the database or the Django admin. Your platform admin can pull it for you.

The six events the platform handles

Every event Stripe sends is acknowledged with a 200 and a row in the ledger, but only six event types actually do something. Anything else is recorded and ignored.

payment_intent.succeeded

The big one. Fired when a one-time donation's PaymentIntent settles successfully.

Handler behavior:

  • Flips the Donation from Pending to Completed.
  • Updates the donor's lifetime total and count, and sets first/last donation timestamps.
  • Adds the donation amount to the fund's running total.
  • If the donation was attributed to a campaign, adds the amount to the campaign's total and bumps its donor count.
  • Queues the per-donation receipt generation task.

If the event arrives for a Donation that is already Completed (duplicate webhook), the handler returns immediately without re-running.

payment_intent.payment_failed

Fired when a one-time donation's PaymentIntent is rejected by Stripe.

Handler behavior:

  • Flips the Donation from Pending to Failed.
  • That's it — donor and fund totals are not touched (they were not touched at create time either).

charge.refunded

Fired when a charge is refunded — either via the platform's Refund action on the donation detail dialog, or directly through the Stripe dashboard.

Handler behavior:

  • Flips the Donation from Completed to Refunded.
  • Decrements the donor's lifetime total and count.
  • Decrements the fund's total raised.
  • Decrements the campaign's total raised (if the donation was attributed to one).

If the donation is already Refunded (duplicate webhook), the handler returns immediately.

invoice.payment_succeeded

Fired when a successful recurring-pledge charge clears.

Handler behavior:

  • Locates the recurring pledge by Stripe subscription ID.
  • Calculates the processing fee and net amount.
  • Creates a new Donation record attached to the pledge, marked Completed.
  • Updates the pledge: last charge timestamp moves to now, status flips to Active (in case it was Past Due).
  • Updates donor lifetime totals, fund totals, and campaign totals (if attributed).
  • Queues the per-donation receipt task for the new Donation.

This is how recurring pledges produce a stream of Donation rows over time — one per successful charge.

invoice.payment_failed

Fired when a recurring-pledge charge fails (declined card, etc.).

Handler behavior:

  • Flips the pledge from Active to Past Due.

Stripe's Smart Retries kick in automatically from here; the platform will see a invoice.payment_succeeded if Stripe eventually gets a successful charge, or a customer.subscription.deleted if it gives up.

customer.subscription.deleted

Fired when a Stripe subscription is cancelled — either by Stripe itself after exhausting retries on a Past Due pledge, by the donor through the Stripe customer portal, or by the platform via the Cancel action.

Handler behavior:

  • Locates the pledge by Stripe subscription ID.
  • Flips the pledge to Cancelled, sets the cancellation timestamp, and sets a default cancellation reason of "Subscription cancelled via Stripe."

If the pledge was already Cancelled (because the platform's own Cancel action ran first and the webhook is just the trailing acknowledgment), the duplicate is benign — the platform's Cancel sets the more accurate reason; the webhook is a no-op in practice.

Signature verification

Every incoming webhook request is checked against the configured signing secret. The verification confirms two things:

  • The payload was signed by Stripe using the matching secret.
  • The signature is recent (Stripe includes a timestamp to prevent replay attacks).

If signature verification fails, the platform returns a 400 to Stripe and does not record the event in the ledger. Stripe will retry with backoff; if the secret is broken, every retry will fail and the event eventually expires from Stripe's queue.

What happens when a webhook is missed

Webhooks are delivered over the public internet, which means they sometimes fail to deliver — your platform is briefly unreachable, a network blip, a bad cert moment. Stripe handles this on its side with retries.

Stripe's retry behavior

If Stripe gets a non-2xx response (or no response at all) from your webhook endpoint, it retries on an exponential schedule for up to three days. After that, the event is marked as failed in Stripe's dashboard and is no longer auto-retried.

Manual replay

If an event has expired from Stripe's auto-retry window but you discover it later, you can manually replay it from the Stripe dashboard:

  1. Go to your Stripe dashboard → Developers → Webhooks → your endpoint.
  2. Find the failed event under the Events tab.
  3. Click "Resend" on the event.

Stripe will redeliver the event right now. Your platform will receive it, verify the signature, see the event ID is not in the ledger, and process it normally — donor totals will tick up, the donation will flip to Completed, and the receipt will go out.

Because of the idempotency ledger, you can safely resend the same event multiple times without double-processing.

Stuck donations: a debugging checklist

If a donation is stuck in Pending more than a few minutes after the charge cleared on Stripe's side:

  1. Check the Stripe dashboard. Did payment_intent.succeeded fire? Did it land at your endpoint?
  2. Check the response status in Stripe. A 200 means the platform got it. A 400 usually means the signing secret is wrong on one side. A 5xx means the platform crashed processing the event.
  3. Check the ledger. Did the event get recorded? If yes, what is its status?
  4. Check the webhook endpoint URL. Is it pointing at the right foundation's UUID?
  5. Check the signing secret. Does the secret in Stripe match what is saved in Foundation Settings?
  6. If everything looks right, manually resend the event from Stripe. The idempotency ledger makes this safe.

Tips

  • Always set up the webhook before your first real campaign. A campaign that takes donations without the webhook configured will have all donations stuck Pending and zero receipts going out. The cleanup is messy.
  • Use Stripe's test mode for the first end-to-end run. Run a $1 test charge through the public page, watch the webhook fire in Stripe's dashboard, confirm the donation flips to Completed, confirm the receipt lands in your inbox.
  • Don't manually edit donations or pledges in the database. The webhook handlers are the source of truth for state transitions. Editing rows directly will get you out of sync with Stripe and make later reconciliation painful.
  • Subscribe to all six event types. Even if you don't run recurring pledges today, subscribe to the invoice and subscription events so when you do start, the wiring is already in place.

Last verified against v0.62.1 (2026-05-10).