Skip to content

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 postMessageINIT_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:

  1. Immediately — the guest browser polls DonationFormController.checkPaymentStatus() (which delegates to DonationService.checkStagingPaymentStatus()) right after Stripe redirects back from payment confirmation.
  2. On a backup scheduleStagingReconciliationBatch (via StagingReconciliationScheduler) re-fetches any staging record that hasn't resolved, as a safety net for a donor who closed their browser before the poll completed.
  3. A slower sweepStripePaymentSweep catches anything both of the above missed, on a longer interval, plus a ReconciliationHeartbeat that 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.

  • Connecting Stripe


    Setup walkthrough for a new Payment Account.

    Read more →

  • Donations & payments


    The end-to-end flow this architecture supports.

    Read more →