Skip to content

Security review

Sample API callouts#

This is the web rendering of sample-api-callouts.pdf, the Sample API Callouts document uploaded to the AppExchange security review. Page and PDF are built from the same source.

Solution: Pledgivo  ·  Namespace: pledgivo  ·  Package version: 1.2.2  ·  Publisher: CloudAlgo Private Limited  ·  Document date: 6 October 2026
External services called: Stripe only  ·  Inbound endpoints exposed: one, read-only  ·  Inbound webhooks: none

Every HTTP request this package makes, and the one request it answers. All outbound calls go to Stripe over TLS through a Named Credential the subscriber's administrator creates. Values shown in <angle brackets> are runtime substitutions; all other header values are exactly what the package sends.

1. How every outbound request is built#

All outbound calls are issued by a single private method, StripeGateway.rawRequest — the only setEndpoint in the package — so the header set below is identical on every request in this document.

HeaderValueSet by
AuthorizationBearer rk_live_<restricted key>The platform, from the Named Credential. Never set in packaged code and never visible to it. The key value does not appear in any packaged file, field, custom setting, static resource or log.
Stripe-Version2026-03-25.dahliaPackage. Pinned in PaymentGatewayRegistry and upgraded only by a package upgrade, so a Stripe API change cannot alter behaviour in an installed org.
Content-Typeapplication/x-www-form-urlencodedPackage. Stripe's API is form-encoded, not JSON.
Idempotency-Key<deterministic key>Package, on write operations only. Omitted entirely when blank, so no empty header is sent.

Endpoint resolution. The package sets the endpoint as callout:<NamedCredentialName> + <path>. The platform resolves that to https://api.stripe.com and injects the credential. Two credentials are used and are referred to below as:

DONOR KEY reachable on the public/guest path — write on Payment Intents, Setup Intents, Customers, Payment Methods; None on Charges, Refunds and Balance.
ADMIN KEY staff-only — adds write on Payment Intents, Charges, Refunds; read on Balance, Disputes, Customers, Payment Methods.

Request timeout: 30,000 ms on every call.

2. Outbound requests — payment capture#

Sequence diagram showing where each Stripe call happens during one guest donation

Where the calls in this section happen. The two solid green arrows into Stripe are the server-side callouts documented below — POST /v1/payment_intents when the gift is staged and GET /v1/payment_intents/<id> when the outcome is read back. The red dashed arrows are Stripe.js traffic from the browser-hosted iframe and are not Apex callouts; they are shown so a reviewer can see that card capture, confirmation and 3-D Secure never pass through Salesforce. The purple arrows are the reconciler's re-fetch of any intent left open. No arrow runs from Stripe into the package: there is no webhook.

DONOR KEYPOST /v1/payment_intents

Creates the payment for a one-off gift. Called from StripeGateway.createPaymentIntent.

POST https://api.stripe.com/v1/payment_intents
Authorization: Bearer rk_live_<donor restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded
Idempotency-Key: <staging record id>-intent

amount=5000&currency=usd&automatic_payment_methods[enabled]=true
&metadata[correlation_id]=<uuid>&metadata[campaign_id]=<15-char Campaign Id>
&metadata[donor_email]=donor%40example.org

HTTP/1.1 200 OK
Content-Type: application/json
{
  "id": "pi_3QxampleIntentId",
  "object": "payment_intent",
  "amount": 5000,
  "currency": "usd",
  "status": "requires_payment_method",
  "client_secret": "pi_3QxampleIntentId_secret_<redacted>",
  "metadata": { "correlation_id": "<uuid>", "campaign_id": "701...", "donor_email": "donor@example.org" }
}

Note. The client_secret is returned to the donor's browser so Stripe.js can confirm the payment. It is scoped to this one PaymentIntent and cannot be used to read or charge anything else.

DONOR KEYGET /v1/payment_intents/<id>

Re-fetches the intent to establish the outcome. This call is why the package needs no webhook: payment status is pulled from Stripe rather than trusted from an inbound request.

GET https://api.stripe.com/v1/payment_intents/pi_3QxampleIntentId
Authorization: Bearer rk_live_<donor restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded

HTTP/1.1 200 OK
Content-Type: application/json
{
  "id": "pi_3QxampleIntentId",
  "object": "payment_intent",
  "amount": 5000,
  "amount_received": 5000,
  "currency": "usd",
  "status": "succeeded",
  "latest_charge": "ch_3QxampleChargeId"
}
ADMIN KEYPOST /v1/payment_intents (off-session)

Charges a stored payment method for a scheduled recurring gift, with no donor present. Called from the recurring-donation batch.

POST https://api.stripe.com/v1/payment_intents
Authorization: Bearer rk_live_<admin restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded
Idempotency-Key: <recurring donation id>-<period>

amount=2500&currency=usd&customer=cus_Example&payment_method=pm_Example
&off_session=true&confirm=true

HTTP/1.1 200 OK
Content-Type: application/json
{ "id": "pi_3QxampleRecurring", "status": "succeeded", "amount": 2500, "currency": "usd" }

Failure path. A card requiring authentication returns 402 with "code": "authentication_required". The package records the failure, does not retry immediately, and emails the donor a re-authentication link rather than raising an exception.

3. Outbound requests — stored payment methods#

DONOR KEYPOST /v1/customers

Creates a Stripe Customer for a recurring or saved-card gift only — one-time gifts and event tickets are customerless. This is the call that transmits donor name and email to Stripe.

POST https://api.stripe.com/v1/customers
Authorization: Bearer rk_live_<donor restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded
Idempotency-Key: <correlation id>-customer

email=donor%40example.org&name=Jane%20Donor

HTTP/1.1 200 OK
Content-Type: application/json
{ "id": "cus_Example", "object": "customer", "email": "donor@example.org", "name": "Jane Donor" }
DONOR KEYPOST /v1/setup_intents

Authorises a card for future off-session use without charging it.

POST https://api.stripe.com/v1/setup_intents
Authorization: Bearer rk_live_<donor restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded
Idempotency-Key: <correlation id>-setup

customer=cus_Example&usage=off_session

HTTP/1.1 200 OK
{ "id": "seti_Example", "object": "setup_intent", "status": "requires_payment_method",
  "client_secret": "seti_Example_secret_<redacted>" }
DONOR KEYPOST /v1/payment_methods/<id>/attach  ·  POST /v1/customers/<id>  ·  GET /v1/payment_methods/<id>

Attaches a payment method to a customer, sets it as the default, and reads back its non-sensitive metadata (brand, last four digits, expiry) for display on Payment_Method__c.

POST https://api.stripe.com/v1/payment_methods/pm_Example/attach
Authorization: Bearer rk_live_<donor restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded

customer=cus_Example

GET https://api.stripe.com/v1/payment_methods/pm_Example
Authorization: Bearer rk_live_<donor restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded

HTTP/1.1 200 OK
{ "id": "pm_Example", "type": "card",
  "card": { "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2029 } }

Note. last4, brand and expiry are the only card attributes the package ever receives or stores. No full card number is returned by this endpoint.

4. Outbound requests — back office#

ADMIN KEYPOST /v1/refunds

Refunds a gift. Initiated by a staff user holding the Process_Refunds custom permission, from the Opportunity record page. The donor credential cannot perform this call — its Refunds permission is None.

POST https://api.stripe.com/v1/refunds
Authorization: Bearer rk_live_<admin restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded
Idempotency-Key: <opportunity id>-refund

payment_intent=pi_3QxampleIntentId&amount=5000&reason=requested_by_customer

HTTP/1.1 200 OK
{ "id": "re_Example", "object": "refund", "amount": 5000, "currency": "usd",
  "payment_intent": "pi_3QxampleIntentId", "status": "succeeded" }
ADMIN KEYGET /v1/payment_intents  ·  GET /v1/refunds  ·  GET /v1/disputes (list, reconciliation)

Scheduled jobs list recent activity to reconcile against org records and to detect payments that completed while a browser session was interrupted.

GET https://api.stripe.com/v1/payment_intents?limit=100&created%5Bgte%5D=1789000000
Authorization: Bearer rk_live_<admin restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded

HTTP/1.1 200 OK
{ "object": "list", "has_more": false, "data": [
  { "id": "pi_3QxampleIntentId", "status": "succeeded", "amount": 5000,
    "metadata": { "correlation_id": "<uuid>" } } ] }

The /v1/refunds and /v1/disputes list calls take the same shape and the same headers, differing only in path.

5. Outbound requests — setup verification#

These calls exist only so an administrator can prove, at configuration time, that the credentials were built with the intended scopes. They are issued from the Settings console, never from a guest path.

ADMIN KEYGET /v1/refunds?limit=1 (connection test)

Confirms the administrator credential works. /v1/account is deliberately not used, so no key is ever required to hold Accounts: Read.

GET https://api.stripe.com/v1/refunds?limit=1
Authorization: Bearer rk_live_<admin restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded

HTTP/1.1 200 OK
{ "object": "list", "data": [], "has_more": false }
DONOR KEYPOST /v1/charges (donor-key scope probe — expects 403)

A deliberate, empty POST whose expected outcome is a permission error. On this endpoint Stripe checks permission before validating parameters, so a correctly scoped donor key is refused and nothing is created. Charges and Refunds share one permission, so the refusal is proof the guest-held key cannot refund.

POST https://api.stripe.com/v1/charges
Authorization: Bearer rk_live_<donor restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded

(empty body)

HTTP/1.1 403 Forbidden          <-- PASS: the key is correctly scoped
{ "error": { "type": "invalid_request_error",
             "message": "The provided key does not have the required permissions ... charge_write" } }

HTTP/1.1 400 Bad Request        <-- FAIL: the key is over-scoped; setup step reports an error
{ "error": { "code": "parameter_missing", "message": "Must provide source or customer." } }

Why this endpoint and no other. It is the only Stripe endpoint with both required properties: permission is checked before validation, and an empty POST creates nothing. An empty POST to /v1/customers or /v1/setup_intents succeeds and leaves a record behind; /v1/refunds validates before checking permission and so answers 400 to a correctly scoped and an over-scoped key alike. Both alternatives were measured in a Stripe sandbox during the scope audit of 7 September 2026 and rejected on the evidence. No charge is created in either outcome.

ADMIN KEYGET /v1/products?limit=1 (admin-key scope probe)

Reads an endpoint deliberately outside everything the administrator key is asked to hold, confirming the key is not over-scoped. A correctly scoped key returns 403.

GET https://api.stripe.com/v1/products?limit=1
Authorization: Bearer rk_live_<admin restricted key>
Stripe-Version: 2026-03-25.dahlia
Content-Type: application/x-www-form-urlencoded

HTTP/1.1 403 Forbidden          <-- PASS: the key holds nothing beyond its audited scopes

6. Inbound — the one endpoint this package exposes#

NO AUTH · READ-ONLYGET /services/apexrest/pledgivo/embed/config

EmbedConfigResource — the only global class in the package (1 of 293). Served by the subscriber's own Salesforce Site. Returns campaign display configuration to the embeddable donation widget hosted on a third-party website.

GET https://<subscriber-site>/services/apexrest/pledgivo/embed/config?campaignId=<id>
Origin: https://www.example-charity.org
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://www.example-charity.org
{ "success": true,
  "data": { "campaignName": "Winter Appeal", "goalAmount": 25000, "currency": "USD",
            "suggestedAmounts": [25, 50, 100], "themeTokens": { "--pf-brand-primary": "#C4462F" } } }

HTTP/1.1 200 OK        <-- origin not on the subscriber's allowlist
{ "success": false, "error": "This request could not be completed." }

Security properties. GET only, no write path. Returns no donor records and no personal data of any kind. Answers only origins on the subscriber's own allowlist in Settings__c. Requires no authentication by design — it serves public campaign display data to a public web page — and it sanitizes its own error text by hand rather than relying on the shared ControllerAction wrapper, because it is read by third-party JavaScript. Theme tokens are validated against a protected custom metadata catalogue before being emitted.

7. Endpoints this package does not call#

  • No inbound webhook of any kind. The package registers no webhook with Stripe, exposes no webhook receiver, and therefore stores no webhook signing secret. Payment outcomes are always pulled.
  • No /v1/account. Removed deliberately on 7 September 2026 so that no credential is ever required to hold Accounts: Read. The Payments settings panel prints no Stripe account name as a direct consequence.
  • No http:// endpoint anywhere. Every outbound call is TLS.
  • No publisher-operated endpoint in the data path. CloudAlgo hosts no server, API, database or logging service that the package calls, and the package emits no telemetry. The publisher's subscription checkout (billing.cloudalgo.com) and documentation site (pledgivo.cloudalgo.com) are reached only by a staff user clicking a hyperlink that opens in a new tab; the package issues no request to either host and registers no CSP trusted site for them.
  • No Salesforce licence-manager callout. Seat checks use the platform's local UserInfo.isCurrentUserLicensed() and a PackageLicense query; nothing leaves the org.