Skip to content

Adding Providers

Zirzir ships with Chapa and Telebirr built in. To add another provider — M-Pesa, Flutterwave, Stripe, or anything else — you build a plugin. Plugins are standalone Go binaries that Zirzir discovers and manages automatically.


Zirzir uses HashiCorp’s go-plugin system. Your plugin runs as a separate process that communicates with the Zirzir server over gRPC. This means:

  • Plugins can’t crash the main server
  • You can develop and deploy plugins independently
  • Any Go developer can add a provider without touching the Zirzir codebase

Terminal window
mkdir zirzir-plugin-myprovider && cd zirzir-plugin-myprovider
go mod init github.com/yourorg/zirzir-plugin-myprovider
go get github.com/zirzir/zirzir/plugin

Create main.go:

package main
import (
"context"
"encoding/json"
"fmt"
"github.com/zirzir/zirzir/plugin"
)
type MyProvider struct {
apiKey string
}
func main() {
plugin.Serve(&MyProvider{})
}

That’s the skeleton. Now implement the 7 methods of the plugin.Provider interface.


type Provider interface {
Info() ProviderInfo
Configure(config json.RawMessage) error
InitiatePayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error)
CheckStatus(ctx context.Context, txnID string) (*StatusResponse, error)
ParseWebhook(payload []byte, headers map[string]string) (*WebhookEvent, error)
UISchema() *UISchema
CustomRoutes() []CustomRoute
}

Let’s walk through each method.


Returns static information about your provider. This is displayed in the dashboard and used for validation.

func (p *MyProvider) Info() plugin.ProviderInfo {
return plugin.ProviderInfo{
Code: "myprovider", // Unique identifier (lowercase, no spaces)
Name: "My Provider", // Display name
Version: "1.0.0",
Description: "Accept payments via MyProvider",
Countries: []string{"ET"}, // ISO 3166-1 alpha-2
Currencies: []string{"ETB"}, // ISO 4217
PaymentTypes: []string{"web_checkout", "mobile_money"},
ConfigFields: []plugin.Field{
{
Name: "api_key",
Label: "API Key",
Type: "password", // text, password, select, checkbox
Required: true,
},
{
Name: "environment",
Label: "Environment",
Type: "select",
Required: true,
Options: []plugin.Option{
{Value: "sandbox", Label: "Sandbox"},
{Value: "production", Label: "Production"},
},
},
},
}
}

The ConfigFields define what credentials merchants need to provide. These are rendered as a form in the dashboard.


Called once per merchant when the provider is first used. Parse the JSON credentials and store them on your struct.

func (p *MyProvider) Configure(config json.RawMessage) error {
var cfg struct {
APIKey string `json:"api_key"`
Environment string `json:"environment"`
}
if err := json.Unmarshal(config, &cfg); err != nil {
return fmt.Errorf("parse config: %w", err)
}
if cfg.APIKey == "" {
return fmt.Errorf("api_key is required")
}
p.apiKey = cfg.APIKey
return nil
}

Called when the SDK’s charge() method hits the server. Make the API call to your provider and return a normalized response.

func (p *MyProvider) InitiatePayment(
ctx context.Context,
req *plugin.PaymentRequest,
) (*plugin.PaymentResponse, error) {
// 1. Call your provider's API
resp, err := p.callProviderAPI(req)
if err != nil {
return nil, fmt.Errorf("provider API error: %w", err)
}
// 2. Return a normalized response
return &plugin.PaymentResponse{
ProviderTxnID: resp.TransactionID,
Status: plugin.StatusPending,
PaymentURL: resp.CheckoutURL, // Empty for USSD providers
Message: "Payment initiated",
}, nil
}

PaymentRequest fields available to you:

FieldTypeDescription
ReferencestringMerchant’s unique reference
Amountfloat64Payment amount
CurrencystringISO 4217 code
CustomerPhonestringCustomer phone
CustomerEmailstringCustomer email
CustomerNamestringCustomer name
ReturnURLstringRedirect after payment
CallbackURLstringWebhook URL
Metadatamap[string]stringArbitrary metadata

CheckStatus() — Query Transaction Status

Section titled “CheckStatus() — Query Transaction Status”

Called when the SDK’s verify() method hits the server or when the dashboard refreshes a transaction.

func (p *MyProvider) CheckStatus(
ctx context.Context,
txnID string,
) (*plugin.StatusResponse, error) {
resp, err := p.queryProviderStatus(txnID)
if err != nil {
return nil, err
}
return &plugin.StatusResponse{
ProviderTxnID: txnID,
Status: mapStatus(resp.Status), // Map to: pending, completed, failed, cancelled
Message: resp.Message,
PaidAt: resp.CompletedAt, // ISO 8601 timestamp
}, nil
}

ParseWebhook() — Handle Incoming Webhooks

Section titled “ParseWebhook() — Handle Incoming Webhooks”

Called when a provider sends a webhook to POST /api/v1/webhooks/:provider. Parse the provider’s payload format and return a normalized event.

func (p *MyProvider) ParseWebhook(
payload []byte,
headers map[string]string,
) (*plugin.WebhookEvent, error) {
// 1. Verify signature (if the provider signs webhooks)
sig := headers["x-signature"]
if !p.verifySignature(payload, sig) {
return nil, fmt.Errorf("invalid webhook signature")
}
// 2. Parse the provider-specific payload
var data ProviderWebhookPayload
if err := json.Unmarshal(payload, &data); err != nil {
return nil, fmt.Errorf("parse webhook: %w", err)
}
// 3. Map to Zirzir's event format
status := plugin.StatusPending
eventType := "payment.pending"
switch data.Status {
case "SUCCESS":
status = plugin.StatusCompleted
eventType = "payment.completed"
case "FAILED":
status = plugin.StatusFailed
eventType = "payment.failed"
}
return &plugin.WebhookEvent{
Type: eventType,
ProviderTxnID: data.TransactionID,
Reference: data.MerchantRef,
Status: status,
Amount: data.Amount,
Currency: data.Currency,
Message: data.StatusMessage,
RawPayload: payload,
}, nil
}

Optional. Return UI components for the Zirzir dashboard.

func (p *MyProvider) UISchema() *plugin.UISchema {
return &plugin.UISchema{
DashboardWidgets: []plugin.Widget{
{
Type: "stat",
Title: "MyProvider Transactions",
Config: json.RawMessage(`{
"endpoint": "/api/v1/payments?provider=myprovider",
"countField": "total"
}`),
},
},
}
}

Return nil if you don’t need custom widgets.


Optional. Add provider-specific endpoints under /api/v1/providers/:code/....

func (p *MyProvider) CustomRoutes() []plugin.CustomRoute {
return []plugin.CustomRoute{
{
Method: "GET",
Path: "/balance",
Handler: func(c echo.Context) error {
balance, _ := p.getBalance()
return c.JSON(200, balance)
},
},
}
}

Return nil if you don’t need custom routes.


Terminal window
go build -o myprovider .

Copy the binary to the Zirzir server’s plugins directory:

Terminal window
cp myprovider /path/to/zirzir/plugins/

Restart the server. Zirzir discovers and loads the plugin automatically:

INFO starting server port=8080
INFO loaded plugin provider=myprovider version=1.0.0

Mount your plugins directory:

services:
zirzir:
image: ghcr.io/recite-labs/zirzir/server:latest
volumes:
- ./plugins:/app/plugins:ro

See the complete M-Pesa example in the repository:

server/plugin/examples/mpesa/main.go

Build it with:

Terminal window
cd server
make plugin-example

Run your plugin binary directly to check that it starts without errors:

Terminal window
./myprovider
# Should block waiting for gRPC connection — that means it's working
# Ctrl+C to stop

Then start the Zirzir server and:

  1. Check GET /api/v1/providers — your provider should appear
  2. Configure credentials via the dashboard or admin API
  3. Create a test payment with POST /api/v1/payments

Use these constants when setting status values:

ConstantValueMeaning
plugin.StatusPending"pending"Awaiting customer action
plugin.StatusCompleted"completed"Payment confirmed
plugin.StatusFailed"failed"Payment failed
plugin.StatusCancelled"cancelled"Customer cancelled