dotnet-9.0aspnetcore-9.0paystack-webhooks-v1Handling and Verifying Paystack Webhooks in ASP.NET Core
Polling API endpoints for payment status updates is inefficient and unreliable. Paystack pushes transaction status updates to your backend via HTTP webhooks. However, accepting unverified webhooks exposes your application to spoofing and fraud.
This tutorial demonstrates how to build a secure, constant-time HMAC-SHA512 webhook signature verification middleware and minimal API endpoint in ASP.NET Core 9.0.
1. How Paystack Webhook Verification Works
Paystack signs every HTTP POST webhook payload using your account’s secret key:
- Header Name:
x-paystack-signature. - Algorithm: HMAC-SHA512.
- Payload: The exact, unparsed raw JSON request body bytes.
- Validation Rule: Compute HMAC-SHA512 over the raw body bytes using your Paystack Secret Key, convert to a hex string, and compare against
x-paystack-signature.
[!CAUTION] You MUST compute the HMAC signature using the raw, unparsed request body string. Deserializing and re-serializing JSON will alter whitespace and key ordering, causing signature validation to fail.
2. Signature Verifier Helper Service
Use System.Security.Cryptography.HMACSHA512 and CryptographicOperations.FixedTimeEquals to prevent timing attack vulnerabilities.
namespace Tofali.Payments.Paystack.Security;
using System.Security.Cryptography;
using System.Text;
public interface IPaystackWebhookVerifier
{
bool VerifySignature(ReadOnlySpan<byte> bodyBytes, string? signatureHeader, string secretKey);
}
public sealed class PaystackWebhookVerifier : IPaystackWebhookVerifier
{
public bool VerifySignature(ReadOnlySpan<byte> bodyBytes, string? signatureHeader, string secretKey)
{
if (string.IsNullOrWhiteSpace(signatureHeader) || string.IsNullOrWhiteSpace(secretKey))
{
return false;
}
var keyBytes = Encoding.UTF8.GetBytes(secretKey);
// Compute HMAC-SHA512 directly on raw body bytes without string re-encoding
Span<byte> hashBytes = stackalloc byte[64];
if (!HMACSHA512.TryHashData(keyBytes, bodyBytes, hashBytes, out var bytesWritten) || bytesWritten != 64)
{
return false;
}
var computedHex = Convert.ToHexStringLower(hashBytes);
var headerBytes = Encoding.UTF8.GetBytes(signatureHeader.Trim().ToLowerInvariant());
var computedBytes = Encoding.UTF8.GetBytes(computedHex);
// Constant-time byte comparison prevents timing side-channel attacks
return CryptographicOperations.FixedTimeEquals(headerBytes, computedBytes);
}
}
3. Minimal API Webhook Endpoint
Implement an endpoint that reads the raw HTTP request stream without automatic framework JSON binding:
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
using Tofali.Payments.Paystack.Security;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IPaystackWebhookVerifier, PaystackWebhookVerifier>();
var app = builder.Build();
app.MapPost("/api/webhooks/paystack", async (
HttpContext httpContext,
[FromServices] IPaystackWebhookVerifier verifier,
[FromServices] IConfiguration config,
[FromServices] ILogger<Program> logger) =>
{
// 1. Extract signature header
if (!httpContext.Request.Headers.TryGetValue("x-paystack-signature", out var signatureHeader))
{
logger.LogWarning("Paystack webhook request missing x-paystack-signature header.");
return Results.Unauthorized();
}
// 2. Read raw request body bytes
httpContext.Request.EnableBuffering();
using var ms = new MemoryStream();
await httpContext.Request.Body.CopyToAsync(ms);
var bodyBytes = ms.ToArray();
httpContext.Request.Body.Position = 0;
// 3. Verify signature using raw bytes
var secretKey = config["Paystack:SecretKey"] ?? string.Empty;
if (!verifier.VerifySignature(bodyBytes, signatureHeader, secretKey))
{
logger.LogWarning("Invalid Paystack webhook signature header: {Signature}", (string?)signatureHeader);
return Results.Unauthorized();
}
// 4. Parse verified event payload safely
using var jsonDoc = JsonDocument.Parse(bodyBytes);
var root = jsonDoc.RootElement;
var eventType = root.GetProperty("event").GetString();
logger.LogInformation("Verified Paystack webhook event received: {Event}", eventType);
if (!string.IsNullOrEmpty(eventType))
{
var data = root.GetProperty("data");
var reference = data.GetProperty("reference").GetString();
var amountInKobo = data.GetProperty("amount").GetInt64();
logger.LogInformation("Processing successful payment event ({Event}) for reference {Reference}, Amount: {Kobo} kobo", eventType, reference, amountInKobo);
// TODO: Dispatch domain event or update database transaction status
}
// Always respond with HTTP 200 OK promptly to acknowledge delivery
return Results.Ok(new { status = "success" });
});
app.Run();
4. Webhook Security Checklist
- HTTPS Mandatory: Paystack will only deliver production webhooks over TLS/HTTPS.
- Fast HTTP 200 Response: Process webhooks asynchronously (or queue them) if your processing takes longer than 3 seconds. Paystack considers non-200 responses as delivery failures and retries delivery.
- Idempotency: Webhook events may be delivered more than once by Paystack’s retry mechanism. Always ensure your database update logic handles duplicate event delivery safely using the transaction
reference.
5. Official Provenance
- Paystack Webhooks Guide: https://paystack.com/docs/payments/webhooks/
- Signature Verification Reference: https://paystack.com/docs/api
- Last Tested: August 22, 2026 UTC