DEV Community

Cover image for Building a Mini Wallet Service with Paystack Integration
Beryl Christine Atieno
Beryl Christine Atieno

Posted on

Building a Mini Wallet Service with Paystack Integration

How I built a mini digital wallet with Paystack integration, Google OAuth, and learned the importance of webhooks along the way.

Why I Built a Wallet Service

I recently decided to tackle a project that would push my understanding of payment systems, authentication, and API design. A digital wallet service was perfect for this; Not just a simple payment processor, but a complete wallet system where users could deposit money, transfer funds to each other, and manage their balance, all secured with proper authentication and permissions.

The goal was clear; to build something production-ready that I'd feel confident deploying. That meant handling real money (well, test money initially), implementing proper security, and most importantly, integrating with a real payment gateway.

Enter Paystack.

Choosing Paystack

I chose Paystack for several reasons:

  1. Excellent documentation - Their API docs are clean and comprehensive
  2. Great developer experience - Paystack has a test mode works flawlessly
  3. Webhook support - Critical for real-time payment updates
  4. African focus - Paystack is a Nigerian company, perfect for the African market with integrations to popular African payment solutions like MPESA.

Tech Stack

  • Backend: Go (Golang) - for speed and simplicity
  • Database: SQLite (I picked this because I have no scaling worries to think about)
  • Payment Gateway: Paystack
  • Authentication: Google OAuth + JWT tokens
  • Architecture: Clean architecture with repository pattern

The Paystack Integration Journey

Step 1: Understanding the Flow

The first thing I learned: payment integration isn't just "call API, get money." There's a flow:

1. User clicks "Deposit KES 5,000"
2. Your server calls Paystack: "Initialize a payment"
3. Paystack returns a payment link
4. User pays on Paystack's secure page
5. Paystack sends a webhook to your server
6. Your server verifies and credits the wallet
Enter fullscreen mode Exit fullscreen mode

Step 2: Setting Up Paystack Client

First, I created a Paystack client in Go. Here's what it looks like:

package paystack

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

type Client struct {
    secretKey string
    baseURL   string
}

func NewClient(secretKey string) *Client {
    return &Client{
        secretKey: secretKey,
        baseURL:   "https://api.paystack.co",
    }
}
Enter fullscreen mode Exit fullscreen mode

Simple enough. The secret key comes from your Paystack dashboard (use test keys during development).

Step 3: Initializing a Transaction

When a user wants to deposit money, I need to initialize a Paystack transaction:

type InitializeRequest struct {
    Email     string `json:"email"`
    Amount    int64  `json:"amount"` // Amount in cents (KES 10 = 1000 cents)
    Reference string `json:"reference"` // Unique transaction reference
}

func (c *Client) InitializeTransaction(email string, amount int64, reference string) (*InitializeResponse, error) {
    reqBody := InitializeRequest{
        Email:     email,
        Amount:    amount,
        Reference: reference,
    }

    jsonData, _ := json.Marshal(reqBody)

    req, _ := http.NewRequest("POST", c.baseURL+"/transaction/initialize", bytes.NewBuffer(jsonData))
    req.Header.Set("Authorization", "Bearer "+c.secretKey)
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)

    var initResp InitializeResponse
    json.Unmarshal(body, &initResp)

    return &initResp, nil
}
Enter fullscreen mode Exit fullscreen mode

Key learnings here:

  1. Amount is in cents - KES 10.50 = 1050 cents. This avoids floating-point errors. I made this mistake initially and had users "depositing" KES 10 when they meant KES 1,000!

  2. Reference must be unique - I generate mine like this: DEP_userID_amount_timestamp. This prevents duplicate transactions.

  3. Email is required - Paystack uses it for receipts and verification.

Step 4: Handling the Response

Paystack returns a payment link, which is the authorizationURL in the response structure below. Here's my wallet handler:

func (h *WalletHandler) InitiateDeposit(w http.ResponseWriter, r *http.Request) {
    // Get user ID from JWT or API key
    userID := h.getUserID(r)

    // Parse request
    var req DepositRequest
    json.NewDecoder(r.Body).Decode(&req)

    // Get user's wallet
    userWallet, _ := h.walletRepo.GetByUserID(userID)

    // Generate unique reference
    reference := fmt.Sprintf("DEP_%s_%d", userID, req.Amount)

    // Initialize Paystack payment
    paystackResp, err := h.paystackClient.InitializeTransaction(
        h.getUserEmail(r),
        req.Amount,
        reference,
    )

    if err != nil {
        respondError(w, "Failed to initialize payment")
        return
    }

    // Create pending transaction in database
    h.walletService.InitiateDeposit(userWallet.ID, req.Amount, reference)

    // Return payment link to user
    respondSuccess(w, map[string]interface{}{
        "reference":         paystackResp.Data.Reference,
        "authorization_url": paystackResp.Data.AuthorizationURL,
    })
}
Enter fullscreen mode Exit fullscreen mode

The authorization url is used to redirect the user to Paystack's payment page. They enter their card/MPESA details (or use the test details provided by Paystack), and Paystack processes the payment.

Example response:

{
  "data": {
    "authorization_url": "https://checkout.paystack.com/09fgljcz1ierho0",
    "reference": "DEP_d892fb679690f5dc285003d161873736_5000"
  }
}
Enter fullscreen mode Exit fullscreen mode

But here's where it gets interesting...

Step 5: The Webhook - The Most Important Part

Initially, I thought: "Great! User paid, I'll just check Paystack's API to see if payment succeeded."

Wrong approach. That means polling constantly, wasting resources, and delayed updates.

The right way? Webhooks.

Paystack sends a POST request to your server the moment payment succeeds. Here's how I handled it:

func (h *WebhookHandler) HandlePaystackWebhook(w http.ResponseWriter, r *http.Request) {
    // CRITICAL: Validate webhook signature
    body, valid := paystack.ValidateWebhookSignature(r, h.paystackSecret)
    if !valid {
        respondError(w, http.StatusUnauthorized, "Invalid signature")
        return
    }

    // Parse the event
    event, _ := paystack.ParseWebhookEvent(body)

    // Only process successful charges
    if event.Event == "charge.success" && event.Data.Status == "success" {
        // Credit the wallet
        h.walletService.CompleteDeposit(event.Data.Reference)
    }

    // Always return 200 OK (so Paystack knows we received it)
    respondSuccess(w, map[string]interface{}{"status": true})
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Webhook Signature Validation for Security

Without signature validation, anyone could send fake webhooks to credit wallets for free! The key security element here is the X-Paystack-Signature header, which includes the webhook signature used to verify that a request is coming from Paystack.

Here's how Paystack signs webhooks:

func ValidateWebhookSignature(r *http.Request, secretKey string) ([]byte, bool) {
    signature := r.Header.Get("X-Paystack-Signature")

    body, _ := io.ReadAll(r.Body)

    // Compute HMAC SHA512
    hash := hmac.New(sha512.New, []byte(secretKey))
    hash.Write(body)
    expectedSignature := hex.EncodeToString(hash.Sum(nil))

    // Compare signatures (constant-time comparison prevents timing attacks)
    return body, hmac.Equal([]byte(signature), []byte(expectedSignature))
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Idempotency - Handling Duplicate Webhooks

Paystack might send the same webhook twice due to network issues, retries, etc. You need to handle this gracefully. In my case, I check the webhook reference and check if it has already been processed. If yes, I do nothing making sure no single request is processed twice.

func (s *Service) CompleteDeposit(reference string) error {
    tx, _ := s.transactionRepo.GetByReference(reference)

    // Check if already processed
    if tx.Status == TransactionStatusSuccess {
        return nil // Already credited, do nothing (idempotent)
    }

    // Update transaction status
    tx.Status = TransactionStatusSuccess
    s.transactionRepo.Update(tx)

    // Credit wallet
    s.walletRepo.UpdateBalance(tx.WalletID, tx.Amount)

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Without this check, a user could get credited twice for the same payment!

Testing

The following is an example of a payment process, tested with curl

1. User Initiates Deposit

curl -X POST http://localhost:8080/wallet/deposit \
  -H "Authorization: Bearer eyJhbGciOi..." \
  -d '{"amount": 5000}'
Enter fullscreen mode Exit fullscreen mode

2. Server Response

{
  "data": {
    "reference": "DEP_user123_5000",
    "authorization_url": "https://checkout.paystack.com/abc123"
  }
}
Enter fullscreen mode Exit fullscreen mode

3. User Pays on Paystack

  • Frontend redirects user to authorization_url

Paystack enter payment details window screenshot

  • User enters payment details

Paystack payment processing window screenshot

  • Paystack processes payment

Paystack payment processing successful screenshot

4. Paystack Sends Webhook

POST /wallet/paystack/webhook
X-Paystack-Signature: abc123...

{
  "event": "charge.success",
  "data": {
    "reference": "DEP_user123_5000",
    "amount": 5000,
    "status": "success"
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Server Credits Wallet

UPDATE wallets 
SET balance = balance + 5000 
WHERE user_id = 'user123'
Enter fullscreen mode Exit fullscreen mode

6. User Checks Balance

curl http://localhost:8080/wallet/balance \
  -H "Authorization: Bearer eyJhbGciOi..."

Response: {"data": {"balance": 5000}}
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

1. Always Use Webhooks

Polling is tempting but wrong. Webhooks give you real-time updates and are how Paystack intends you to work.

2. Validate Everything

  • Webhook signatures (prevent fraud)
  • Amount formats (use smallest currency unit)
  • Transaction uniqueness (prevent duplicates)

3. Handle Idempotency

Webhooks can arrive multiple times. Your code should handle this gracefully without double-crediting.

4. Unique References Matter

Generate unique transaction references. I use: {type}_{userID}_{amount}_{timestamp}. This makes debugging easier and prevents conflicts.

7. Security Layers

I implemented multiple security layers:

  • Google OAuth for user authentication
  • JWT tokens for session management
  • API keys with permissions for services
  • Webhook signature validation
  • HTTPS in production

What's Next?

I'm planning to add:

  1. Recurring payments - Using Paystack subscriptions
  2. Payouts to bank - Using Paystack Transfer API
  3. Split payments - For marketplace scenarios
  4. Invoice generation - PDF receipts for deposits
  5. Analytics dashboard - Transaction trends and insights

Final Thoughts

Building this wallet service taught me more about payment systems than any tutorial could. The moment I saw my first test webhook arrive and the balance update automatically.

Paystack made the integration smooth with great docs and test mode. The webhook system is elegant once you understand it. And seeing real (test) money flow through the system you built? That's incredibly satisfying.

If you're building anything involving payments in Africa, I highly recommend Paystack. The developer experience is top-notch, and they handle the complex parts so you can focus on your product.

The complete code is on Github. Feel free to explore, learn, and build your own!

Top comments (1)

Collapse
 
macmartins profile image
Obinna Aguwa

It’s really insightful to see how payment can made using Paystack.
I will check out the GitHub repo for trying it out myself.

Thanks for sharing Beryl