Handle Paystack Webhooks in ASP.NET Core
Create a POST action in your PaymentController that reads the raw request body as a string, computes an HMAC SHA512 hash using your Paystack secret key, and compares it to the x-paystack-signature request header. Return HTTP 200 immediately and process the event asynchronously. Enable request body buffering to allow reading the body in ASP.NET Core.
Webhook Controller
// Controllers/WebhookController.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
[IgnoreAntiforgeryToken] // Paystack cannot send CSRF tokens
public class WebhookController : ControllerBase
{
private readonly IConfiguration _config;
private readonly ILogger<WebhookController> _logger;
public WebhookController(IConfiguration config, ILogger<WebhookController> logger)
{
_config = config;
_logger = logger;
}
[HttpPost("paystack")]
public async Task<IActionResult> Paystack()
{
// Read raw body
Request.EnableBuffering();
string rawBody;
using (var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true))
{
rawBody = await reader.ReadToEndAsync();
}
// Verify signature
var signature = Request.Headers["x-paystack-signature"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
return BadRequest("Missing signature");
var secretKey = _config["Paystack:SecretKey"]!;
var keyBytes = Encoding.UTF8.GetBytes(secretKey);
var bodyBytes = Encoding.UTF8.GetBytes(rawBody);
using var hmac = new HMACSHA512(keyBytes);
var hashBytes = hmac.ComputeHash(bodyBytes);
var hash = Convert.ToHexString(hashBytes).ToLower();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(hash),
Encoding.UTF8.GetBytes(signature)))
{
_logger.LogWarning("Invalid Paystack webhook signature");
return BadRequest("Invalid signature");
}
var webhookEvent = JsonSerializer.Deserialize<JsonElement>(rawBody);
var eventType = webhookEvent.GetProperty("event").GetString();
var eventData = webhookEvent.GetProperty("data");
_logger.LogInformation("Paystack webhook received: {EventType}", eventType);
// Return 200 immediately
_ = Task.Run(() => ProcessEventAsync(eventType!, eventData));
return Ok(new { received = true });
}
private async Task ProcessEventAsync(string eventType, JsonElement data)
{
try
{
switch (eventType)
{
case "charge.success":
await HandleChargeSuccess(data);
break;
case "transfer.success":
await HandleTransferSuccess(data);
break;
case "transfer.failed":
_logger.LogWarning("Transfer failed: {Reference}", data.GetProperty("reference").GetString());
break;
default:
_logger.LogInformation("Unhandled event: {EventType}", eventType);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Paystack event {EventType}", eventType);
}
}
private Task HandleChargeSuccess(JsonElement data)
{
var reference = data.GetProperty("reference").GetString();
var amount = data.GetProperty("amount").GetInt64();
var currency = data.GetProperty("currency").GetString();
_logger.LogInformation("Payment confirmed: {Reference} {Amount} {Currency}",
reference, amount / 100m, currency);
// Update order in database
return Task.CompletedTask;
}
private Task HandleTransferSuccess(JsonElement data)
{
var reference = data.GetProperty("reference").GetString();
_logger.LogInformation("Transfer successful: {Reference}", reference);
return Task.CompletedTask;
}
}
Enable Raw Body Reading
ASP.NET Core reads the request body once. To read it as raw text for signature verification, call Request.EnableBuffering() before reading. This rewinds the stream so model binding can still work if needed.
For Minimal APIs, configure the endpoint to allow reading the body:
// Program.cs (Minimal API approach)
app.MapPost("/api/webhooks/paystack", async (HttpContext context, IConfiguration config) =>
{
context.Request.EnableBuffering();
string rawBody;
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true))
{
rawBody = await reader.ReadToEndAsync();
}
var signature = context.Request.Headers["x-paystack-signature"].FirstOrDefault();
var secretKey = config["Paystack:SecretKey"]!;
using var hmac = new HMACSHA512(Encoding.UTF8.GetBytes(secretKey));
var hash = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody))).ToLower();
if (signature != hash)
return Results.BadRequest("Invalid signature");
var evt = JsonSerializer.Deserialize<JsonElement>(rawBody);
// Process event...
return Results.Ok(new { received = true });
}).DisableAntiforgery();
Learn More
This guide is part of the Paystack integration guides by language and framework series.
Key Takeaways
- ✓Read the raw request body with StreamReader before any model binding. Model binding will read and discard the stream.
- ✓Compute HMAC SHA512 using System.Security.Cryptography.HMACSHA512 with your secret key as UTF8 bytes.
- ✓The x-paystack-signature header contains the hex-encoded HMAC. Compare strings in constant time to prevent timing attacks.
- ✓Return HTTP 200 immediately. Use a background task or queue for event processing to avoid timeout.
- ✓Disable anti-forgery validation on the webhook action — Paystack cannot send CSRF tokens.
- ✓Store processed event references in your database to prevent duplicate processing on retries.
Frequently Asked Questions
- Why does my Paystack webhook signature verification fail in ASP.NET Core?
- The most common cause is reading the request body after model binding has already consumed it. Use Request.EnableBuffering() and read with StreamReader before any other body access. The second common cause is using a different encoding for the body bytes. Always use UTF8.
- Do I need to disable CSRF protection for the Paystack webhook endpoint?
- Yes. Add [IgnoreAntiforgeryToken] to your controller or action. Paystack sends a raw POST request without CSRF tokens. For Minimal APIs, call .DisableAntiforgery() on the endpoint.
- Should I process webhook events synchronously or asynchronously?
- Return 200 immediately and process asynchronously. Paystack has a timeout after which it will retry if it does not receive a 200. Heavy database operations should be queued (BullMQ equivalent: use IBackgroundTaskQueue or Hangfire in .NET).
- How do I prevent double-processing of Paystack webhook events?
- Store the transaction reference in a processed_webhooks table when you first handle an event. Before processing, check if the reference already exists. If it does, return 200 without processing again.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon