Skip to content

Repository files navigation

ramp-go

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.Handler compatible
  • Resilient HTTP — per-request retry with full-jitter exponential backoff, 401 auto-refresh, configurable timeouts
  • Structured errors*ramp.Error with discriminated Type, TraceID, RetryAfter, IsRetryable()
  • log/slog native — structured logging throughout, bring your own logger

Installation

go get github.com/iamkanishka/ramp-go

Requires Go 1.25+.


Quick Start

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)
}

Configuration

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)),
})

Project Structure (DDD)

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

Authentication

Client Credentials (default)

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).

Authorization Code (partner / multi-tenant)

// 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.

Pre-obtained Token

client, err := ramp.NewWithToken("ramp_tok_...", ramp.Config{
    ClientID:     "...",
    ClientSecret: "...",
})

Pagination

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.


Deferred Tasks

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,
})

Webhooks

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-SHA256 using the ramp-webhook-signature header
  • Rejects timestamps outside the 5-minute replay window
  • Dispatches to specific + wildcard handlers in registration order
  • Returns 401 on bad signature, 500 on handler error, 200 on success

For manual verification:

event, err := h.ConstructEvent(rawBody, signatureHeader)

Error Handling

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

ERP Sync Workflow

// 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,
})

Sandbox

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"
})

Testing

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...
}

Resource Reference

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

License

MIT