Tutoriel · Stripe · Salesforce · Apex
Déclencher un paiement Stripe depuis Salesforce
Un conseiller clique dans Salesforce, le client reçoit un lien de paiement : appel Apex vers le middleware, session Checkout idempotente, puis confirmation par webhook.
Le parcours
Dans la série, Acme Assurances, compagnie fictive, fait souscrire ses contrats depuis Salesforce. Le conseiller ne doit ni saisir ni voir de données de carte : il déclenche un lien de paiement, le client paie sur une page Stripe, et Salesforce se met à jour seul.
1. Le conseiller ouvre le contrat dans Salesforce et clique « Envoyer le lien de paiement »
2. Apex ──POST /api/paiements/checkout──► middleware Symfony
3. Middleware : client Stripe trouvé ou créé, session Checkout en mode abonnement
4. Middleware ──201 { url }──► Apex : lien enregistré sur le contrat, e-mail envoyé au client
5. Le client paie sur la page Stripe (carte ou prélèvement SEPA)
6. Stripe ──webhooks──► middleware ──upsert──► Abonnement__c et Paiement__c dans Salesforce Stripe Checkout héberge la page de paiement : authentification forte 3-D Secure, mandat SEPA, moyens de paiement locaux et conformité PCI restent à sa charge.
Appeler le middleware depuis Salesforce
L’appel sortant passe par une Named Credential, ici Middleware_Paiement, associée à une External Credential. L’URL et le secret vivent dans la configuration de l’org, pas dans le code Apex, et diffèrent naturellement entre sandbox et production.
- URL : celle du middleware (
https://paiement.acme-demo.example, fictive). - Authentification : de préférence OAuth 2.0 Client Credentials auprès du middleware ; à défaut, un en-tête secret défini dans l’External Credential.
- Accès : le principal de l’External Credential est attribué par ensemble d’autorisations, aux seuls conseillers habilités.
Le même mécanisme est détaillé, pour une autre API, dans l’article Appeler l’API Claude depuis Salesforce Apex.
La classe Apex
Une méthode @AuraEnabled, appelée depuis un bouton (composant LWC ou action rapide) :
public with sharing class PaiementStripeService {
@AuraEnabled
public static String creerLienPaiement(Id contratId) {
// WITH USER_MODE : le conseiller ne peut déclencher un paiement que sur un contrat qu'il voit.
Contrat__c contrat = [
SELECT Id, Compte__c, Compte__r.Name, Compte__r.Email__c, Compte__r.Stripe_Customer_Id__c, Stripe_Price_Id__c
FROM Contrat__c WHERE Id = :contratId WITH USER_MODE
];
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Middleware_Paiement/api/paiements/checkout');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(20000);
req.setBody(JSON.serialize(new Map<String, Object>{
'contratId' => contrat.Id,
'compteId' => contrat.Compte__c,
'nom' => contrat.Compte__r.Name,
'email' => contrat.Compte__r.Email__c,
'stripeCustomerId' => contrat.Compte__r.Stripe_Customer_Id__c,
'priceId' => contrat.Stripe_Price_Id__c
}));
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 201) {
throw new AuraHandledException('Le lien de paiement n’a pas pu être créé (' + res.getStatusCode() + ').');
}
String url = (String) ((Map<String, Object>) JSON.deserializeUntyped(res.getBody())).get('url');
contrat.Lien_Paiement__c = url;
update as user contrat;
return url;
}
} L’appel est synchrone : le conseiller attend son lien, quelques centaines de millisecondes. Le délai maximal de 20 secondes reste sous la limite des callouts Apex ; au-delà, le middleware est en défaut et l’erreur s’affiche proprement.
L’endpoint du middleware
Le corps est désérialisé et validé avant tout appel à Stripe : un identifiant mal formé est refusé en 422.
final readonly class CheckoutRequest
{
public function __construct(
#[Assert\Regex('/^[a-zA-Z0-9]{18}$/')] public string $contratId,
#[Assert\Regex('/^001[a-zA-Z0-9]{15}$/')] public string $compteId,
#[Assert\NotBlank] public string $nom,
#[Assert\Email] public string $email,
#[Assert\Regex('/^price_\w+$/')] public string $priceId,
#[Assert\Regex('/^cus_\w+$/')] public ?string $stripeCustomerId = null,
) {
}
} #[Route('/api/paiements/checkout', methods: ['POST'])]
#[IsGranted('ROLE_SALESFORCE')] // jeton OAuth émis pour l'org, vérifié par le pare-feu Symfony
public function checkout(#[MapRequestPayload] CheckoutRequest $input): JsonResponse
{
$customerId = $input->stripeCustomerId ?? $this->createCustomer($input);
$session = $this->stripe->checkout->sessions->create([
'mode' => 'subscription',
'customer' => $customerId,
'line_items' => [['price' => $input->priceId, 'quantity' => 1]],
'client_reference_id' => $input->contratId,
// Copiées sur l'abonnement : chaque webhook saura à quel contrat Salesforce il se rapporte.
'subscription_data' => ['metadata' => ['sf_contrat_id' => $input->contratId]],
'success_url' => 'https://espace.acme-demo.example/paiement/merci',
'cancel_url' => 'https://espace.acme-demo.example/paiement/annule',
'expires_at' => time() + 23 * 3600, // une session Checkout vit au plus 24 h
], ['idempotency_key' => 'checkout-' . $input->contratId . '-' . $input->priceId . '-' . date('Ymd')]);
return new JsonResponse(['url' => $session->url], Response::HTTP_CREATED);
}
private function createCustomer(CheckoutRequest $input): string
{
// Clé d'idempotence par compte : deux clics rapprochés ne créent pas deux clients Stripe.
$customer = $this->stripe->customers->create([
'name' => $input->nom,
'email' => $input->email,
'metadata' => ['sf_account_id' => $input->compteId],
], ['idempotency_key' => 'customer-' . $input->compteId]);
// Écrire tout de suite l'identifiant Stripe sur le compte : les webhooks qui suivent s'y rattachent.
$this->salesforce->request('PATCH', '/sobjects/Account/' . $input->compteId, [
'json' => ['Stripe_Customer_Id__c' => $customer->id],
]);
return $customer->id;
} Le client Stripe se crée au premier paiement, puis son identifiant est écrit sur le compte Salesforce. Les paiements suivants le réutilisent : l’historique de facturation reste regroupé sur un seul client.
Idempotence : un clic, un paiement
Un double clic, un délai dépassé côté Apex alors que le middleware a bien répondu, une reprise automatique : autant de façons de demander deux fois la même chose. Stripe accepte une clé d’idempotence par requête de création : la même clé renvoie le même résultat, sans créer un second objet.
customer-{compteId}: un seul client Stripe par compte Salesforce.checkout-{contratId}-{priceId}-{jour}: une session par contrat, formule et journée ; le lendemain, un nouveau lien est possible si le premier a expiré.
Stripe conserve une clé environ 24 heures et refuse sa réutilisation avec des paramètres différents : la clé doit donc dériver des données métier qui définissent la demande, jamais d’un identifiant aléatoire généré à chaque essai.
Le retour par webhook
La page de succès n’est pas une preuve de paiement : le client peut la fermer avant la redirection, ou la retrouver dans son historique. Seul le webhook signé fait foi. À checkout.session.completed, le middleware synchronise l’abonnement créé et met à jour le contrat :
// Dans le handler de webhooks : checkout.session.completed
$session = $this->stripe->checkout->sessions->retrieve($message->objectId);
if ($session->subscription !== null) {
$this->subscriptions->sync($session->subscription); // crée Abonnement__c sans attendre l'événement suivant
}
$this->salesforce->request('PATCH', '/sobjects/Contrat__c/' . $session->client_reference_id, [
'json' => ['Lien_Paiement__c' => null, 'Date_Souscription__c' => gmdate('c', $session->created)],
]); Les échéances suivantes suivent le chemin décrit dans Synchroniser les abonnements Stripe dans Salesforce.
Bonnes pratiques
- Prix gérés par Stripe : Salesforce ne transmet qu’un identifiant de prix, jamais un montant ; un montant envoyé par l’appelant peut être falsifié.
- Droits côté Salesforce : requêtes en
USER_MODEet accès à la Named Credential par ensemble d’autorisations. - Tests Apex :
HttpCalloutMocksimule le middleware, réponse 201 comme 500. - Mode test de bout en bout : sandbox Salesforce, middleware de recette et clés de test Stripe ; les cartes de test Stripe couvrent les cas 3-D Secure et refus.
- Lien à durée limitée : une session expirée se régénère d’un clic ; ne pas envoyer de lien permanent par e-mail.