one clean payment event, every rail, exactly once.
A thin, auditable reconciliation layer for payment webhooks from African payment providers. It sits on top of rails you already use — it never becomes a payment processor itself.
| Provider | Body Integrity | Status |
|---|---|---|
| Paystack | HMAC-SHA512 over body | Shipped |
| Flutterwave | None — see warning below | Shipped |
| OPay | HMAC-SHA512 over body | Shipped |
| M-Pesa (Daraja) | Pre-shared callback token (constant-time cmp) | Shipped |
go get github.com/HalxDocs/onceo-core
package main
import (
"context"
"errors"
"io"
"net/http"
"os"
onceo "github.com/HalxDocs/onceo-core"
"github.com/HalxDocs/onceo-core/providers/paystack"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
body, _ := io.ReadAll(r.Body)
provider, _ := paystack.New(os.Getenv("PAYSTACK_SECRET_KEY"))
store := onceo.NewMemoryStore() // or store/redis
event, err := onceo.Process(ctx, provider, store, r.Header, body)
if errors.Is(err, onceo.ErrInvalidSignature) {
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
if errors.Is(err, onceo.ErrDuplicateEvent) {
w.WriteHeader(http.StatusOK) // idempotent: already processed
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// event is the canonical, normalized payment event
switch event.Status {
case onceo.StatusSuccess:
fulfillOrder(event.Reference, event.AmountMinor, event.Currency)
case onceo.StatusFailed:
markOrderFailed(event.Reference)
}
w.WriteHeader(http.StatusOK)
}The single entry point. Runs the full pipeline:
- Body size check — rejects payloads over
MaxBodySize(1 MiB) - Provider validation — verifies provider name is non-empty and valid
- Event ID length check — rejects
ProviderEventID>MaxProviderEventIDLength(256) - Signature verification — delegates to
provider.VerifySignature - Parse — decodes provider-specific payload
- Normalize — maps to canonical
Eventschema - Idempotency check —
store.SaveIfNew(atomic, exactly-once) - Return — the saved
EventwithProcessedAtset
type Event struct {
ID string // unique event ID (generated)
Provider string // provider name
ProviderEventID string // provider's event ID (unique key for idempotency)
Type string // event type (e.g. "charge.success")
Status Status // success, failed, pending, reversed
AmountMinor int64 // amount in minor currency unit (e.g. kobo, cents)
Currency string // ISO 4217 currency code
Reference string // merchant reference
RawPayload []byte // original raw body
ReceivedAt time.Time // when the webhook was received
ProcessedAt time.Time // when Process returned successfully
}Status enum: StatusPending (0), StatusSuccess (1), StatusFailed (2), StatusReversed (3).
Supports JSON marshal/unmarshal (string-based, case-insensitive).
Use errors.Is to check:
| Error | Meaning |
|---|---|
ErrInvalidSignature |
Signature does not match |
ErrDuplicateEvent |
Event already processed (idempotent hit) |
ErrUnknownProvider |
Provider name not recognized |
ErrMalformedPayload |
Body cannot be parsed for event ID |
ErrMissingHeader |
Required signature header missing |
ErrDuplicateHeader |
Header appears more than once |
ErrEventParseFailed |
Provider event ID is empty or unparseable |
ErrStoreFailed |
Store backend returned an error |
type Store interface {
SaveIfNew(ctx context.Context, e Event) (created bool, err error)
}Contract: atomic, exactly-one semantics. Two concurrent calls for the same event must never both return created=true.
In-memory FIFO store, safe for concurrent use. Max 100,000 events by default. Not recommended for production — events are lost on restart and evicted when capacity is reached.
Production-grade store backed by Redis SETNX. Requires github.com/redis/go-redis/v9.
import redisstore "github.com/HalxDocs/onceo-core/store/redis"
store, err := redisstore.New("localhost:6379", "", 0)type Provider[T any] interface {
Name() string
BodyBound() bool
VerifySignature(headers http.Header, body []byte) error
ParsePayload(body []byte) (T, error)
Normalize(payload T) (Event, error)
}BodyBound(): if true, the signature covers the request body (Paystack, OPay). If false (Flutterwave), callers should be aware there is no application-level body integrity.- Each provider uses Go generics to define its own parsed payload type.
import "github.com/HalxDocs/onceo-core/providers/paystack"
provider, err := paystack.New("sk_test_...")- Signature: HMAC-SHA512 of raw body, sent in
X-Paystack-Signatureheader - Body integrity: Yes (
BodyBound() = true) - Parsed type:
paystack.Payload
import "github.com/HalxDocs/onceo-core/providers/flutterwave"
provider, err := flutterwave.New("FLWSECK_TEST-...")WARNING: Flutterwave's
Verif-Hashheader is a static SHA-256 hash of the webhook secret hash (secret_hashin the dashboard). It does not include the request body.BodyBound()returnsfalse. Any attacker who captures one validVerif-Hashcan forge arbitrary webhook payloads until the secret hash is rotated. There is no application-level body integrity — protection relies entirely on TLS.
- Signature: Static SHA-256 hash (
Verif-Hashheader) - Body integrity: No (
BodyBound() = false) - Parsed type:
flutterwave.Payload
import "github.com/HalxDocs/onceo-core/providers/opay"
provider, err := opay.New("OPAY_MERCHANT_ID", "OPAY_SECRET_KEY")- Signature: HMAC-SHA512 of raw body, sent in
Authorizationheader. Supports optional fallbackOpay-Signatureheader for backward compatibility. - Body integrity: Yes (
BodyBound() = true) - Parsed type:
opay.Payload
import "github.com/HalxDocs/onceo-core/providers/mpesa"
provider, err := mpesa.New("callback-token")- Signature: Pre-shared callback token, verified via
subtle.ConstantTimeCompare - Body integrity: Yes (
BodyBound() = true) - Parsed type:
mpesa.STKCallbackPayload - Only STK Push callbacks are currently supported
Install the onceo CLI to verify webhook payloads locally:
go install github.com/HalxDocs/onceo-core/cmd/onceo@latest
# Verify a Paystack payload
onceo verify charge_success.json --provider=paystack --secret=sk_test_...
# Verify a Flutterwave payload
onceo verify transfer_event.json --provider=flutterwave --secret=FLWSECK_TEST-...The CLI runs the full Process pipeline and prints the normalized Event as JSON. Use the noop secret (--secret=noop) to skip signature verification for inspection.
All signature and token comparisons use crypto/hmac.Equal, crypto/subtble.ConstantTimeCompare, or crypto/subtle.ConstantTimeEq. No early-exit comparison paths.
Payloads over MaxBodySize (1 MiB) are rejected early, before any signature or parsing work.
ProviderEventID is limited to MaxProviderEventIDLength (256 characters) to prevent database indexing issues and memory exhaustion attacks.
MemoryStore and RedisStore escape : and \ characters in provider names and event IDs to prevent key delimiter collisions.
The library has been audited across 6 rounds of findings (critical → low):
- Body size enforcement added
- Provider BodyBound() flag for bodyless signatures
- Amount fields changed to
int64for 32-bit safety - Idempotency keys properly escaped
- Signature error wrapping fixed
- Duplicate/malformed header handling hardened
- Redis store input validation added
- Consistent timestamp snapshots
MIT — see LICENSE.