The official Go SDK for the FluidPay payment gateway. It follows the current API reference at sandbox.fluidpay.com/docs and covers transactions, the Customer Vault, recurring billing, terminals, settlements, BIN lookup, user and API key administration, and webhook verification.
- Idiomatic Go: one
Client, one service per API area,context.Contexton every call, typed requests and responses, no external dependencies. - API-key first: private keys go in the
Authorizationheader, public keys are rejected up front, andNewClientFromEnvkeeps secrets out of source. - Honest errors: a
*fluidpay.Errorcarries the HTTP status, the gateway message and thex-correlation-idsupport will ask you for. - Fully unit tested against an in-process gateway, with runnable examples and an opt-in sandbox integration suite.
- Install
- Quick start
- Authentication and API keys
- Configuration
- Transactions
- Customer Vault
- Recurring billing
- Terminals, settlements and BIN lookup
- Users, API keys and JWTs
- Errors and correlation IDs
- Webhooks
- Testing
- Migrating from the previous SDK
- Contributing
go get github.com/fluidpay/fluidpay-goRequires Go 1.21 or newer.
package main
import (
"context"
"log"
"time"
"github.com/fluidpay/fluidpay-go"
)
func main() {
// Use your private key (api_...). Public keys (pub_...) are for the browser.
client, err := fluidpay.NewClient("api_your_secret_key", fluidpay.WithSandbox())
if err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
tx, err := client.Transactions.Sale(ctx, &fluidpay.TransactionRequest{
Amount: 1299, // cents: $12.99
IdempotencyKey: fluidpay.NewIdempotencyKey(),
PaymentMethod: fluidpay.PaymentMethod{
Card: &fluidpay.CardPayment{
Number: "4111111111111111",
ExpirationDate: "12/30",
CVC: "123",
},
},
})
if err != nil {
log.Fatal(err) // transport error, bad credentials, invalid request...
}
if !tx.Approved() {
log.Fatalf("declined: %d %s", tx.ResponseCode, tx.Response)
}
log.Printf("approved %s, auth code %s", tx.ID, tx.ResponseBody.Card.AuthCode)
}Three things to remember:
- Amounts are integers in the smallest currency unit (cents for USD).
- A declined card is not an error. The call succeeds and
tx.Approved()is false. Errors mean the request itself failed. - Give transaction calls a generous deadline. FluidPay recommends at least three minutes because some authorizations take that long; the default client timeout is set accordingly.
FluidPay issues two kinds of keys from Settings → API Keys in the control panel:
| Key | Prefix | Use |
|---|---|---|
| Private (secret) | api_ |
Server-side calls, which is everything this SDK does |
| Public | pub_ |
Browser-side tooling such as the Tokenizer and Cart sessions |
NewClient sends the private key as the Authorization header on every
request. It refuses pub_ keys, because the gateway would answer
401 Unauthorized anyway.
Keep keys out of source code with NewClientFromEnv:
export FLUIDPAY_API_KEY=api_your_secret_key
export FLUIDPAY_ENVIRONMENT=sandbox # or production (default)
# export FLUIDPAY_BASE_URL=http://localhost:8001/api # optional overrideclient, err := fluidpay.NewClientFromEnv()Options passed to NewClientFromEnv are applied after the environment and
take precedence.
If a key stops working, check the usual suspects from the FluidPay docs:
the key was deleted, it is a public key, it has IP or URL restrictions
(localhost is never a valid URL), it belongs to a partner rather than a
merchant account, or it belongs to the other environment.
fluidpay.IsUnauthorized(err) identifies this case.
You can also manage keys programmatically; see Users, API keys and JWTs.
| Option | Effect |
|---|---|
WithSandbox() |
Target https://sandbox.fluidpay.com/api. Simulated processing, no billing. |
WithEnvironment(env) |
fluidpay.Sandbox or fluidpay.Production (default). |
WithBaseURL(url) |
Any base URL including the /api prefix, for local gateways or proxies. |
WithHTTPClient(hc) |
Supply your own *http.Client (custom transport, tracing, retries). |
WithTimeout(d) |
Set the HTTP timeout. Default is fluidpay.DefaultTimeout (3 minutes). |
WithUserAgent(ua) |
Prefix the User-Agent with your application name. |
WithBearerToken(jwt) |
Authenticate with a JWT from Auth.ObtainJWT instead of an API key. |
A *Client is safe for concurrent use. Build one and share it.
Every result embeds fluidpay.APIResource, so the HTTP response that
produced it is one call away:
tx.CorrelationID() // quote this in support requests
tx.LastResponse.StatusCode
tx.LastResponse.HeaderMethods with nothing else to return (Void, Delete, Settle, ...) hand
back the *fluidpay.APIResponse directly. See
Correlation IDs for every way to reach the ID.
client.Transactions maps to /transaction:
| Method | Endpoint | Notes |
|---|---|---|
Sale |
POST /transaction |
Authorize and capture in one call |
Authorize |
POST /transaction |
Hold funds; capture later |
Verify |
POST /transaction |
Zero-dollar verification |
Credit |
POST /transaction |
Send funds without a prior sale |
Create |
POST /transaction |
Any type via TransactionRequest.Type |
Capture |
POST /transaction/{id}/capture |
Nil request captures the full amount |
Void |
POST /transaction/{id}/void |
Cancels a pending settlement (auth reversal where supported) |
Refund |
POST /transaction/{id}/refund |
Partial refunds allowed up to the settled total |
Get |
GET /transaction/{id} |
|
Search |
POST /transaction/search |
Defaults to the prior four months |
PaymentMethod holds exactly one of:
fluidpay.PaymentMethod{Card: &fluidpay.CardPayment{Number: "...", ExpirationDate: "MM/YY", CVC: "..."}}
fluidpay.PaymentMethod{Token: "temporary-token-from-tokenizer"}
fluidpay.PaymentMethod{Customer: &fluidpay.CustomerPayment{ID: customerID}} // Customer Vault
fluidpay.PaymentMethod{ACH: &fluidpay.ACHPayment{RoutingNumber: "...", AccountNumber: "...", SecCode: fluidpay.SecCodeWeb, AccountType: fluidpay.AccountTypeChecking}}
fluidpay.PaymentMethod{Terminal: &fluidpay.TerminalPayment{ID: terminalID, PrintReceipt: fluidpay.ReceiptBoth}}
fluidpay.PaymentMethod{ApplePayToken: &fluidpay.ApplePayToken{TemporaryToken: "..."}} // production only
fluidpay.PaymentMethod{GooglePayToken: json.RawMessage(tokenFromGoogle)} // production only
fluidpay.PaymentMethod{APM: &fluidpay.APMPayment{Type: "klarna", MerchantRedirectURL: "..."}}TransactionRequest exposes every documented field: BaseAmount (let the
gateway add surcharges), tax/shipping/tip/discount amounts,
PaymentAdjustment, billing and shipping addresses, LineItems and the
other Level 3 fields, CustomFields, Descriptor, CreateVaultRecord,
AllowPartialPayment, SplitTransactionAmount, CIT/MIT indicators and
HSA/FSA amounts. Zero values are omitted from the request.
auth, err := client.Transactions.Authorize(ctx, &fluidpay.TransactionRequest{
Amount: 5000,
PaymentMethod: fluidpay.PaymentMethod{Token: token},
})
// ... ship the goods ...
captured, err := client.Transactions.Capture(ctx, auth.ID, &fluidpay.CaptureRequest{Amount: 4500})switch {
case tx.Approved(): // response codes 100-199
if tx.PartiallyApproved() {
// tx.AmountAuthorized < tx.Amount: collect the rest or void
}
case tx.Declined(): // 200-299, issuer decline
case tx.GatewayDeclined(): // 300-399, duplicate, rule engine, blocked customer
case tx.ProcessorError(): // 400-499
}The processor detail lives under tx.ResponseBody.Card, .ACH or
.Terminal, whichever applies, including AVS and CVV response codes.
Query fields take small operator structs. Constructors keep it short:
page, err := client.Transactions.Search(ctx, &fluidpay.TransactionSearchRequest{
Status: fluidpay.Equal(fluidpay.StatusSettled),
Amount: fluidpay.GreaterThan(10000),
CreatedAt: fluidpay.DateRange(since, time.Now()), // or fluidpay.Day(day)
CustomerID: fluidpay.NotEqual(""),
Limit: 100,
Offset: 0,
})
for _, tx := range page.Data { ... }
fmt.Println(page.TotalCount)Set IdempotencyKey (a UUID; fluidpay.NewIdempotencyKey() makes one) and
retry the same request safely for five minutes, or for IdempotencyTime
seconds.
client.Customers is the canonical customer API (/vault/customer).
Store a customer once, then charge with
PaymentMethod{Customer: &CustomerPayment{ID: customer.ID}}.
customer, err := client.Customers.Create(ctx, &fluidpay.CustomerCreateRequest{
VerificationOptions: fluidpay.VerificationOptions{Validate: true}, // $0 verification
Description: "Jane Doe",
DefaultPayment: &fluidpay.VaultPaymentMethod{
Card: &fluidpay.VaultCard{Number: "4111111111111111", ExpirationDate: "12/30"},
},
DefaultBillingAddress: &fluidpay.VaultAddress{
FirstName: "Jane", LastName: "Doe", Line1: "123 Some St",
City: "Chicago", State: "IL", PostalCode: "60601", Country: "US",
},
})
customer.ID
customer.DefaultCard().MaskedNumber
customer.Addresses, customer.Cards, customer.ACH // flattened from the gateway's nested shape| Method | Endpoint |
|---|---|
Create, Get, Search, Update, Delete |
/vault/customer, /vault/{id}, /vault/customer/search |
CreateAddress, UpdateAddress, DeleteAddress |
/vault/customer/{id}/address[/{addressId}] |
CreateCard, UpdateCard, DeleteCard |
/vault/customer/{id}/card[/{paymentMethodId}] |
CreateACH, UpdateACH, DeleteACH |
/vault/customer/{id}/ach[/{paymentMethodId}] |
CreateToken, UpdateToken |
/vault/customer/{id}/token[/{paymentMethodId}] |
CreateApplePay, CreateGooglePay |
/vault/customer/{id}/applepay, /googlepay |
Create calls return the whole customer; the new record's ID is in
CreatedAddressID or CreatedPaymentMethodID. VerificationOptions
(Validate, Authorize, BypassRuleEngine) are sent as query parameters
on customer and payment method creation.
The gateway rejects an address identical to one already stored
(invalid: would create a duplicate address). Check customer.Addresses
and reuse the existing ID instead.
The older /customer endpoints are still available as
client.LegacyCustomers, marked Deprecated. FluidPay advises against
mixing the two families in one application.
Add-ons and discounts adjust a charge by a fixed amount or a percentage. Plans define the schedule. Subscriptions apply a plan to a vault customer.
plan, err := client.Plans.Create(ctx, &fluidpay.PlanRequest{
Name: "Pro monthly",
Amount: 4900,
BillingCycleInterval: 1,
BillingFrequency: fluidpay.BillingMonthly, // monthly, twice_monthly, daily
BillingDays: "1", // "1,15" for twice monthly, "0" for last day
})
sub, err := client.Subscriptions.Create(ctx, &fluidpay.SubscriptionRequest{
PlanID: plan.ID,
Customer: fluidpay.SubscriptionCustomer{ID: customer.ID},
Amount: plan.Amount,
BillingCycleInterval: 1,
BillingFrequency: fluidpay.BillingMonthly,
BillingDays: "1",
NextBillDate: "2026-10-01",
})Lifecycle methods on client.Subscriptions: Pause, Activate (with an
optional next bill date), MarkPastDue, Cancel, Complete, Delete,
plus Search and Get.
The billing engine moves a subscription through active → failing →
failed on consecutive declines and sets error on configuration
problems. sub.NeedsAttention() covers all three. Renewal charges are
ordinary transactions with TransactionSource == fluidpay.SourceRecurring
and SubscriptionID set.
terminals, err := client.Terminals.List(ctx) // GET /terminals
_, err = client.Terminals.Settle(ctx, terminalID) // POST /terminal/{id}/settle
batches, err := client.Settlements.SearchBatches(ctx, &fluidpay.SettlementBatchSearchRequest{
BatchDate: fluidpay.Day(time.Now().AddDate(0, 0, -1)),
}) // POST /settlement/batch/search
batches.Summary, batches.Results
bin, err := client.Lookup.BIN(ctx, &fluidpay.BINLookupRequest{
BIN: "424242", Country: "US", State: "IL",
}) // POST /lookup/bin/protected
bin.CardBrand, bin.CardType, bin.IsSurchargeableTo charge through a terminal, run a normal sale with
PaymentMethod{Terminal: ...}. Sandbox terminal behaviour is driven by
the terminal's TPN (see Testing).
me, err := client.Users.Current(ctx) // who owns this key
me.Can("process_refund")
key, err := client.APIKeys.Create(ctx, &fluidpay.APIKeyCreateRequest{
Type: fluidpay.APIKeyTypePrivate, Name: "backend-2026",
})
key.Key // shown once; store it now
keys, err := client.APIKeys.List(ctx)
_, err = client.APIKeys.Delete(ctx, key.ID) // revoke; the response carries the correlation idclient.Users also offers Get, List, Create, Update, Delete and
ChangePassword. For user-driven sessions, client.Auth.ObtainJWT
exchanges a username and password for a token you can pass to
WithBearerToken; Auth.Logout, ForgotUsername, ForgotPassword and
ResetPassword round out account recovery.
Every method returns a plain error for transport failures (DNS, TLS,
timeouts, cancelled contexts) and a *fluidpay.Error when the gateway
rejects the request with a non-2xx status or a "status": "failed"
envelope.
tx, err := client.Transactions.Sale(ctx, req)
if err != nil {
if apiErr, ok := fluidpay.AsError(err); ok {
log.Printf("%d %s (correlation id %s)", apiErr.StatusCode, apiErr.Msg, apiErr.CorrelationID)
}
if fluidpay.IsUnauthorized(err) { /* rotate or fix the key */ }
if fluidpay.IsNotFound(err) { /* unknown id */ }
return err
}Error.Error() reads like
fluidpay: POST /transaction: 400 Bad Request: bad request error: invalid Postal Code (correlation id ...).
FluidPay attaches an x-correlation-id header to every response. It is
the key their support team uses to find a request in their logs, so the
SDK keeps it reachable no matter how a call ends:
| Situation | Where to find it |
|---|---|
A call that returns a value (Sale, Get, List, ...) |
tx.CorrelationID() or tx.LastResponse.CorrelationID; list results and each item carry it too |
A call with no other result (Void, Delete, Settle, Logout, ...) |
The *fluidpay.APIResponse it returns: resp.CorrelationID |
| The gateway rejected the request | apiErr.CorrelationID, apiErr.Response(), or fluidpay.CorrelationID(err) |
| The response could not be decoded | fluidpay.CorrelationID(err); the message includes it as well |
| Every request, for your logs | fluidpay.WithResponseHook |
// On results.
tx, err := client.Transactions.Sale(ctx, req)
log.Printf("sale %s correlation id %s", tx.ID, tx.CorrelationID())
// On calls with no other result.
resp, err := client.Transactions.Void(ctx, tx.ID)
log.Printf("void correlation id %s", resp.CorrelationID)
// On any error, wrapped or not.
if err != nil {
log.Printf("failed: %v (correlation id %q)", err, fluidpay.CorrelationID(err))
}
// Once, for everything.
client, _ := fluidpay.NewClientFromEnv(fluidpay.WithResponseHook(func(r *fluidpay.APIResponse) {
log.Printf("fluidpay %s /%s -> %d correlation id %s", r.Method, r.Path, r.StatusCode, r.CorrelationID)
}))APIResponse also exposes the HTTP StatusCode, the full Header, and the
envelope Status and Msg. Transport failures never reach the gateway, so
they carry no correlation ID and fluidpay.CorrelationID(err) returns "".
Deliveries are signed with HMAC-SHA256 over the raw body, base64url without
padding, in the Signature header. Verify against the raw bytes exactly as
received.
http.HandleFunc("/webhooks/fluidpay", func(w http.ResponseWriter, r *http.Request) {
event, err := fluidpay.ParseWebhookRequest(r, os.Getenv("FLUIDPAY_WEBHOOK_SECRET"))
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
if event.IsTest() { // signed with fluidpay.WebhookTestSecret, verified automatically
w.WriteHeader(http.StatusOK)
return
}
// event.EventID is the idempotency key; store it before doing side effects.
tx, _ := event.Transaction()
log.Printf("%s: %s is %s", event.Type, tx.ID, tx.Status)
w.WriteHeader(http.StatusOK)
})ParseWebhookRequest also tries the Signature-Previous header, and you
may pass the previous secret as an extra argument during rotation. For
non-HTTP contexts use ParseWebhook(body, signature, secret) or the lower
level VerifyWebhookSignature.
Everything below is documented under Test Data and only works in the sandbox. Any future expiration date and any CVC work; cards not listed are approved.
| Card | Result |
|---|---|
4111111111111111 |
Approved (Visa debit, not surchargeable) |
4005519200000004 |
Approved (Visa credit, surchargeable; use with BaseAmount) |
4000000000000002 |
Declined |
4000000000009995 |
Insufficient funds |
4000000000000069 |
Expired card |
4000000000000051 |
Partial approval (half the amount) |
4000000000000010 |
Auth succeeds, refund declines |
| Trigger | Effect |
|---|---|
CVC 200 |
CVV response N (no match) |
Postal code 20000 |
AVS response N (no match) |
ACH routing 000000000 |
Decline |
ACH routing 000000001 / 000000002 |
Return / late return during settlement |
Terminal TPN 000000000001 / 2 / 3 / 4 |
Success / decline / error / success with tip |
The unit suite runs entirely against an in-process fake gateway and checks every endpoint's method, path, query string, request body and response decoding, using response fixtures taken from the API reference:
go test -race ./...An opt-in suite exercises the real sandbox with the test data above and cleans up after itself:
FLUIDPAY_API_KEY=api_... FLUIDPAY_ENVIRONMENT=sandbox go test -tags integration -run TestIntegration ./...The CI workflow runs it on pushes when a FLUIDPAY_SANDBOX_API_KEY
repository secret is configured.
Runnable programs live under examples/:
| Program | Shows |
|---|---|
examples/sale |
A card sale and void with error handling |
examples/vault |
Storing a customer, adding an address, charging the stored card |
examples/subscription |
Plan and subscription creation, pause/activate/cancel, search |
examples/webhook |
An HTTP handler that verifies signatures and dedupes events |
FLUIDPAY_API_KEY=api_... FLUIDPAY_ENVIRONMENT=sandbox go run ./examples/saleGodoc examples are in example_test.go.
The earlier package exposed free functions that took a Fluidpay struct
and returned envelope structs. Those are gone; the table shows the
replacements.
| Before | After |
|---|---|
Fluidpay{APIKey, Client, Ctx, Sandbox, LocalDev} |
fluidpay.NewClient(key, fluidpay.WithSandbox()); WithBaseURL replaces LocalDev; the context is passed per call |
DoTransaction(fp, req) |
client.Transactions.Sale/Authorize/Verify/Credit/Create(ctx, req) |
GetTransactionStatus(fp, id) |
client.Transactions.Get(ctx, id) |
QueryTransactions(fp, req) |
client.Transactions.Search(ctx, req) with fluidpay.Equal, IntEqual, DateRange... |
CaptureTransaction(fp, req, id) |
client.Transactions.Capture(ctx, id, req) |
VoidTransaction(fp, id) |
client.Transactions.Void(ctx, id) |
RefundTransaction(fp, req, id) |
client.Transactions.Refund(ctx, id, req) |
CreateCustomer and friends (/customer) |
client.Customers.* (Customer Vault) for new code; client.LegacyCustomers.* keeps the old endpoints |
CreateAddOn, GetAddOns, ... |
client.AddOns.Create/Get/List/Update/Delete |
CreateDiscount, ... |
client.Discounts.* |
CreatePlan, ... |
client.Plans.* |
CreateSubscription, ... |
client.Subscriptions.* plus Pause, Activate, Cancel, Complete, MarkPastDue, Search |
GetTerminals(fp) |
client.Terminals.List(ctx) |
CreateKey, GetKeys, DeleteKey |
client.APIKeys.Create/List/Delete |
CreateUser, GetCurrentUser, ... |
client.Users.* |
ObtainJWT, TokenLogout, Forgotten*, PasswordReset |
client.Auth.*; NewAuth/SetAuth become WithBearerToken |
TransactionRequest.EmailReciept |
EmailReceipt (the JSON tag was wrong before) |
PaymentMethodsRequest, CardRequest, AchRequest, ... |
PaymentMethod, CardPayment, ACHPayment, CustomerPayment, TerminalPayment |
TerminalResponse / TransactionResponse |
*Transaction; check tx.Approved() instead of comparing Msg == "success" |
uint amounts |
int amounts |
TestAPIKey constant |
Removed. Use FLUIDPAY_API_KEY. |
Behavioural changes worth knowing:
- Failed requests now return an error instead of an empty struct. Declines
still return a
*Transaction. - The context you pass is honoured; the old code ignored it.
- Response
Dataenvelopes are unwrapped for you. Lists return a*XListwithDataandTotalCount. - Calls that have no other result (
Void,Delete, ...) return(*APIResponse, error)so the correlation ID is never lost.
gofmt -l . # formatting
go vet ./... # static checks
go test -race ./... # unit testsPlease add a unit test with a fixture from the API reference for any new endpoint, and keep the CHANGELOG current.