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.
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": ""
}
}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 });
});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);== 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();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() ?+
CryptographicOperations.FixedTimeEquals ou ==+
Convert.ToHexString existe en .NET 8 ?+
Minimal API ou controllers MVC ?+
Comment stocker les clés en production ?+
Compatible avec Blazor Server ou Razor Pages ?+
Lectures recommandées :