Integrations
Stripe#
Stripe is the only shipped payment gateway, reached through an interface designed so a second processor could be added without touching the donation pipeline. No inbound webhook exists anywhere in the package.
The gateway abstraction#
Every gateway call in the package goes through IPaymentGateway — an interface whose first
method is init(Id paymentAccountId). PaymentGatewayFactory.getByAccount(Id) is the sole
resolution path from a Payment_Account__c record to the Apex class that talks to that
account's processor; nothing else in the codebase branches on gateway type.
IPaymentGateway
├── init(Id paymentAccountId)
├── createIntent(...) → GatewayResult.Intent
├── chargeOffSession(...) → GatewayResult.Intent
├── refund(...)
├── createCustomer(...) → GatewayResult.Customer
├── createSetupIntent(...) → GatewayResult.Intent
└── listRecentDisputes(...) → List<GatewayResult.Dispute>
StripeGateway is the current, and only, implementation. PaymentGatewayRegistry is the
catalog that maps a Payment_Account__c.Gateway__c value to its implementing class, its pinned
API version, and the shape of the credential it needs. PaymentGatewayFactory.getByAccount()
resolves a gateway from a specific account — never a global default — so an org with several
Stripe accounts always charges and refunds against the right one.
The registry is Apex, not custom metadata (it replaced Payment_Gateway__mdt in August 2026).
Adding a processor always meant shipping a class in the package, so nothing was ever
configurable about it; holding the class as a compile-time type means a bad reference is a build
error here instead of a "payments are temporarily unavailable" message in a subscriber org.
Adding a second processor is a new class, a registry entry, and a picklist value — not a rewrite
of DonationService or the reconciliation batches.
PCI scope: card data never touches Apex#
The donation form embeds Stripe's Payment Element inside a same-origin Visualforce page
(stripePayment), framed as an iframe on the Experience Cloud site, and communicates with the
parent LWC exclusively via postMessage — INIT_STRIPE, STRIPE_READY, CONFIRM_PAYMENT,
PAYMENT_ERROR. Stripe.js is loaded from js.stripe.com, never bundled or proxied, so the org
stays inside Stripe's provided PCI compliance envelope. A donor's card number goes straight
from their browser to Stripe; the only thing that reaches Apex is a paymentMethod.id
reference.
| Domain | Why it's trusted |
|---|---|
js.stripe.com |
Loads Stripe.js and the Payment Element |
api.stripe.com |
Stripe API calls made from the browser |
m.stripe.com, m.stripe.network |
Stripe's fraud-detection telemetry |
hooks.stripe.com |
Used internally by Stripe.js itself (not a Salesforce inbound webhook — this package has none) |
All five are configured as CSP Trusted Sites (context: All, isApplicableToConnectSrc: true,
isApplicableToFrameSrc: true), and they are the only trusted sites the package ships — so the
install-time "Approve Third-Party Access" dialog lists Stripe and nothing else. Hosts behind
optional features (video embeds, stock photography) are left for the subscriber to add; see
Third-party content.
No inbound webhook, by design#
Pledgivo does not register a Stripe webhook and has no @RestResource/@HttpPost endpoint
listening for one. Instead, Salesforce confirms payment status by asking Stripe:
- Immediately — the guest browser polls
DonationFormController.checkPaymentStatus()(which delegates toDonationService.checkStagingPaymentStatus()) right after Stripe redirects back from payment confirmation. - On a backup schedule —
StagingReconciliationBatch(viaStagingReconciliationScheduler) re-fetches any staging record that hasn't resolved, as a safety net for a donor who closed their browser before the poll completed. - A slower sweep —
StripePaymentSweepcatches anything both of the above missed, on a longer interval, plus aReconciliationHeartbeatthat confirms the whole chain is actually running.
This trades near-instant webhook confirmation for zero public attack surface and no signing secret to manage — see Donations & payments for the full sequence diagram.
Idempotency#
Finalizing a staging record into an Opportunity is keyed on
Opportunity.Stripe_Payment_Intent_Id__c being unique. Because both the guest poll and the
backup batch can independently observe the same succeeded PaymentIntent, finalization checks
for an existing Opportunity with that PaymentIntent id before inserting — the two paths racing
each other can never produce two Opportunities for one payment.
Refunds and disputes#
RefundService issues in-app refunds. RefundReconciliationBatch runs hourly and reconciles
both in-app and Stripe-dashboard-initiated refunds, plus the full dispute/chargeback
lifecycle, onto the Opportunity's Dispute_* fields. Because a Stripe PaymentIntent doesn't
itself expose a cumulative refunded total, the reconciler retrieves the PaymentIntent with
expand[]=latest_charge to read the true running refund amount. See
Process a refund.
Event ticket purchases#
Ticket purchases use the same staging pipeline with Purchase_Type__c = 'Event_Ticket'
routing finalization to EventPurchaseService instead of the plain donation finalizer — see
Events & ticketing.
Credentials#
The package ships no credential, and creates none. You build the credentials by hand in Setup, and the Payments panel in the Settings console walks you through it and then reads your org back to say which field is wrong:
| You create | Holds |
|---|---|
| External Credential (you name it) | The Stripe secret key, in an encrypted parameter on a Named Principal |
| Named Credential (you name it) | Points at https://api.stripe.com; its auth header is a merge formula that reads the parameter above |
| A permission set of your own | Grants access to that principal |
You build that set twice per Stripe account, once per role. The public credential carries a
restricted, charge-only key and its permission set goes to payment staff and — if you take public
donations — the site guest user. The admin credential carries the full key used by refunds, the
reconcilers, the orphan sweep, off-session renewals and Test connection, and its permission set goes
to staff only, never the guest user. Payment_Account__c names both. Pointing both fields at one
credential is supported and graded amber, because your guest user then reaches a key that can refund.
Each account gets its own credentials, so a multi-account org keeps its keys separated the way
Stripe does. No custom field anywhere stores a secret key, and nothing reads one back — the merge
formula is resolved by the platform at callout time. The Payment_Account__c record carries
only the names of the Named Credentials to call, plus the publishable key.
The permission set is yours, not packaged, and that is forced rather than chosen: a managed
permission set cannot be given a principal mapping (ConnectApi throws
CANNOT_MODIFY_MANAGED_OBJECT), so Fundraising_Admin and Fundraising_GuestDonor can never
hold this grant no matter what the package ships.
Nor can the package build the credential for you: a Named Credential created from Apex is born
with callouts disabled, and no Apex API can enable it — so provisioning could never finish
the job, only move the manual step somewhere less visible. PaymentSetupGuideService therefore
verifies instead of creating. It grades eleven steps as done, action needed, confirm-this-yourself,
or on demand — naming the exact checkbox that is wrong rather than repeating Stripe's "Invalid API
Key provided".
What it can read is bounded by the platform, not by effort. ConnectApi.NamedCredentials reaches
only credentials a package created itself, and every credential here is admin-built, so the guide
reads what SOQL exposes — NamedCredential, two of its three callout-option fields,
SetupEntityAccess and PermissionSetAssignment — and nothing else. The external credential, the
stored key, the auth header, the callout URL, Enabled for Callouts and the Allowed Namespaces
entry are invisible to packaged Apex, so those steps return an amber confirm this yourself
verdict naming the exact screen and value rather than an error. A stored secret is write-only in
any case (Salesforce reports its own status as Unknown). Two live calls settle the rest: step 7's
probe proves the public key is really restricted, and step 11's Test connection proves the
whole chain works. Both read your Stripe account and move no money.
Granting the public credential to the site guest user is the one step that widens what an anonymous visitor can reach, so it stays an explicit decision of yours. Public donations are unauthorized until it's done; the guide flags it, and step 9 raises the opposite mistake — a guest user holding the admin credential — naming the permission sets the guest holds for you to check, since a package cannot see which credential a grant belongs to. Step 7's probe catches the same exposure from the other side and does grade it a failure. See Connecting Stripe.
Related#
-
Connecting Stripe
Setup walkthrough for a new Payment Account.
-
Donations & payments
The end-to-end flow this architecture supports.