#payment-processing #payment #worldpay

bin+lib payrix

Rust client for the Payrix payment processing API

1 unstable release

Uses new Rust 2024

0.3.0 Feb 9, 2026

#66 in Finance

MIT license

1.5MB
24K SLoC

payrix

A Rust client library for the Payrix payment processing API.

This library was created based on 18 months of production experience implementing Payrix Pro, documenting quirks and API inconsistencies along the way. We used Claude Code to help with grunt work like ensuring enum correctness and documenting return codes.

Crates.io Documentation License: MIT

Features

  • Full async/await support with Tokio
  • Built-in rate limiting to avoid API throttling
  • Automatic retry with exponential backoff for transient failures
  • Strongly typed API responses with 86 enums and 73 resource types
  • High-level workflows for complex multi-step operations
  • Comprehensive error handling with domain-specific error types
  • Optional SQLx support for database integration
  • Webhook server for receiving real-time event notifications
  • OpenAPI 3.1 spec included for reference

Installation

Add this to your Cargo.toml:

[dependencies]
payrix = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

Feature Flags

# Enable SQLx derive macros for database storage
payrix = { version = "0.1", features = ["sqlx"] }

# Enable webhook server for receiving Payrix callbacks
payrix = { version = "0.1", features = ["webhooks"] }

# Enable local entity cache for faster queries
payrix = { version = "0.1", features = ["cache"] }

# Enable all optional types (financial reports, terminals)
payrix = { version = "0.1", features = ["full"] }

# Enable CLI binary for entity lookups and operations
payrix = { version = "0.1", features = ["cli"] }
Feature Description
sqlx SQLx FromRow derives for database storage
webhooks Webhook server for receiving Payrix callbacks
webhook-cli CLI binary for webhook server management
cache Local entity cache for faster queries
financial Financial/reporting types (settlements, statements)
terminal Terminal/physical device types
cli CLI binary for entity lookups and operations
full All optional types (financial + terminal)

Quick Start

use payrix::{PayrixClient, Environment, EntityType, Customer};

#[tokio::main]
async fn main() -> Result<(), payrix::Error> {
    // Create a client
    let client = PayrixClient::new("your-api-key", Environment::Test)?;

    // Get a customer by ID
    let customer: Option<Customer> = client.get_one(
        EntityType::Customers,
        "t1_cus_12345678901234567890123"
    ).await?;

    Ok(())
}

Common Operations

Creating a Transaction

use payrix::{PayrixClient, Environment, EntityType, Transaction};

async fn charge_card(client: &PayrixClient) -> Result<Transaction, payrix::Error> {
    client.create(
        EntityType::Txns,
        &serde_json::json!({
            "merchant": "t1_mer_12345678901234567890123",
            "token": "t1_tok_12345678901234567890123",
            "total": 1000  // $10.00 in cents
        })
    ).await
}

Searching for Records

use payrix::{PayrixClient, EntityType, Token, SearchBuilder, SearchOperator};

async fn find_customer_tokens(
    client: &PayrixClient,
    customer_id: &str
) -> Result<Vec<Token>, payrix::Error> {
    // Using SearchBuilder
    let search = SearchBuilder::new()
        .field("customer", customer_id)
        .build();

    client.search(EntityType::Tokens, &search).await
}

Search Operators

The library provides strongly-typed search operators. These have been tested against the live API:

Operator String Description Example
Equals equals Exact match (default) inactive[equals]=0
Exact exact Exact string match name[exact]=Test Plan
Greater greater Greater than (>) amount[greater]=1000
Less less Less than (<) amount[less]=5000
Like like Pattern match (% wildcard) name[like]=%test%
In in Value in list status[in]=1,2,3
NotIn notin Value NOT in list status[notin]=0
Diff diff Not equal (!=) inactive[diff]=1
NotLike notlike Pattern NOT matching name[notlike]=%test%
Sort sort Sort results created[sort]=desc

Warning: The operators gte, lte, gt, lt, lesser, and eq are documented in Payrix but do NOT work (they return empty results). Always use greater and less instead.

Basic Operator Usage

use payrix::{SearchBuilder, SearchOperator};

// Greater than: find plans with amount > $10.00
let search = SearchBuilder::new()
    .field_with_op("amount", "1000", SearchOperator::Greater)
    .build();
// Produces: "amount[greater]=1000"

// Less than: find plans with amount < $100.00
let search = SearchBuilder::new()
    .field_with_op("amount", "10000", SearchOperator::Less)
    .build();
// Produces: "amount[less]=10000"

// Pattern matching: find customers with email containing "gmail"
let search = SearchBuilder::new()
    .field_with_op("email", "%gmail%", SearchOperator::Like)
    .build();
// Produces: "email[like]=%gmail%"

// Multiple values: find transactions with status 1, 2, or 3
let search = SearchBuilder::new()
    .field_multi("status", &["1", "2", "3"], SearchOperator::In)
    .build();
// Produces: "status[in]=1,2,3"

Combining Conditions (AND)

Multiple conditions are combined with AND logic by default:

use payrix::{SearchBuilder, SearchOperator};

// Find active plans with amount > $10.00
let search = SearchBuilder::new()
    .field("inactive", "0")
    .field_with_op("amount", "1000", SearchOperator::Greater)
    .build();
// Produces: "inactive[equals]=0&amount[greater]=1000"

OR Conditions

Use or_group() for OR logic:

use payrix::{SearchBuilder, SearchOperator};

// Find plans where status=1 OR status=2
let search = SearchBuilder::new()
    .or_group(&[
        ("status", "1", SearchOperator::Equals),
        ("status", "2", SearchOperator::Equals),
    ])
    .build();
// Produces: "[or]status[equals]=1&[or]status[equals]=2"

// Complex: (inactive=0 OR inactive=1) AND amount > 0
let search = SearchBuilder::new()
    .or_group(&[
        ("inactive", "0", SearchOperator::Equals),
        ("inactive", "1", SearchOperator::Equals),
    ])
    .field_with_op("amount", "0", SearchOperator::Greater)
    .build();
// Produces: "[or]inactive[equals]=0&[or]inactive[equals]=1&amount[greater]=0"

Date Range Queries

Dates must be in YYYYMMDD format (no dashes):

use payrix::{SearchBuilder, SearchOperator};
use payrix::search::make_payrix_date;
use chrono::NaiveDate;

// Find transactions created in December 2025
let start = NaiveDate::from_ymd_opt(2025, 12, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();

let search = SearchBuilder::new()
    .field_with_op("created", &make_payrix_date(&start), SearchOperator::Greater)
    .field_with_op("created", &make_payrix_date(&end), SearchOperator::Less)
    .build();
// Produces: "created[greater]=20251201&created[less]=20251231"

Sorting Results

use payrix::{SearchBuilder, SearchOperator};

// Sort by created date descending (newest first)
let search = SearchBuilder::new()
    .field("inactive", "0")
    .field_with_op("created", "desc", SearchOperator::Sort)
    .build();
// Produces: "inactive[equals]=0&created[sort]=desc"

Pagination

use payrix::{PayrixClient, EntityType, Transaction};

async fn get_all_transactions(client: &PayrixClient) -> Result<Vec<Transaction>, payrix::Error> {
    // Automatically handles pagination
    client.get_all(EntityType::Txns).await
}
use payrix::{PayrixClient, EntityType, Transaction};

async fn get_transaction_with_token(
    client: &PayrixClient,
    txn_id: &str
) -> Result<Option<Transaction>, payrix::Error> {
    // Expand the token relation
    client.get_one_expanded(
        EntityType::Txns,
        txn_id,
        &["token", "customer"]
    ).await
}

High-Level Workflows

The library includes workflow modules that encapsulate complex multi-step operations:

Workflow Description
merchant_onboarding Onboard new merchants with business info, bank accounts, and owners
dispute_handling Handle chargeback disputes with compile-time state enforcement
webhook_setup Set up webhook alerts for real-time event notifications
subscription_management Manage customer subscriptions to recurring payment plans
payment_processing Process credit card and bank account payments
customer_management Create and manage customers with address matching
tokenization Tokenize payment methods with automatic customer creation
transaction_management Handle refunds, voids, and transaction lookups
hold_handling Handle transaction holds from risk department with compile-time state enforcement
account_management Manage entities, merchants, bank accounts, and funds

Merchant Onboarding

use payrix::{PayrixClient, Environment, onboard_merchant, OnboardMerchantRequest};
use payrix::{BusinessInfo, MerchantConfig, BankAccountInfo, MemberInfo, Address, TermsAcceptance};
use payrix::types::{MerchantType, MemberType, AccountHolderType, MerchantEnvironment, DateYmd};

async fn onboard_new_merchant(client: &PayrixClient) -> Result<(), payrix::Error> {
    let result = onboard_merchant(client, OnboardMerchantRequest {
        business: BusinessInfo {
            business_type: MerchantType::Llc,
            legal_name: "Acme Corp LLC".to_string(),
            address: Address {
                line1: "123 Main St".to_string(),
                line2: None,
                city: "Chicago".to_string(),
                state: "IL".to_string(),
                zip: "60601".to_string(),
                country: "USA".to_string(),
            },
            phone: "3125551234".to_string(),
            email: "contact@acme.com".to_string(),
            website: Some("https://acme.com".to_string()),
            ein: "123456789".to_string(),
        },
        merchant: MerchantConfig {
            dba: "Acme Services".to_string(),
            mcc: "5812".to_string(),
            environment: MerchantEnvironment::Ecommerce,
            annual_cc_sales: 500_000_00,  // $500,000 in cents
            avg_ticket: 50_00,             // $50 in cents
            established: DateYmd::new("20200101").unwrap(),
            is_new_business: false,
        },
        accounts: vec![BankAccountInfo {
            routing_number: "071000013".to_string(),
            account_number: "123456789".to_string(),
            account_type: AccountHolderType::Business,
            is_primary: true,
        }],
        members: vec![MemberInfo {
            member_type: MemberType::Owner,
            first_name: "Jane".to_string(),
            last_name: "Smith".to_string(),
            title: Some("CEO".to_string()),
            ownership_percentage: 100,
            date_of_birth: DateYmd::new("19800115").unwrap(),
            ssn: "123456789".to_string(),
            email: "jane@acme.com".to_string(),
            phone: "3125559876".to_string(),
            address: Address {
                line1: "456 Oak Ave".to_string(),
                line2: None,
                city: "Chicago".to_string(),
                state: "IL".to_string(),
                zip: "60602".to_string(),
                country: "USA".to_string(),
            },
        }],
        terms_acceptance: TermsAcceptance {
            version: "4.21".to_string(),
            accepted_at: "2024-01-15 10:30:00".to_string(),
        },
    }).await?;

    println!("Merchant {} created with status {:?}",
             result.merchant_id, result.boarding_status);
    Ok(())
}

Dispute Handling (Type-State Pattern)

The dispute handling workflow uses Rust's type system to enforce valid state transitions at compile time:

use payrix::{PayrixClient, get_actionable_disputes, TypedChargeback, ChargebackState, Evidence};
use payrix::workflows::dispute_handling::{First, Representment};

async fn handle_disputes(client: &PayrixClient, merchant_id: &str) -> Result<(), payrix::Error> {
    // Get disputes that need action
    let disputes = get_actionable_disputes(client, merchant_id).await?;

    for dispute in disputes {
        match dispute {
            TypedChargeback::First(cb) => {
                // Compile-time enforcement: can only submit evidence for First chargebacks
                let evidence = Evidence::new("Proof of delivery")
                    .add_document_from_path("receipt.pdf")?;

                // This transitions the chargeback to Representment state
                let _representment: TypedChargeback<Representment> =
                    cb.submit_representment(client, evidence).await?;
            }
            TypedChargeback::Representment(cb) => {
                // Can only accept or escalate from Representment
                cb.accept(client).await?;
            }
            _ => {}
        }
    }
    Ok(())
}

Hold Handling (Type-State Pattern)

The hold handling workflow uses Rust's type system to enforce valid state transitions at compile time:

use payrix::PayrixClient;
use payrix::types::Hold;
use payrix::workflows::hold_handling::{
    TypedHold, OnHold, MerchantDecision, HoldWorkflowConfig,
    notify_merchant, receive_decision, submit_decision, check_resolution,
    detect_unreleased_holds, load_workflow, HoldPollingConfig, ResolutionStatus,
};

async fn handle_holds(client: &PayrixClient, config: &HoldWorkflowConfig) -> Result<(), payrix::Error> {
    // Detect unreleased holds from Payrix (e.g., at startup)
    let holds = detect_unreleased_holds(client, &HoldPollingConfig::default()).await?;

    for hold in holds {
        // Load hold into the workflow state machine
        let workflow = load_workflow(hold);

        if let payrix::workflows::hold_handling::HoldWorkflow::OnHold(on_hold) = workflow {
            // Compile-time enforcement: can only notify from OnHold state
            let awaiting = notify_merchant(
                on_hold, config, "merchant@example.com", "Acme Corp"
            ).await?;

            // Later, when merchant replies with their decision...
            let with_decision = receive_decision(
                awaiting,
                MerchantDecision::Valid,
                "Transaction verified by customer".to_string(),
                vec![],
            )?;

            // Submit decision to Payrix — transitions to Submitted state
            let submitted = submit_decision(with_decision, client, config).await?;

            // Check if Payrix has resolved the hold
            match check_resolution(submitted, client).await? {
                ResolutionStatus::Resolved(resolved) => {
                    println!("Hold {} resolved", resolved.id());
                }
                ResolutionStatus::Pending(still_submitted) => {
                    println!("Hold {} still pending", still_submitted.id());
                }
            }
        }
    }
    Ok(())
}

Webhook Setup

use payrix::{PayrixClient, setup_webhooks, WebhookConfig, WebhookEventType};

async fn configure_webhooks(client: &PayrixClient, entity_id: &str) -> Result<(), payrix::Error> {
    let config = WebhookConfig::new("https://example.com/webhooks")
        .with_events(vec![
            WebhookEventType::TransactionCreated,
            WebhookEventType::ChargebackCreated,
            WebhookEventType::ChargebackUpdated,
        ]);

    let result = setup_webhooks(client, entity_id, &config).await?;
    println!("Created {} webhook alerts", result.alerts_created.len());
    Ok(())
}

API Coverage

Category Resources
Core Merchants, Entities, Customers, Tokens, Transactions, TxnRefs
Banking Accounts, AccountVerifications, Funds, Disbursements
Billing Subscriptions, Plans, Fees, FeeRules
Operations Batches, Payouts, Reserves, Adjustments, PinlessDebitConversions
Disputes Chargebacks, ChargebackMessages, ChargebackDocuments
Admin Orgs, TeamLogins, Members, Contacts, Vendors

Environment Configuration

Payrix provides separate test (sandbox) and production environments. Each requires its own API key.

use payrix::{PayrixClient, Environment};

// Test environment (sandbox) - for development and integration testing
let test_client = PayrixClient::new("your-test-api-key", Environment::Test)?;

// Production environment - for live transactions
let prod_client = PayrixClient::new("your-prod-api-key", Environment::Production)?;
Environment Base URL ID Prefix
Test https://test-api.payrix.com/ t1_
Production https://api.payrix.com/ p1_

Selecting the Environment at Runtime

A common pattern is to select the environment based on configuration:

use payrix::{PayrixClient, Environment};
use std::env;

fn create_client() -> Result<PayrixClient, payrix::Error> {
    let (api_key, environment) = if env::var("PAYRIX_ENV").as_deref() == Ok("production") {
        (
            env::var("PAYRIX_PRODUCTION_API_KEY").expect("PAYRIX_PRODUCTION_API_KEY must be set"),
            Environment::Production,
        )
    } else {
        (
            env::var("PAYRIX_API_KEY").expect("PAYRIX_API_KEY must be set"),
            Environment::Test,
        )
    };

    PayrixClient::new(&api_key, environment)
}

Environment Differences

Some API endpoints behave differently between environments:

  • Payouts are not processed in the test environment (/payouts returns empty results)
  • Chargebacks cannot be moved through the full lifecycle in test mode
  • Merchant boarding works in test mode but underwriting approval requires Payrix support
  • Some newer endpoints (e.g., /txnRefs, /valueAddedServices/pinlessDebitConversions) may require elevated API key permissions in either environment

Error Handling

use payrix::{PayrixClient, Environment, EntityType, Customer, Error};

async fn handle_errors(client: &PayrixClient) {
    let result: Result<Option<Customer>, Error> = client.get_one(
        EntityType::Customers,
        "invalid_id"
    ).await;

    match result {
        Ok(Some(customer)) => println!("Found: {:?}", customer),
        Ok(None) => println!("Customer not found"),
        Err(Error::Unauthorized(_)) => println!("Invalid API key"),
        Err(Error::RateLimited(_)) => println!("Rate limited, retry later"),
        Err(Error::Api(errors)) => println!("API errors: {:?}", errors),
        Err(e) => println!("Other error: {}", e),
    }
}

Design Decisions

Rate Limiting Strategy

The client implements two-layer rate limiting:

  1. Proactive - Tracks outgoing requests with a sliding window to stay under API limits
  2. Reactive - Handles 429 responses with exponential backoff (10s sleep, up to 3 retries)

Why Not Tower Middleware?

This library uses method-specific helpers instead of Tower middleware:

  • Simplicity - Tower's trait bounds add complexity without proportional benefit
  • Payrix quirks - Payrix returns HTTP 200 with errors in the JSON body
  • Debuggability - Simple loops are easier to trace than middleware stacks
  • Code size - ~50 lines for retry+rate limiting vs 100+ for Tower setup

Reality Wins

When the OpenAPI spec differs from actual API behavior, we follow reality:

  • Flexible deserializers - Transaction enums accept both integer and string formats
  • Undocumented variants - Added enum values observed in production
  • Optional fields - Made fields like Customer.merchant optional when API returns null
  • Integer vs string enums - FeeType, FeeUnit, FeeCollection use integers

See API_INCONSISTENCIES.md for the full catalog of discrepancies.

Testing

Category Count Status
Unit tests 685 All passing
Doc tests 53 All passing
Integration tests 380+ Ignored (require API key)

Running Tests

# Unit and doc tests (no API key required)
cargo test -p payrix

# Integration tests against test (sandbox) environment
TEST_PAYRIX_API_KEY=your_test_key cargo test -p payrix -- --ignored --nocapture

# Integration tests against production (GET-only, read-safe)
PAYRIX_PRODUCTION_API_KEY=your_prod_key cargo test -p payrix -- --ignored --nocapture

# Specific test suite
TEST_PAYRIX_API_KEY=your_test_key cargo test -p payrix --test search_operators -- --ignored --nocapture

Test Environment Variables

Variable Required Description
TEST_PAYRIX_API_KEY For sandbox tests API key for the test/sandbox environment
PAYRIX_PRODUCTION_API_KEY For production tests API key for the production environment (GET-only tests)
TEST_MERCHANT_ID No Merchant ID for resource creation tests (default provided)
TEST_ENTITY_ID No Entity ID for entity tests (default provided)

When both PAYRIX_PRODUCTION_API_KEY and TEST_PAYRIX_API_KEY are set, tests that support both environments will prefer the production key. This is useful for verifying deserialization against real production data.

If your production key is stored under a different variable name, alias it when running tests:

# Alias an existing env var
PAYRIX_PRODUCTION_API_KEY=$MY_PAYRIX_KEY cargo test -p payrix -- --ignored --nocapture

API Reference

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Disclaimer

This is an unofficial client library. Payrix and Worldpay are trademarks of their respective owners.

Dependencies

~9–31MB
~354K SLoC