1 unstable release
Uses new Rust 2024
| 0.3.0 | Feb 9, 2026 |
|---|
#66 in Finance
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.
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, andeqare documented in Payrix but do NOT work (they return empty results). Always usegreaterandlessinstead.
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
}
Expanding Related Resources
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 (
/payoutsreturns 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:
- Proactive - Tracks outgoing requests with a sliding window to stay under API limits
- 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.merchantoptional when API returns null - Integer vs string enums -
FeeType,FeeUnit,FeeCollectionuse 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