||

Webhooks

One endpoint verifies and normalizes webhooks from every configured provider — the right signature scheme per provider, raw bytes, constant-time comparison, fail-closed.

Webhooks are a security-critical, first-class citizen in Payweave. One API verifies and parses webhooks for any provider you've configured on the client, using each provider's correct signature scheme, and returns a typed, normalized event.

Verify on the raw bytes

Verification must run on the exact raw bytes received. Parsing then re-stringifying JSON is a known footgun — never do it.

import express from "express"

const app = express()

// Capture the RAW body for the webhook route only.
app.post("/webhooks/payments", express.raw({ type: "*/*" }), (req, res) => {
  let event
  try {
    event = payweave.webhooks.constructEvent({ rawBody: req.body, headers: req.headers })
  } catch (err) {
    return res.sendStatus(400) // bad signature → PayweaveWebhookVerificationError
  }

  res.sendStatus(200) // ack fast, then process asynchronously

  switch (event.unifiedType) {
    case "payment.succeeded":
      // Never grant value from the webhook alone — re-verify first:
      // await payweave.verify({ reference }) and check amount + currency + status.
      break
  }
})

Capture the raw body per framework: express.raw() in Express, await req.text() in the Next.js App Router, the rawBody hook in Fastify, or buffering the stream in bare http.

One endpoint, every provider

payweave.webhooks serves every provider configured on that client from a single route — it detects which one sent the request from the signature header name present, before the body is ever parsed:

HeaderProvider
stripe-signatureStripe
x-paystack-signaturePaystack
verif-hashFlutterwave v3
flutterwave-signatureFlutterwave v4

It rejects (always PayweaveWebhookVerificationError, fail-closed, never falling through to another verifier) when: no known header is present; more than one known header is present (ambiguous or forged — rejected even if one of them would verify); the header names a provider that isn't configured on this client; or a Flutterwave header doesn't match the version this client is configured for.

Signature schemes (handled for you)

HeaderScheme
Stripestripe-signatureHMAC-SHA256 of {timestamp}.{rawBody} (hex), format t=...,v1=...; ±300s tolerance
Paystackx-paystack-signatureHMAC-SHA512 of the raw body (hex), keyed with your secret key
Flutterwave v3verif-hashPlain equality against the dashboard secret hash
Flutterwave v4flutterwave-signatureHMAC-SHA256 of the raw body (base64), keyed with the dashboard secret hash

Comparison is always constant-time (crypto.timingSafeEqual) and fails closed.

The Flutterwave dashboard secret hash is not your API key. Pass it as webhookSecret; Stripe likewise needs its own dashboard-issued signing secret:

const payweave = createPayweave({
  flutterwave: {
    secretKey: process.env.FLW_SECRET_KEY!,
    webhookSecret: process.env.FLW_WEBHOOK_SECRET!, // dashboard secret hash
  },
})

Paystack defaults webhookSecret to your secretKey; Stripe and Flutterwave throw PayweaveConfigError if you call webhooks.* for them without an explicit webhookSecret — never a silent pass.

The normalized event

const event = payweave.webhooks.constructEvent({ rawBody, headers })
// event: {
//   provider: "paystack",
//   type: "charge.success",           // provider-native name
//   unifiedType: "payment.succeeded", // normalized
//   data,                             // typed per event where known
//   dedupeKey,                        // stable idempotency key
//   raw,
// }

event.apply() is also attached to every event — see Plans & features for how it drives subscription/billing state.

Unified event names map across providers (extend, never break):

unifiedTypePaystackFlutterwaveStripe
payment.succeededcharge.successcharge.completed (successful)payment_intent.succeeded, checkout.session.completed*, checkout.session.async_payment_succeeded
payment.failedcharge.completed (failed)payment_intent.payment_failed, checkout.session.async_payment_failed
transfer.succeededtransfer.successtransfer.completed (successful)
transfer.failedtransfer.failedtransfer.completed (failed)
transfer.reversedtransfer.reversed
refund.processedrefund.processedrefund.completedcharge.refunded, refund.updated*
subscription.createdsubscription.createcustomer.subscription.created
subscription.updatedsubscription.not_renewcustomer.subscription.updated
subscription.canceledsubscription.disablesubscription.cancelledcustomer.subscription.deleted
invoice.paidinvoice.updateinvoice.paid, invoice.payment_succeeded
invoice.payment_failedinvoice.payment_failedinvoice.payment_failed
dispute.createdcharge.dispute.createchargeback.initiatedcharge.dispute.created
unknownanything unmappedanything unmappedanything unmapped

* checkout.session.completed only normalizes to payment.succeeded when the session's payment_status is paid or no_payment_required — otherwise it's delivered as unknown (the real signal arrives later via checkout.session.async_payment_succeeded, for delayed payment methods). refund.updated only normalizes to refund.processed when its status is succeeded; every other transition is unknown.

Unmapped events are still delivered as unifiedType: "unknown" with type preserved — an event is never dropped.

Consumer checklist

  1. Always return 200 fast; process asynchronously.
  2. Never grant value from the webhook alone — re-verify via payweave.verify({ reference }) and check amount + currency + status.
  3. Be idempotent: providers redeliver. Dedupe on event.dedupeKey (or call event.apply(), which does this for you against your database).
  4. Exempt the webhook route from CSRF.
Did you like the content?