mercury-go
Production-grade Go client for the Mercury Banking API.

Features
- Full API coverage — all 59 Mercury endpoints across 16 resource services
- Zero runtime dependencies — standard library only (
net/http, encoding/json, context)
- Idiomatic Go —
context.Context on every call, typed errors with errors.As/errors.Is support, functional options
- Generics-based pagination —
Pages, Items, Collect, CollectN work uniformly across every resource
- Exponential backoff retries — automatic retry with jitter on 429/5xx; 400/401/404/409 fail immediately
- Rich, inspectable errors —
IsAuthError, IsNotFoundError, IsRateLimitError, etc.
- Request/response hooks — plug in your own logger, tracer, or metrics exporter
- Thread-safe — a single
*Client can be shared and reused across goroutines
Installation
go get github.com/iamkanishka/mercury-go
Requires Go 1.22 or later.
The canonical, fully-documented implementation lives in the mercury subpackage.
A thin root-level package re-exports the most common entry points (NewClient,
Option, Ptr, the Is*Error helpers) so either import path works identically:
import "github.com/iamkanishka/mercury-go/mercury" // canonical
// or
import mercury "github.com/iamkanishka/mercury-go" // flat alias, same Client
Anything not re-exported at the root (individual resource types, less common
options) is available by importing the mercury subpackage directly.
Quick start
package main
import (
"context"
"fmt"
"log"
"github.com/iamkanishka/mercury-go/mercury"
)
func main() {
client := mercury.NewClient("secret-token:mercury_production_...")
ctx := context.Background()
account, err := client.Accounts.Get(ctx, "account-id")
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s: $%.2f\n", account.Name, account.AvailableBalance)
// Auto-paginate every transaction
all, err := client.Transactions.Collect(ctx, &mercury.ListTransactionsParams{
Status: []mercury.TransactionStatus{mercury.TransactionStatusSent},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d sent transactions\n", len(all))
}
Client options
client := mercury.NewClient(
"secret-token:...",
mercury.WithBaseURL("https://sandbox.mercury.com/api/v1"), // override for testing
mercury.WithTimeout(15 * time.Second), // default: 30s
mercury.WithRetry(mercury.RetryConfig{
MaxRetries: 5,
InitialDelay: 250 * time.Millisecond,
MaxDelay: 20 * time.Second,
JitterFactor: 0.3,
}),
mercury.WithHTTPClient(customHTTPClient), // bring your own *http.Client / transport
mercury.WithRequestHook(func(method, url, requestID string) {
log.Printf("→ %s %s [%s]", method, url, requestID)
}),
mercury.WithResponseHook(func(method, url, requestID string, status int, d time.Duration) {
log.Printf("← %d in %s [%s]", status, d, requestID)
}),
)
Resources
Every resource is a field on *mercury.Client:
| Field |
Service |
client.Accounts |
accounts, cards, statements, transactions, send money |
client.Transactions |
get, list, update, upload attachment |
client.Recipients |
full CRUD, attachments |
client.Invoices |
full CRUD, cancel, PDF download, attachments |
client.Payments |
internal transfers, send-money approval requests |
client.Categories |
full CRUD |
client.Customers |
full CRUD |
client.Treasury |
accounts, transactions, statements |
client.Webhooks |
full CRUD, verify |
client.Events |
get, list, collect |
client.Organization |
get, submit onboarding |
client.Users |
get, list, collect |
client.SAFERequests |
get, list, download document |
client.Credit |
list |
client.Attachments |
get |
client.OAuth2 |
authorize URL, code exchange |
Example: send money
txn, err := client.Accounts.SendMoney(ctx, accountID, &mercury.SendMoneyRequest{
RecipientID: recipientID,
Amount: 500.00,
PaymentMethod: mercury.PostTransactionPaymentMethodACH,
IdempotencyKey: uuid.NewString(),
Note: mercury.Ptr("Invoice #1234"),
})
Example: domestic wire with required purpose
txn, err := client.Accounts.SendMoney(ctx, accountID, &mercury.SendMoneyRequest{
RecipientID: recipientID,
Amount: 10000.00,
PaymentMethod: mercury.PostTransactionPaymentMethodDomesticWire,
IdempotencyKey: uuid.NewString(),
Purpose: &mercury.SendMoneyPurpose{
Simple: &mercury.SimplePurpose{
Category: mercury.WirePurposeCategoryVendor,
AdditionalInfo: mercury.Ptr("Acme Supplies Inc"),
},
},
})
Example: internal transfer (returns both legs)
result, err := client.Payments.CreateInternalTransfer(ctx, &mercury.InternalTransferRequest{
SourceAccountID: checkingID,
DestinationAccountID: savingsID,
Amount: 5000.00,
IdempotencyKey: uuid.NewString(),
})
// result.DebitTransaction — the outgoing leg
// result.CreditTransaction — the incoming leg
Example: create a recipient with ACH routing
recipient, err := client.Recipients.Create(ctx, &mercury.CreateRecipientRequest{
Name: "Acme Supplies",
Emails: []string{"billing@acmesupplies.com"},
ElectronicRoutingInfo: &mercury.ElectronicRoutingInfoInput{
AccountNumber: "0123456789",
RoutingNumber: "021000021",
ElectronicAccountType: mercury.ElectronicAccountTypeBusinessChecking,
Address: mercury.AddressWithoutName{
Address1: "123 Main St",
City: "San Francisco",
Region: "CA",
PostalCode: "94105",
Country: "US",
},
},
})
Every list-capable resource exposes the same three-tier pattern, powered by generics:
// 1. Single page — manual cursor control
page1, _ := client.Recipients.List(ctx, &mercury.ListParams{Limit: mercury.Ptr(25)})
page2, _ := client.Recipients.List(ctx, &mercury.ListParams{StartAfter: page1.Page.NextPage})
// 2. Channel-based streaming — memory-efficient for large datasets.
// client.Recipients.Pages(params) returns a mercury.PageFetcher[Recipient];
// pass it to mercury.Pages or mercury.Items to get a streaming channel.
for result := range mercury.Pages(ctx, client.Recipients.Pages(nil)) {
if result.Error != nil {
log.Fatal(result.Error)
}
for _, r := range result.Page.Items {
fmt.Println(r.Name)
}
}
for result := range mercury.Items(ctx, client.Recipients.Pages(nil)) {
if result.Error != nil {
log.Fatal(result.Error)
}
fmt.Println(result.Item.Name)
}
// 3. Collect — convenience for smaller datasets
all, err := client.Recipients.Collect(ctx, nil)
// Or cap the number of items fetched:
first50, err := mercury.CollectN(ctx, 50, client.Recipients.Pages(nil))
mercury-go targets Go 1.22, so pagination uses buffered channels (<-chan PageResult[T])
rather than the iter.Seq range-over-func form introduced in Go 1.23. The API shape is
identical in spirit — Pages, Items, Collect, CollectN — and will gain native
iter.Seq2 support in a future major version once Go 1.23 is broadly available.
Error handling
import "github.com/iamkanishka/mercury-go/mercury"
account, err := client.Accounts.Get(ctx, id)
switch {
case mercury.IsAuthError(err):
// 401 — bad or missing API token
case mercury.IsNotFoundError(err):
var nfErr *mercury.NotFoundError
errors.As(err, &nfErr)
fmt.Println("missing resource:", nfErr.Resource)
case mercury.IsRateLimitError(err):
var rlErr *mercury.RateLimitError
errors.As(err, &rlErr)
fmt.Println("retry after seconds:", rlErr.RetryAfter)
case mercury.IsValidationError(err):
// 400 — bad request body
case mercury.IsConflictError(err):
// 409 — e.g. duplicate idempotency key
case mercury.IsServerError(err):
// 5xx
case mercury.IsNetworkError(err):
// transport-level failure; unwrap with errors.Unwrap(err) for the cause
case err != nil:
// unexpected
}
Every typed error embeds mercury.APIError, which exposes:
Code — mercury.ErrorCode ("unauthorized", "not_found", "rate_limited", …)
StatusCode — the HTTP status code
Message — human-readable message from the API
RequestID — Mercury's or the SDK's request ID, for support tickets
Body — the raw decoded JSON error body
Retry behaviour
| Condition |
Retried? |
429 Too Many Requests |
✅ respects Retry-After header |
500 / 502 / 503 / 504 |
✅ exponential backoff with jitter |
400 / 401 / 403 / 404 / 409 |
❌ fails immediately |
| Network error / timeout |
❌ |
Configure via mercury.WithRetry(mercury.RetryConfig{...}). Defaults: 3 retries, 500ms
initial delay, 30s cap, 25% jitter.
Idempotency
SendMoneyRequest and InternalTransferRequest both require an IdempotencyKey. Always
generate a fresh UUID per logical operation and persist it alongside your own records so a
retried request — yours or the SDK's — can't double-send money:
import "github.com/google/uuid"
req := &mercury.SendMoneyRequest{
// ...
IdempotencyKey: uuid.NewString(),
}
Optional fields
Mercury request structs use pointers for optional fields, matching Go convention. The
generic mercury.Ptr helper makes literals easy:
mercury.Ptr("a note")
mercury.Ptr(42)
mercury.Ptr(mercury.SortOrderDesc)
Development
go build ./...
go vet ./...
gofmt -l . # should print nothing
go test ./... -race -cover
License
MIT