<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Popoola Sinaayo</title>
    <description>The latest articles on DEV Community by Popoola Sinaayo (@ayopee).</description>
    <link>https://dev.to/ayopee</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F860070%2Face2e332-f8da-48f1-9dcc-b15ba8b9d275.jpg</url>
      <title>DEV Community: Popoola Sinaayo</title>
      <link>https://dev.to/ayopee</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ayopee"/>
    <language>en</language>
    <item>
      <title>How to verify Paystack, Flutterwave, Monnify and Squad webhooks correctly in Node.js and Python (plus a Monnify docs bug)</title>
      <dc:creator>Popoola Sinaayo</dc:creator>
      <pubDate>Thu, 12 Nov 2026 15:28:21 +0000</pubDate>
      <link>https://dev.to/ayopee/how-to-verify-paystack-flutterwave-monnify-and-squad-webhooks-correctly-in-nodejs-and-python-684</link>
      <guid>https://dev.to/ayopee/how-to-verify-paystack-flutterwave-monnify-and-squad-webhooks-correctly-in-nodejs-and-python-684</guid>
      <description>&lt;p&gt;If you've integrated more than one Nigerian or African payment provider, you've written the same webhook-verification&lt;br&gt;
code several times, and probably got it subtly wrong at least once. I did. So I tested how each provider &lt;em&gt;actually&lt;/em&gt;&lt;br&gt;
signs its webhooks against their own documentation, and packaged the result as an open-source library.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why webhook verification matters (and fails)&lt;/li&gt;
&lt;li&gt;How Paystack, Flutterwave, Monnify, Squad, Korapay, Fincra, Interswitch and Nomba each sign webhooks&lt;/li&gt;
&lt;li&gt;The two bugs in provider documentation I found while testing&lt;/li&gt;
&lt;li&gt;A one-line way to do it correctly in Node.js, Next.js, NestJS, Express, Django, Flask or FastAPI&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Why verification matters
&lt;/h2&gt;

&lt;p&gt;Your webhook URL is public. Anyone who finds it can &lt;code&gt;POST&lt;/code&gt; a fake &lt;code&gt;charge.success&lt;/code&gt; and, if you don't verify the&lt;br&gt;
signature, get goods for free. Every provider signs its webhooks so you can prove they're genuine. The catch: they all&lt;br&gt;
do it differently.&lt;/p&gt;
&lt;h2&gt;
  
  
  The #1 bug: verifying a parsed body
&lt;/h2&gt;

&lt;p&gt;Signatures are computed over the &lt;strong&gt;exact bytes&lt;/strong&gt; the provider sent. Most frameworks parse JSON before your handler runs.&lt;br&gt;
If you then do &lt;code&gt;JSON.stringify(req.body)&lt;/code&gt; and hash that, it &lt;em&gt;usually&lt;/em&gt; matches. Then one day a payload contains a forward&lt;br&gt;
slash, a unicode character or a float formatted differently, and verification starts failing (or you disable it "for&lt;br&gt;
now").&lt;/p&gt;

&lt;p&gt;Keep the raw body:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Express&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;express.raw({ type: 'application/json' })&lt;/code&gt; on the webhook route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NestJS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NestFactory.create(AppModule, { rawBody: true })&lt;/code&gt;, then &lt;code&gt;req.rawBody&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastify&lt;/td&gt;
&lt;td&gt;a buffer content-type parser in a scoped plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js App Router / Hono / Workers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await request.arrayBuffer()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js Pages Router&lt;/td&gt;
&lt;td&gt;&lt;code&gt;export const config = { api: { bodyParser: false } }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;request.body&lt;/code&gt; (already raw)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FastAPI&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;await request.body()&lt;/code&gt;; don't declare a Pydantic body on that route&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  How each provider signs webhooks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;What's signed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Paystack&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x-paystack-signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA512 (hex), secret key&lt;/td&gt;
&lt;td&gt;raw body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flutterwave v3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;verif-hash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none; it's your dashboard "secret hash"&lt;/td&gt;
&lt;td&gt;nothing (!)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flutterwave v4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flutterwave-signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256 (base64), secret hash&lt;/td&gt;
&lt;td&gt;raw body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monnify&lt;/td&gt;
&lt;td&gt;&lt;code&gt;monnify-signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA512 (hex), client secret&lt;/td&gt;
&lt;td&gt;raw body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Squad&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x-squad-encrypted-body&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA512 (hex, upper-case), secret key&lt;/td&gt;
&lt;td&gt;raw body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Korapay&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x-korapay-signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256 (hex), secret key&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;only the &lt;code&gt;data&lt;/code&gt; object&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fincra&lt;/td&gt;
&lt;td&gt;&lt;code&gt;signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA512 (hex), &lt;strong&gt;webhook&lt;/strong&gt; secret (not your API key)&lt;/td&gt;
&lt;td&gt;payload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interswitch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-Interswitch-Signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA512 (hex), webhook secret&lt;/td&gt;
&lt;td&gt;raw body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nomba&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nomba-signature&lt;/code&gt; + &lt;code&gt;nomba-timestamp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HMAC-SHA256 (base64), signature key&lt;/td&gt;
&lt;td&gt;selected fields + timestamp&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flutterwave v3's &lt;code&gt;verif-hash&lt;/code&gt; doesn't protect the body.&lt;/strong&gt; It's a shared secret sent as a header. Anyone who learns
it can send any payload. Always re-verify the transaction with Flutterwave's API (&lt;code&gt;GET /v3/transactions/{id}/verify&lt;/code&gt;)
before giving value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paystack&lt;/strong&gt; only sends webhooks from &lt;code&gt;52.31.139.75&lt;/code&gt;, &lt;code&gt;52.49.173.169&lt;/code&gt; and &lt;code&gt;52.214.14.220&lt;/code&gt;, and retries for up to
72 hours, so you &lt;em&gt;will&lt;/em&gt; receive duplicates. Make your handler idempotent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Korapay&lt;/strong&gt; signs &lt;code&gt;JSON.stringify(body.data)&lt;/code&gt;, not the whole body, so you must extract &lt;code&gt;data&lt;/code&gt; exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monnify sandbox webhooks are not signed at all.&lt;/strong&gt; Code that "works in sandbox" may have never verified anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nomba&lt;/strong&gt; is the only one here that signs a timestamp (so you can reject replays), but it doesn't sign the amount,
so re-verify via the API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amounts use different units:&lt;/strong&gt; Paystack and Squad send kobo; Flutterwave, Monnify, Korapay, Fincra and Nomba send
naira. Mixing them up is a 100× bug.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Two documentation bugs I found
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Monnify's docs describe the wrong algorithm.&lt;/strong&gt; The prose says the signature is "SHA-512(client secret + request&lt;br&gt;
body)". But Monnify's own published example hash only reproduces as &lt;strong&gt;HMAC-SHA512&lt;/strong&gt; of the body keyed with the client&lt;br&gt;
secret. If you implement the prose, no real webhook will ever verify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Nomba's example signature has the wrong case.&lt;/strong&gt; The example in the docs starts with a lower-case &lt;code&gt;q&lt;/code&gt;; computing&lt;br&gt;
it gives an upper-case &lt;code&gt;Q&lt;/code&gt;. Nomba's sample code compares signatures case-insensitively, which is why their example&lt;br&gt;
"works". If you compare base64 strictly, the documented example fails.&lt;/p&gt;

&lt;p&gt;Both are now covered by automated tests in the library (the tests assert the published vectors reproduce, and that&lt;br&gt;
Monnify's prose formula does &lt;em&gt;not&lt;/em&gt;).&lt;/p&gt;
&lt;h2&gt;
  
  
  Doing it in one line
&lt;/h2&gt;

&lt;p&gt;I packaged all of this as &lt;strong&gt;&lt;a href="https://github.com/evergreenesoftware/africa-payments-webhooks" rel="noopener noreferrer"&gt;africa-payments-webhooks&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
It's MIT-licensed, has zero dependencies, and works with JavaScript/TypeScript and Python.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm install africa-payments-webhooks
&lt;span class="c"&gt;# or&lt;/span&gt;
pip install africa-payments-webhooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Next.js App Router (also Hono, Cloudflare Workers, Deno, Bun):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/webhooks/paystack/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fetchWebhook&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'africa-payments-webhooks/web'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetchWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'paystack'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;PAYSTACK_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'payment.succeeded'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// event.reference, event.amount → { value: 500000, unit: 'minor', currency: 'NGN' }&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markOrderPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Express:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'express'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;expressWebhook&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'africa-payments-webhooks/express'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'/webhooks/monnify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;expressWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'monnify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;MONNIFY_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Several providers on one endpoint.&lt;/strong&gt; The provider is detected from the headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;fetchWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'paystack'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'flutterwave'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'monnify'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;paystack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;PAYSTACK_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flutterwave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;FLW_SECRET_HASH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monnify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;MONNIFY_SECRET&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Django:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;africa_payments_webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adapters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;django&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;webhook_view&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"payment.succeeded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;mark_order_paid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;urlpatterns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"webhooks/paystack/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;webhook_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"paystack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;PAYSTACK_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_event&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;on_event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every provider produces the same event shape (&lt;code&gt;payment.succeeded&lt;/code&gt;, &lt;code&gt;payment.failed&lt;/code&gt;, &lt;code&gt;transfer.succeeded&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;refund.processed&lt;/code&gt;, …) with the amount &lt;em&gt;and its unit&lt;/em&gt;. The handler also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;returns the right HTTP response for each provider (Interswitch wants a 200 with an empty body, for example);&lt;/li&gt;
&lt;li&gt;rejects already-parsed bodies with a clear &lt;code&gt;body_already_parsed&lt;/code&gt; error instead of failing silently;&lt;/li&gt;
&lt;li&gt;deduplicates retries if you give it a store (Redis/Postgres recipes are in the docs);&lt;/li&gt;
&lt;li&gt;supports secret rotation (&lt;code&gt;secret: [newKey, oldKey]&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;includes &lt;code&gt;signTestPayload()&lt;/code&gt; so you can unit-test your own handler with genuinely signed fake webhooks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It runs on Node.js 20+, Bun, Deno, Cloudflare Workers, Vercel and AWS Lambda, with adapters for Express, Fastify,&lt;br&gt;
NestJS, Koa, Next.js and Hono, plus Django, Flask and FastAPI on the Python side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still re-verify before giving value
&lt;/h2&gt;

&lt;p&gt;A valid signature proves the webhook came from the provider. It doesn't prove the customer paid what you expected.&lt;br&gt;
Before fulfilling an order, call the provider's verify endpoint and check the amount, currency, status and reference.&lt;br&gt;
Webhooks tell you &lt;em&gt;something happened&lt;/em&gt;; the API tells you &lt;em&gt;exactly what&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/evergreenesoftware/africa-payments-webhooks" rel="noopener noreferrer"&gt;https://github.com/evergreenesoftware/africa-payments-webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://evergreenesoftware.github.io/africa-payments-webhooks/" rel="noopener noreferrer"&gt;https://evergreenesoftware.github.io/africa-payments-webhooks/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/africa-payments-webhooks" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/africa-payments-webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/africa-payments-webhooks/" rel="noopener noreferrer"&gt;https://pypi.org/project/africa-payments-webhooks/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a real webhook from one of these providers doesn't verify, please open an issue. Provider docs change, and real&lt;br&gt;
payloads are how the library stays correct. Which provider should be next: OPay, Paga, or M-Pesa?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Popoola Sinaayo, backend engineer (Node.js/NestJS &amp;amp; Django).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted with AI assistance and then reviewed and edited by me. Every signature detail above is checked by the library's automated tests against the providers' own published examples.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>python</category>
      <category>webdev</category>
      <category>security</category>
    </item>
  </channel>
</rss>
