This is a deep-dive sub-post for Project 01 — Sai Dance Academy. Start with the project overview if you haven't already.

Read the project overview →

This post covers one specific piece of the Sai Dance Academy build in full detail: the Stripe payment integration. If you've ever tried to wire Stripe into a Salesforce Experience Cloud site for anonymous guest users, you'll know it's one of those integrations where the happy-path tutorial runs out quickly and the real work begins.

I'm documenting this because the issues I hit — guest Apex REST access across multiple sites, webhook payload parsing, sharing context, credential header gotchas — are exactly the things that cost hours and don't appear in the official docs. Consider this the guide I wish I'd had.

Don't have a Stripe account yet? You'll need one to follow along. Test mode is free — no payment details required to start building.


1

The big picture

What We Built — In One Paragraph

A visitor picks a class on the public site and submits an enrollment form. Salesforce creates an Enrollment__c record with status Pending Payment, then calls Stripe to create a Checkout Session and redirects the visitor to Stripe's hosted payment page. The visitor pays. Stripe fires a webhook to a public Salesforce REST endpoint. The handler finds the enrollment by the record ID stored in Stripe's session metadata, flips the status to Confirmed, writes a Payment__c audit record, and a record-triggered Flow sends a confirmation email. The same pattern powers paid event RSVPs. Everything runs for anonymous guest users — no login required.

The architecture

Visitor on /sda/enroll
      │ submits form (LWC)
      ▼
EnrollmentController.createEnrollment
      │ DML only — Contact + Enrollment__c (Pending Payment)
      │ returns enrollmentId
      ▼
StripeCheckoutController.createCheckoutSession
      │ callout only — POST to Stripe, save Session Id
      │ returns Stripe Checkout URL → Enrollment → Awaiting Payment
      ▼
Stripe hosted Checkout page
      │ visitor pays with card
      ▼  (server-to-server)
Stripe webhook → POST payment_intent.succeededStripeWebhookHandler (@RestResource, public)
      │ find Enrollment by metadata id
      │ set Confirmed, write Payment__c
      ▼
Record-triggered Flow
      │ emails student + admin

Two Apex classes, one REST endpoint, one Flow, and the credential plumbing. The responsibilities are split deliberately — and that split is forced by a hard Salesforce platform rule I'll come to in section 3.

Stripe Checkout page showing class name and fee in USD

Stripe Checkout in test mode — class name and fee passed from Salesforce via the Checkout Session


2

The secure foundation

Credential Setup — No Secret Keys in Code

Before writing a line of Apex, the credentials need to be in place. Stripe's secret key never touches the codebase — it lives in Salesforce's credential framework and is referenced by name.

What you need to set up

External Credential configuration in Salesforce Setup

External Credential — Stripe secret key stored securely, never in code

Named Credential referencing the External Credential

Named Credential — Apex references this as callout:Stripe_API

⚠️ The single trickiest credential detail — a multi-hour gotcha

In the External Credential's Custom Header, the Authorization header must reference the inner auth-parameter name, not the principal's display label:

Authorization = {!$Credential.Stripe_API_Credential.Authorization}

The inner parameter is literally named Authorization, and its value holds the raw bearer string — so no Bearer prefix is needed (the value already is the full token). "Allow Formulas in HTTP Header" must also be checked, or the merge field is treated as literal text. Referencing the principal label instead of the inner parameter name silently fails authentication. This is the kind of thing no quickstart mentions.


3

A platform rule that shapes the architecture

The Two-Call Pattern — Why DML and Callout Must Be Split

The enrollment flow makes two separate Apex calls from the LWC, not one. This isn't a design preference — it's forced by a hard Salesforce platform rule:

⚡ Platform rule

You cannot make an HTTP callout after a DML operation in the same transaction. If a single method creates the Enrollment (DML) and then calls Stripe (callout), Salesforce throws "You have uncommitted work pending."

The solution is to split the work across two transactions:

💡 The rule in plain English

Callout before DML in a single transaction: allowed. DML before callout: not allowed. Callout then DML (in the same transaction): allowed. The pattern that works is either "callout first, then DML" or "split into two transactions." Here we use both — transaction one is DML-only, transaction two is callout-then-DML.


4

The callout design

The Checkout Controller — What Goes to Stripe

The checkout controller (StripeCheckoutController) is without sharing — it runs in the guest context and must operate regardless of the guest's record sharing rules. Three design decisions here are worth understanding:

Form-encoded, not JSON

Stripe's Checkout Session endpoint expects form-encoded data, not JSON. The request body is built as application/x-www-form-urlencoded with keys like line_items[0][price_data][unit_amount]. This trips up developers expecting a JSON API — Stripe's payment endpoints consistently use form encoding.

Amount in the smallest currency unit

⚠️ Common bug

Stripe amounts are in the smallest currency unit — multiply by 100. A $55.00 class fee must be sent as 5500. Forgetting this results in charging 100× the correct amount. This is a frequent source of "why did it charge $5500?" bugs in testing.

The metadata handshake — the most important part

The enrollment record ID is written into Stripe's session metadata in two places:

// Both of these are needed metadata[enrollment_id] = enrollmentId payment_intent_data[metadata][enrollment_id] = enrollmentId

Why both? The checkout.session.completed event carries metadata. But this integration uses payment_intent.succeeded — and that event carries payment_intent_data[metadata]. Writing to both covers either event type. The webhook uses the enrollment_id from metadata to find the exact record to confirm. Miss this and the webhook arrives but has nothing to update.


5

The hard part

The Webhook Handler — Architecture and Design

When Stripe finishes processing a payment, it sends a POST request to a public Salesforce REST endpoint. This is the piece that took the most work to get right — across four separate problems I'll detail in the next section. Here's how the handler is designed:

@RestResource(urlMapping='/stripe/webhook/*') global without sharing class StripeWebhookHandler { @HttpPost global static void handleWebhook() { // Parse → find record → update → return 200 } }
Stripe dashboard showing the configured webhook endpoint URL

Stripe dashboard — webhook endpoint pointing to the Salesforce REST endpoint URL


6

Where the real learning happened

The Webhook Saga — Four Problems, Four Fixes

Getting the webhook working end-to-end took four separate debugging sessions. Each problem was independent — fixing one revealed the next. This section is the most valuable part of this article because these are the non-obvious issues that don't appear in tutorials.

Problem 01
"You do not have access to the Apex class" — guest REST access across multiple sites
After payment, Stripe called the webhook endpoint and got "You do not have access to the Apex class." Enrollments never got confirmed. Stalled for days — the standard fix of enabling the class on the site's guest profile wasn't working.
The org had multiple sites — the main SDA site, a default help center, and an esw_* site auto-created by the Agentforce/Embedded Service deployment. The Apex class was enabled on the main site's guest profile, but the REST call was resolving against one of the other sites' guest context.
Grant the Apex class on the guest profile of all sites in the org — Setup → Sites → [each site] → Public Access Settings → Apex Class Access. One site isn't enough when multiple exist.
Two guest profiles exist conceptually: the Experience Cloud guest profile (controls pages and components) and the Sites guest profile (controls /services/apexrest/ REST endpoints). The webhook uses the latter. In a multi-site org, both must be configured on every site.
Problem 02
"Internal error logged" on every event — typed wrapper + string replace broke on Stripe's newer payload
Once reachable, the webhook returned HTTP 200 but with "Internal error logged" — hitting the catch block on every call. Nothing updated.
The original handler used typed Apex wrapper classes plus a string .replace('"object":', '"object_x":') trick to work around Apex reserved words (object, currency). Stripe's newer API version sends a much richer, deeply-nested payload. The blunt string replacement was corrupting string values (like "object": "event"), breaking deserialization entirely.
Rewrote the handler to parse with JSON.deserializeUntyped into a Map<String, Object>. Reserved words become harmless map keys. Extra fields in the payload are simply ignored. The rewrite also made the handler surface the real error text in the HTTP response — which meant the next problem became immediately visible in Stripe's dashboard.
Parse evolving third-party payloads with untyped JSON, not rigid typed wrappers. Typed wrappers work until the payload evolves — then they break silently or with generic errors. Untyped is more verbose but far more resilient.
Problem 03
Handler returns "OK" but enrollment never updates — sharing context
The rewritten handler returned HTTP 200 with "OK," but the enrollment status didn't change. The only silent path was "record not found" — the SOQL was returning zero rows even though the enrollment definitely existed.
The handler was with sharing. It runs as the Site Guest User — a different user from the one who created the enrollment. In Salesforce's sharing model, a different user in a separate transaction can't see records created by another user unless sharing is explicitly opened. The query returned zero rows because the guest user running the webhook had no visibility of the guest-created enrollment.
Changed the handler to without sharing. It now runs in system context and can find and update any record regardless of sharing rules.
A webhook is a trusted server-to-server caller, not an end user. without sharing is the correct and appropriate choice — this isn't bypassing security for a person, it's a backend integration that needs to operate on records regardless of who created them.
Problem 04
Two field/config errors on the final stretch — Flow email and restricted picklist
With visibility fixed, two precise errors surfaced in turn: first CANNOT_EXECUTE_FLOW_TRIGGER blocking the confirmation email, then INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST on Currency_Code__c.
The confirmation Flow's email action used a sender whose domain wasn't verified in Salesforce. Separately, Currency_Code__c was a restricted picklist that didn't include the currency value being written by the webhook.
Configured and used a verified Org-Wide Email Address as the sender in the Flow. Added the correct currency value to the restricted picklist.
Restricted picklists must include every value your code writes — code and metadata must agree. Automated emails need a verified sender. After fixing these two, the full chain worked end to end for the first time.

7

How to validate it works

Testing the Pipeline

Once the integration is wired up, here's how to test and debug efficiently:

Enrollment record showing Confirmed status and linked Payment record

The end state — Enrollment confirmed, Payment__c audit record created, email sent


Lessons Checklist

Everything from this build distilled into a scan-able reference:

The Stripe + Salesforce integration checklist

See it live

The full payment pipeline is live. Use Stripe test card 4242 4242 4242 4242, any future date, any CVC. Use a real email to receive the confirmation.

Salesforce Experience Cloud (LWR) · Apex · Stripe Checkout · Named Credentials · Guest User Context · Record-Triggered Flow