.NET 8.NET 8 · ASP.NET Core · C# 12 · minimal API

Intégrer MonCashConnect avec ASP.NET Core (.NET 8)

Backend C# moderne avec minimal API : HttpClient typé via IHttpClientFactory, raw body via Request.EnableBuffering(), vérification webhook HMAC-SHA256 en temps constant avec CryptographicOperations.FixedTimeEquals.

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

  • .NET SDK 8.0+ (LTS) — testé jusqu'à .NET 9
  • Visual Studio 2022 / Rider / VS Code avec C# Dev Kit
  • Un projet MonCashConnect actif — créer un projet
  • Endpoint webhook accessible en HTTPS (ngrok pour le dev)

Configuration et user-secrets

dotnet new web -n MonCashApp
cd MonCashApp
dotnet user-secrets init
dotnet user-secrets set "MonCash:SecretKey"     "sk_proj_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
dotnet user-secrets set "MonCash:WebhookSecret" "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
// appsettings.json — structure publique, valeurs vides
{
  "Logging": {
    "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
  },
  "AllowedHosts": "*",
  "MonCash": {
    "ApiBase":       "https://hvlmeoqyxaguzcujpmit.supabase.co/functions/v1",
    "SecretKey":     "",
    "WebhookSecret": ""
  }
}
En production, les valeurs viennent de variables d'environnement (MonCash__SecretKey) ou Azure Key Vault. user-secrets est local au profil dev uniquement.

HttpClient typé MonCashClient

// MonCashOptions.cs
public sealed class MonCashOptions
{
    public string ApiBase { get; set; } = "";
    public string SecretKey { get; set; } = "";
    public string WebhookSecret { get; set; } = "";
}

// MonCashClient.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;

public sealed class MonCashClient
{
    private readonly HttpClient _http;

    public MonCashClient(HttpClient http, IConfiguration cfg)
    {
        var opts = cfg.GetSection("MonCash").Get<MonCashOptions>()
            ?? throw new InvalidOperationException("MonCash config missing");

        _http = http;
        _http.BaseAddress = new Uri(opts.ApiBase);
        _http.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", opts.SecretKey);
        _http.Timeout = TimeSpan.FromSeconds(10);
    }

    public async Task<PaymentResponse> CreateAsync(CreatePaymentRequest req,
        string? idempotencyKey = null, CancellationToken ct = default)
    {
        using var msg = new HttpRequestMessage(HttpMethod.Post, "/pay-create")
        {
            Content = JsonContent.Create(req),
        };
        if (idempotencyKey is not null)
        {
            msg.Headers.Add("Idempotency-Key", idempotencyKey);
        }

        using var res = await _http.SendAsync(msg, ct);
        res.EnsureSuccessStatusCode();
        return (await res.Content.ReadFromJsonAsync<PaymentResponse>(ct))!;
    }

    public async Task<PaymentResponse> RetrieveAsync(string reference, CancellationToken ct = default)
    {
        return (await _http.GetFromJsonAsync<PaymentResponse>(
            $"/pay-status?reference={Uri.EscapeDataString(reference)}", ct))!;
    }
}

public sealed record CreatePaymentRequest(
    [property: JsonPropertyName("amount")]        int Amount,
    [property: JsonPropertyName("referenceId")]   string ReferenceId,
    [property: JsonPropertyName("returnUrl")]     string ReturnUrl,
    [property: JsonPropertyName("customerName")]  string? CustomerName  = null,
    [property: JsonPropertyName("customerEmail")] string? CustomerEmail = null);

public sealed record PaymentResponse(
    [property: JsonPropertyName("paymentUrl")] string PaymentUrl,
    [property: JsonPropertyName("reference")]  string Reference,
    [property: JsonPropertyName("status")]     string Status,
    [property: JsonPropertyName("amount")]     int    Amount);

Endpoint create-payment

// Endpoints/Payments.cs
app.MapPost("/api/payments", async (
    CreateBody body,
    MonCashClient mcc,
    CancellationToken ct) =>
{
    if (body.Amount <= 0 || string.IsNullOrEmpty(body.OrderId))
        return Results.BadRequest(new { error = "amount et orderId requis" });

    var payment = await mcc.CreateAsync(new CreatePaymentRequest(
        Amount: body.Amount,
        ReferenceId: body.OrderId,
        ReturnUrl: $"{body.SiteOrigin}/payment/return",
        CustomerEmail: body.Email
    ), idempotencyKey: $"order-{body.OrderId}", ct);

    return Results.Ok(new
    {
        paymentUrl = payment.PaymentUrl,
        reference  = payment.Reference,
    });
});

public sealed record CreateBody(int Amount, string OrderId, string SiteOrigin, string? Email);

Lecture du statut côté serveur

app.MapGet("/payment/return", async (
    string ref_,
    MonCashClient mcc,
    CancellationToken ct) =>
{
    if (string.IsNullOrEmpty(ref_))
        return Results.BadRequest("ref manquant");

    // SOURCE DE VÉRITÉ = appel serveur. Ne JAMAIS croire un statut
    // passé en query string par l'utilisateur.
    var tx = await mcc.RetrieveAsync(ref_, ct);
    return Results.Ok(new { tx.Status, tx.Reference, tx.Amount });
});
La variable s'appelle ref_ car ref est un mot-clé C#. ASP.NET Core mappe automatiquement ?ref=… en query vers ce paramètre.

Webhook — Request.EnableBuffering() + HMACSHA256

Le défi en ASP.NET Core est que le body est un stream à usage unique. Pour calculer le HMAC sur les octets bruts puis désérialiser le JSON, on appelle EnableBuffering() pour pouvoir rewinder le stream.

// Endpoints/Webhook.cs
using System.Security.Cryptography;
using System.Text;

app.MapPost("/webhooks/moncash", async (HttpContext ctx, IConfiguration cfg) =>
{
    // 1. Activer le buffering pour pouvoir relire le body
    ctx.Request.EnableBuffering();

    string rawBody;
    using (var reader = new StreamReader(
        ctx.Request.Body,
        encoding: Encoding.UTF8,
        detectEncodingFromByteOrderMarks: false,
        leaveOpen: true))
    {
        rawBody = await reader.ReadToEndAsync();
    }
    ctx.Request.Body.Position = 0; // rewind pour d'éventuels middlewares aval

    // 2. Récupérer headers de signature
    var signature = ctx.Request.Headers["X-MCC-Signature"].ToString();
    var timestamp = ctx.Request.Headers["X-MCC-Timestamp"].ToString();
    if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp))
        return Results.Unauthorized();

    // 3. Calculer HMAC-SHA256 sur "timestamp.rawBody"
    var secret = cfg["MonCash:WebhookSecret"]
        ?? throw new InvalidOperationException("WebhookSecret missing");
    var payload = $"{timestamp}.{rawBody}";

    byte[] expected;
    using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)))
    {
        expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
    }

    // 4. Décoder la signature hex reçue
    byte[] received;
    try { received = Convert.FromHexString(signature); }
    catch (FormatException) { return Results.Unauthorized(); }

    // 5. Comparaison en temps constant
    if (!CryptographicOperations.FixedTimeEquals(received, expected))
        return Results.Unauthorized();

    // 6. Maintenant on peut désérialiser en toute confiance
    var evt = System.Text.Json.JsonSerializer.Deserialize<WebhookEvent>(rawBody);
    if (evt is null) return Results.BadRequest();

    // 7. Idempotence — stockez evt.Id en base avec UNIQUE INDEX
    // if (await db.ProcessedWebhooks.AnyAsync(p => p.EventId == evt.Id))
    //     return Results.Ok();

    switch (evt.Event)
    {
        case "payment.completed":
            // MarkOrderPaid(evt.Reference);
            break;
        case "payment.failed":
            // MarkOrderFailed(evt.Reference);
            break;
    }

    return Results.Ok();
});

public sealed record WebhookEvent(
    [property: JsonPropertyName("id")]        string Id,
    [property: JsonPropertyName("event")]     string Event,
    [property: JsonPropertyName("reference")] string Reference,
    [property: JsonPropertyName("amount")]    int    Amount);
N'utilisez jamais == ni SequenceEqual pour comparer la signature. CryptographicOperations.FixedTimeEquals est la seule méthode garantie en temps constant pour éviter les attaques par mesure de timing.

Program.cs complet

// Program.cs
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

// HttpClient typé — IHttpClientFactory gère le pooling
builder.Services.AddHttpClient<MonCashClient>();

var app = builder.Build();

app.MapPost("/api/payments", async (
    CreateBody body, MonCashClient mcc, CancellationToken ct) =>
{
    if (body.Amount <= 0 || string.IsNullOrEmpty(body.OrderId))
        return Results.BadRequest(new { error = "amount et orderId requis" });

    var payment = await mcc.CreateAsync(new CreatePaymentRequest(
        Amount: body.Amount,
        ReferenceId: body.OrderId,
        ReturnUrl: $"{body.SiteOrigin}/payment/return",
        CustomerEmail: body.Email
    ), idempotencyKey: $"order-{body.OrderId}", ct);

    return Results.Ok(new { payment.PaymentUrl, payment.Reference });
});

app.MapGet("/payment/return", async (string ref_, MonCashClient mcc, CancellationToken ct) =>
{
    var tx = await mcc.RetrieveAsync(ref_, ct);
    return Results.Ok(tx);
});

// Voir la section précédente pour le handler webhook complet

app.Run();
Configurez https://votresite.com/webhooks/moncash comme URL webhook dans le dashboard MonCashConnect. En dev, dotnet run sur le port HTTPS local puis exposez avec ngrok.

Liste de contrôle avant production

MonCash:SecretKey et MonCash:WebhookSecret viennent de l'environnement (pas appsettings.json en clair)

AddHttpClient<MonCashClient> est utilisé — pas de new HttpClient() à la main

HttpClient.Timeout est défini (10 secondes) sur le client typé

Request.EnableBuffering() est appelé AVANT toute lecture de Request.Body

Request.Body.Position = 0 après lecture pour les middlewares aval

CryptographicOperations.FixedTimeEquals est utilisé (jamais == ou SequenceEqual sur des bytes)

Idempotency-Key envoyé sur CreateAsync (par exemple 'order-' + orderId)

Une table ProcessedWebhooks avec UNIQUE INDEX sur EventId garantit l'idempotence

Le endpoint webhook répond 200 OK en moins de 5 s (travail lourd via IHostedService ou queue)

HTTPS est forcé en prod (UseHttpsRedirection ou reverse proxy)

Les exceptions HttpClient sont catchées et loggées — pas de 500 brut au client

FAQ

Pourquoi EnableBuffering() ?+
Par défaut, Request.Body est un stream non-rewindable. Vous le lisez une fois et il est consommé. EnableBuffering() le bufferise en mémoire (ou disque selon la taille) pour pouvoir le rejouer après vérification HMAC.
CryptographicOperations.FixedTimeEquals ou ==+
Toujours FixedTimeEquals. La comparaison == peut court-circuiter dès qu'un octet diffère, ce qui fuit l'information de timing. FixedTimeEquals existe précisément pour les comparaisons cryptographiques.
Convert.ToHexString existe en .NET 8 ?+
Oui, depuis .NET 5. Convert.ToHexString(byte[]) renvoie une string hex majuscule. On la ToLowerInvariant() pour matcher le format MonCashConnect (lowercase).
Minimal API ou controllers MVC ?+
Les deux fonctionnent. Le code montré est en minimal API (Map* dans Program.cs) car plus concis. Pour controllers, transposez les endpoints en [ApiController] avec [HttpPost(...)] et [FromBody].
Comment stocker les clés en production ?+
Azure Key Vault, AWS Secrets Manager, ou variables d'environnement. dotnet user-secrets est strictement dev — il ne s'applique qu'en Environment=Development.
Compatible avec Blazor Server ou Razor Pages ?+
Oui. La logique de service (MonCashClient) est indépendante du modèle de présentation. Ajoutez juste le services.AddHttpClient<MonCashClient>() dans le pipeline Blazor/Razor habituel.