GoGo 1.21+ · net/http · crypto/hmac · zéro dépendance

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.

Sandbox

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
}
La page de retour utilisateur (/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)
}
Toujours renvoyer 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.id dé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
}
La map en mémoire ci-dessus est un exemple. En production multi-instance, utilisez Redis (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 ?+
Non pour l'instant. Le SDK officiel couvre Node (@moncashconnect/sdk) et Python (moncashconnect). En Go, vous utilisez net/http directement — c'est volontairement ~150 lignes lisibles plutôt qu'une dépendance.
Pourquoi hmac.Equal et pas bytes.Equal ?+
bytes.Equal a un short-circuit qui fuit l'information de timing — un attaquant peut deviner la signature octet par octet en mesurant les temps de réponse. hmac.Equal compare en temps constant.
Faut-il un context.Context pour les appels HTTP ?+
Oui — utilisez http.NewRequestWithContext avec un context.WithTimeout pour propager les annulations et éviter des goroutines bloquées si MonCashConnect est lent.
Comment lire le body brut dans un handler net/http ?+
io.ReadAll(r.Body) puis r.Body.Close(). Mais ne décodez pas JSON avant la vérification HMAC — la signature est calculée sur les octets exacts envoyés.
L'Idempotency-Key est-elle obligatoire ?+
Non, mais fortement recommandée. Si votre serveur retry après timeout, l'Idempotency-Key garantit que MonCashConnect ne créera pas deux paiements pour la même commande.
Compatible avec Gin, Echo, Chi ?+
Oui — la logique HMAC reste identique. Adaptez juste l'extraction des headers (c.GetHeader en Gin, c.Request().Header.Get en Echo) et la lecture du raw body.