Intégrer MonCashConnect en Go (Golang)
Backend Go natif avec la bibliothèque standard : net/http pour appeler /pay-create, crypto/hmac + crypto/sha256 pour vérifier les webhooks signés, et gestion d'idempotence prête à mettre en production.
Commencez en Sandbox
Avant d'implémenter le code ci-dessous avec votre clé live (sk_proj_…), testez d'abord en mode Sandbox avec sk_test_proj_…. Le code est identique — vous ne changez que la valeur de la variable d'environnement. Lisez le Guide Sandbox avant de continuer.
Prérequis
- Go 1.21+ (les exemples utilisent
log/slog) - Un projet MonCashConnect avec clé secrète — créer un projet
- Un endpoint webhook accessible en HTTPS (ngrok pour le dev local)
Configuration et constantes
# .env
MCC_SECRET_KEY=sk_proj_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MCC_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx// internal/moncash/client.go
package moncash
import (
"net/http"
"os"
"time"
)
const APIBase = "https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1"
type Client struct {
HTTP *http.Client
SecretKey string
WebhookSecret string
}
func NewFromEnv() *Client {
return &Client{
HTTP: &http.Client{Timeout: 10 * time.Second},
SecretKey: os.Getenv("MCC_SECRET_KEY"),
WebhookSecret: os.Getenv("MCC_WEBHOOK_SECRET"),
}
}Client HTTP réutilisable
Une méthode privée doRequest centralise l'auth, le JSON encoding et la gestion d'erreur HTTP. Toutes les méthodes publiques (create, retrieve) la réutilisent :
// internal/moncash/http.go
package moncash
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type apiError struct {
StatusCode int
Message string
}
func (e *apiError) Error() string {
return fmt.Sprintf("moncashconnect: %d %s", e.StatusCode, e.Message)
}
func (c *Client) doRequest(ctx context.Context, method, path string,
body any, idempotencyKey string, out any) error {
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil { return err }
reader = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, APIBase+path, reader)
if err != nil { return err }
req.Header.Set("Authorization", "Bearer "+c.SecretKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := c.HTTP.Do(req)
if err != nil { return err }
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil { return err }
if resp.StatusCode >= 400 {
return &apiError{StatusCode: resp.StatusCode, Message: string(respBody)}
}
if out == nil { return nil }
return json.Unmarshal(respBody, out)
}Créer un paiement
// internal/moncash/payments.go
package moncash
import "context"
type CreatePaymentInput struct {
Amount int `json:"amount"`
ReferenceID string `json:"referenceId"`
ReturnURL string `json:"returnUrl"`
CustomerName string `json:"customerName,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
}
type Payment struct {
PaymentURL string `json:"paymentUrl"`
Reference string `json:"reference"`
Status string `json:"status"`
Amount int `json:"amount"`
}
func (c *Client) CreatePayment(ctx context.Context,
in CreatePaymentInput, idempotencyKey string) (*Payment, error) {
var out Payment
if err := c.doRequest(ctx, "POST", "/pay-create", in, idempotencyKey, &out); err != nil {
return nil, err
}
return &out, nil
}Utilisation dans un handler HTTP :
// cmd/server/main.go
package main
import (
"encoding/json"
"log/slog"
"net/http"
"yourapp/internal/moncash"
)
var mcc = moncash.NewFromEnv()
func checkoutHandler(w http.ResponseWriter, r *http.Request) {
orderID := r.URL.Query().Get("order_id")
if orderID == "" {
http.Error(w, "order_id required", http.StatusBadRequest)
return
}
payment, err := mcc.CreatePayment(r.Context(), moncash.CreatePaymentInput{
Amount: 500,
ReferenceID: orderID,
ReturnURL: "https://votresite.com/payment/return",
}, "create-"+orderID) // idempotency-key déterministe par commande
if err != nil {
slog.Error("mcc create failed", "err", err)
http.Error(w, "payment init failed", http.StatusBadGateway)
return
}
http.Redirect(w, r, payment.PaymentURL, http.StatusSeeOther)
}
func main() {
http.HandleFunc("/checkout", checkoutHandler)
http.HandleFunc("/webhooks/moncash", webhookHandler)
slog.Info("listening on :8080")
http.ListenAndServe(":8080", nil)
}Lecture du statut
// internal/moncash/payments.go (suite)
func (c *Client) RetrievePayment(ctx context.Context, reference string) (*Payment, error) {
var out Payment
if err := c.doRequest(ctx, "GET", "/pay-status?reference="+reference, nil, "", &out); err != nil {
return nil, err
}
return &out, nil
}/payment/return?ref=…) doit appeler RetrievePayment côté serveur. Ne faites jamais confiance à un statut "completed" passé en query string par MonCash — un utilisateur peut le falsifier.Webhook — vérification HMAC-SHA256
La signature est calculée sur timestamp + "." + rawBody avec le secret du webhook. Lisez le body en premier — décoder le JSON avant invalide la signature.
// internal/moncash/webhook.go
package moncash
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
)
type Event struct {
ID string `json:"id"`
Event string `json:"event"`
Reference string `json:"reference"`
Amount int `json:"amount"`
Data json.RawMessage `json:"data"`
}
var ErrInvalidSignature = errors.New("moncashconnect: invalid webhook signature")
// ConstructEvent vérifie la signature HMAC en temps constant et
// décode l'événement. NE LIT PAS le body — passez les bytes bruts.
func (c *Client) ConstructEvent(rawBody []byte, signature, timestamp string) (*Event, error) {
mac := hmac.New(sha256.New, []byte(c.WebhookSecret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
got, err := hex.DecodeString(signature)
if err != nil { return nil, ErrInvalidSignature }
want, _ := hex.DecodeString(expected)
if !hmac.Equal(got, want) {
return nil, ErrInvalidSignature
}
var evt Event
if err := json.Unmarshal(rawBody, &evt); err != nil {
return nil, err
}
return &evt, nil
}Handler net/http :
// cmd/server/webhook.go
package main
import (
"errors"
"io"
"log/slog"
"net/http"
"yourapp/internal/moncash"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-MCC-Signature")
timestamp := r.Header.Get("X-MCC-Timestamp")
evt, err := mcc.ConstructEvent(rawBody, signature, timestamp)
if err != nil {
if errors.Is(err, moncash.ErrInvalidSignature) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
http.Error(w, "bad event", http.StatusBadRequest)
return
}
// Idempotence — voir section suivante
if alreadySeen(evt.ID) {
w.WriteHeader(http.StatusOK)
return
}
markSeen(evt.ID)
switch evt.Event {
case "payment.completed":
slog.Info("paid", "ref", evt.Reference, "amount", evt.Amount)
// markOrderPaid(evt.Reference)
case "payment.failed":
slog.Warn("failed", "ref", evt.Reference)
// markOrderFailed(evt.Reference)
}
w.WriteHeader(http.StatusOK)
}200 OK rapidement. Si votre logique de livraison prend > 5 secondes, déposez-la dans une file (Redis, NATS, channel goroutine) et acknowledgez le webhook immédiatement.Idempotency-Key et déduplication
Deux protections différentes utilisent le même nom :
- Vous → MonCashConnect : header
Idempotency-Keyà l'envoi pour empêcher de créer deux paiements si votre serveur retry. - MonCashConnect → vous : ignorer un
event.iddéjà traité au cas où le webhook est retransmis (timeout réseau).
// cmd/server/idempotency.go
package main
import "sync"
// Pour la prod, remplacez par Redis ou une table avec UNIQUE INDEX sur event_id.
var (
seenMu sync.Mutex
seen = make(map[string]bool, 1024)
)
func alreadySeen(eventID string) bool {
seenMu.Lock()
defer seenMu.Unlock()
return seen[eventID]
}
func markSeen(eventID string) {
seenMu.Lock()
defer seenMu.Unlock()
seen[eventID] = true
}SETNX event:<id> 1 EX 86400) ou une contrainte UNIQUE en base — sinon deux replicas peuvent traiter le même event en parallèle.Liste de contrôle avant production
MCC_SECRET_KEY et MCC_WEBHOOK_SECRET viennent d'os.Getenv (pas en dur)
Un *http.Client unique avec Timeout: 10*time.Second est partagé par toutes les requêtes
Tous les appels utilisent http.NewRequestWithContext (propagation du context)
io.ReadAll(r.Body) est appelé AVANT toute tentative de json.Unmarshal sur le webhook
hmac.Equal est utilisé pour la comparaison de signature (jamais bytes.Equal ou ==)
Idempotency-Key est envoyé sur /pay-create (par exemple 'create-'+orderID)
Les event.id reçus sont stockés (Redis ou base) pour ignorer les replays
Le handler webhook renvoie 200 OK en moins de 5 secondes — travail lourd en queue
TLS 1.2+ sur le serveur exposant /webhooks/moncash (let's encrypt ou reverse proxy)
Tests unitaires pour ConstructEvent avec une signature factice connue
FAQ
Existe-t-il un SDK Go officiel ?+
Pourquoi hmac.Equal et pas bytes.Equal ?+
Faut-il un context.Context pour les appels HTTP ?+
Comment lire le body brut dans un handler net/http ?+
L'Idempotency-Key est-elle obligatoire ?+
Compatible avec Gin, Echo, Chi ?+
Lectures recommandées :