Production-grade Go client for the Ramp Developer API v1.
- Zero external dependencies — pure stdlib only
- Domain-Driven Design — one package per resource domain, clean separation of concerns
- Generic cursor pagination —
*pagination.Iterator[T]with.Collect(),.Take(n)across all list endpoints - OAuth 2.0 — client credentials + authorization code, mutex single-flight token fetch, proactive refresh
- Deferred task polling — typed
poller.Poll[T]with exponential backoff for card/user/limit creation - Webhook handler — HMAC-SHA256 verification, replay-attack protection, typed event routing,
http.Handlercompatible - Resilient HTTP — per-request retry with full-jitter exponential backoff, 401 auto-refresh, configurable timeouts
- Structured errors —
*ramp.Errorwith discriminatedType,TraceID,RetryAfter,IsRetryable() log/slognative — structured logging throughout, bring your own logger
go get github.com/iamkanishka/ramp-goRequires Go 1.25+.
import (
ramp "github.com/iamkanishka/ramp-go"
"github.com/iamkanishka/ramp-go/domain/transaction"
)
client, err := ramp.New(ramp.Config{
ClientID: os.Getenv("RAMP_CLIENT_ID"),
ClientSecret: os.Getenv("RAMP_CLIENT_SECRET"),
Scopes: []string{"transactions:read", "cards:read", "users:read"},
})
if err != nil {
log.Fatal(err)
}
// Iterate all SYNC_READY transactions across all pages
syncReady := ramp.SyncStatusSyncReady
iter := client.Transactions.List(ctx, transaction.ListParams{
SyncStatus: &syncReady,
})
for iter.Next(ctx) {
txn := iter.Item()
fmt.Println(txn.ID, txn.Amount, txn.MerchantName)
}
if err := iter.Err(); err != nil {
log.Fatal(err)
}client, err := ramp.New(ramp.Config{
ClientID: "...",
ClientSecret: "...",
// Space-separated scopes or a []string — bound to the token at issuance
Scopes: []string{"transactions:read", "cards:write", "users:read"},
// Use ramp.SandboxBaseURL for sandbox testing
BaseURL: ramp.SandboxBaseURL,
// Per-request HTTP timeout (default: 30s)
HTTPTimeout: 30 * time.Second,
// Retry config (default: 3 retries, 500ms base, full-jitter backoff)
MaxRetries: 3,
RetryBaseDelay: 500 * time.Millisecond,
// Bring your own slog.Logger (defaults to slog.Default())
Logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
})ramp-go/
├── ramp.go # Root client — assembles all domain services
├── errors.go # ramp.Error, IsNotFound, IsRateLimit, IsAuth, IsValidation
├── types.go # Re-exported shared types (Money, SyncStatus, etc.)
│
├── shared/ # Zero-import shared value types (breaks import cycles)
│ ├── types.go # Money, Address, SyncStatus, PagedResponse, DeferredTaskStatus
│ └── errors.go # Error struct, ErrorType, APIError factory
│
├── domain/ # One package per bounded context
│ ├── accounting/ # GL accounts, custom fields, connections, ERP sync
│ ├── auditlog/ # Audit event log
│ ├── bill/ # Accounts payable lifecycle
│ ├── business/ # Business entity read
│ ├── card/ # Card issuance, suspend, terminate (deferred)
│ ├── cashback/ # Cashback records
│ ├── department/ # Department CRUD
│ ├── entity/ # Multi-entity support
│ ├── limit/ # Spend controls (deferred create/terminate)
│ ├── location/ # Location CRUD
│ ├── merchant/ # Merchant read
│ ├── reimbursement/ # Reimbursement read
│ ├── spendprogram/ # Spend program read
│ ├── statement/ # Statement read
│ ├── transaction/ # Transaction list (all filters), get, update
│ ├── user/ # User CRUD, invite lifecycle (deferred)
│ ├── vendor/ # Vendor read
│ └── webhook/ # Webhook registration CRUD
│
├── webhooks/ # Standalone webhook handler (HMAC verify + dispatch)
│
├── internal/
│ ├── httpclient/ # HTTP transport — retry, backoff, 401 refresh, logging
│ ├── oauth/ # TokenManager — single-flight, proactive refresh
│ ├── pagination/ # Generic Iterator[T] — Next/Item/Err/Collect/Take
│ └── poller/ # Deferred task poller — typed Poll[T]
│
└── testutil/ # Test helpers — mock server, fixtures, WebhookSignature
Tokens are fetched automatically on the first API call, cached, and refreshed
60 seconds before expiry. Concurrent callers share a single in-flight fetch
(single-flight via sync.Cond).
// Redirect user to Ramp, then exchange the code:
if err := client.ExchangeAuthCode(ctx, code, "https://yourapp.com/callback"); err != nil {
return err
}
// All subsequent calls use the exchanged token.client, err := ramp.NewWithToken("ramp_tok_...", ramp.Config{
ClientID: "...",
ClientSecret: "...",
})All list methods return *pagination.Iterator[T]:
// Lazy iteration — pages are fetched on demand
iter := client.Transactions.List(ctx, transaction.ListParams{})
for iter.Next(ctx) {
txn := iter.Item()
_ = txn
}
if err := iter.Err(); err != nil { /* handle */ }
// Collect all pages into a slice
all, err := client.Users.List(ctx, user.ListParams{}).Collect(ctx)
// Take at most N items (stops fetching after the page containing item N)
first10, err := client.Transactions.List(ctx, transaction.ListParams{}).Take(ctx, 10)Cursor safety: Ramp uses cursor-only pagination. Take(ctx, 10) on a 100-item
dataset fetches exactly 1 page — it never over-fetches.
Card issuance, user creation, and limit creation are asynchronous — the API returns a task reference immediately. The SDK gives you full control:
// Option 1: fire-and-forget, get task ref, poll manually
ref, _, err := client.Cards.Create(ctx, card.CreateParams{
DisplayName: "AWS Infra",
UserID: "user-uuid",
IdempotencyKey: uuid.New().String(),
}, nil) // nil opts = no polling
// Option 2: block until done (poll=true via opts)
_, newCard, err := client.Cards.Create(ctx, card.CreateParams{
DisplayName: "AWS Infra",
UserID: "user-uuid",
IdempotencyKey: uuid.New().String(),
}, &card.PollOptions{
IntervalMs: 500,
MaxIntervalMs: 5_000,
TimeoutMs: 60_000,
})import "github.com/iamkanishka/ramp-go/webhooks"
import "github.com/iamkanishka/ramp-go/domain/webhook"
h := webhooks.NewHandler(os.Getenv("RAMP_WEBHOOK_SECRET"))
h.On(webhook.EventTransactionCreated, func(e webhooks.RawEvent) error {
var txn transaction.Transaction
if err := json.Unmarshal(e.Data, &txn); err != nil {
return err
}
return syncToERP(txn)
}).On(webhook.EventCardSuspended, func(e webhooks.RawEvent) error {
log.Printf("card suspended: %s", e.ID)
return nil
}).OnAny(func(e webhooks.RawEvent) error {
// Wildcard — fires for every event type
metrics.Increment("ramp.webhook." + string(e.Type))
return nil
})
// Register as http.Handler — works with any net/http compatible router
http.Handle("/webhooks/ramp", h)The handler:
- Verifies
HMAC-SHA256using theramp-webhook-signatureheader - Rejects timestamps outside the 5-minute replay window
- Dispatches to specific + wildcard handlers in registration order
- Returns
401on bad signature,500on handler error,200on success
For manual verification:
event, err := h.ConstructEvent(rawBody, signatureHeader)All errors are *ramp.Error with a discriminated Type:
_, err := client.Users.Get(ctx, userID)
if err != nil {
var re *ramp.Error
if errors.As(err, &re) {
switch re.Type {
case ramp.ErrorTypeNotFound:
// 404 — user doesn't exist
case ramp.ErrorTypeRateLimit:
log.Printf("rate limited, retry after %ds", re.RetryAfter)
case ramp.ErrorTypeAuthentication:
// 401 — refresh credentials
case ramp.ErrorTypeAuthorization:
// 403 — insufficient scope
case ramp.ErrorTypeValidation:
log.Printf("bad request: %s\nbody: %s", re.Message, re.Body)
case ramp.ErrorTypeServer:
log.Printf("ramp server error [trace=%s]", re.TraceID)
}
}
}
// Convenience predicates
if ramp.IsNotFound(err) { /* 404 */ }
if ramp.IsRateLimit(err) { /* 429 */ }
if ramp.IsAuth(err) { /* 401 or 403 */ }
if ramp.IsValidation(err) { /* 400 */ }| Type | HTTP | Retried automatically |
|---|---|---|
authentication_error |
401 | Once (token refresh + retry) |
authorization_error |
403 | No |
not_found |
404 | No |
validation_error |
400 | No |
rate_limit_error |
429 | Yes (up to MaxRetries) |
server_error |
5xx | Yes (up to MaxRetries) |
network_error |
— | Yes (up to MaxRetries) |
timeout_error |
— | No |
deferred_task_error |
— | No |
// 1. Fetch all objects ready to sync
syncReady := ramp.SyncStatusSyncReady
txns, err := client.Transactions.List(ctx, transaction.ListParams{
SyncStatus: &syncReady,
EntityID: ramp.Ptr("entity-uuid"), // multi-entity support
}).Collect(ctx)
// 2. Process in your ERP...
for _, txn := range txns {
if err := yourERP.Sync(txn); err != nil {
// handle
}
}
// 3. Report results back to Ramp
syncs := make([]accounting.SyncEntry, len(txns))
for i, txn := range txns {
syncs[i] = accounting.SyncEntry{
ObjectID: txn.ID,
ObjectType: accounting.SyncObjectTransaction,
SyncStatus: accounting.SyncResultSuccess,
}
}
err = client.Accounting.PostSyncStatus(ctx, accounting.PostSyncParams{
IdempotencyKey: uuid.New().String(),
Syncs: syncs,
})client, err := ramp.New(ramp.Config{
ClientID: os.Getenv("RAMP_SANDBOX_CLIENT_ID"),
ClientSecret: os.Getenv("RAMP_SANDBOX_CLIENT_SECRET"),
BaseURL: ramp.SandboxBaseURL, // "https://demo-api.ramp.com"
})Use testutil.NewServer to mock the Ramp API in your own tests:
import "github.com/iamkanishka/ramp-go/testutil"
func TestMyWorkflow(t *testing.T) {
srv := testutil.NewServer(t) // auto-closes on t.Cleanup
srv.QueueJSON(testutil.MakeUser(map[string]any{"email": "alice@example.com"}))
client, _ := ramp.NewWithToken("test_token", ramp.Config{
ClientID: "id",
ClientSecret: "secret",
BaseURL: srv.URL(),
})
u, err := client.Users.Get(context.Background(), "user-001")
// assert...
}client.X |
Domain package | Coverage |
|---|---|---|
Accounting |
domain/accounting |
GL accounts, custom fields+options, connections, ERP sync |
AuditLogs |
domain/auditlog |
Event log read |
Bills |
domain/bill |
Create, list, get, update, void |
Business |
domain/business |
Business entity read |
Cards |
domain/card |
List, get, create (deferred), update, suspend, unsuspend, terminate |
Cashbacks |
domain/cashback |
List, get |
Departments |
domain/department |
List, get, create, update, delete |
Limits |
domain/limit |
List, get, create (deferred), update, terminate (deferred) |
Locations |
domain/location |
List, get, create, update, delete |
Merchants |
domain/merchant |
List, get |
Reimbursements |
domain/reimbursement |
List, get |
SpendPrograms |
domain/spendprogram |
List, get |
Statements |
domain/statement |
List, get |
Transactions |
domain/transaction |
List (all filters), get, update memo/fields |
Users |
domain/user |
List, get, create (deferred), update, deactivate, reactivate |
Vendors |
domain/vendor |
List, get |
Webhooks |
domain/webhook |
List, get, create, update, delete |
MIT