ForhoppPay Connect — Charges API
Feature Reference: FR-5, FR-6, FR-7, FR-8 | Service:
MerchantChargeService| Controller:ConnectChargeController
1. Overview
The Charges API is the core of ForhoppPay Connect. It enables merchants to create payment requests, capture authorized payments, and issue refunds. Every charge goes through a well-defined state machine with automatic expiration and fee calculation.
2. Charge ID Format
ch_[a-zA-Z0-9]{32}
Example: ch_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Generated using SecureRandom. Uniqueness enforced at database level. Generation retries up to 10 times on collision.
3. Endpoints
| Method | Path | Description | Auth |
|---|---|---|---|
POST |
/api/v1/connect/charges |
Create a charge | API Key |
GET |
/api/v1/connect/charges/{id} |
Retrieve a charge | API Key |
GET |
/api/v1/connect/charges |
List charges | API Key |
POST |
/api/v1/connect/charges/{id}/capture |
Capture authorized charge | API Key |
POST |
/api/v1/connect/charges/{id}/refunds |
Refund a charge | API Key |
4. Create Charge
4.1 Request
POST /api/v1/connect/charges
Authorization: Bearer sk_live_xxx
Content-Type: application/json
Idempotency-Key: order_12345_v1
{
"amount": 5000,
"currency": "usd",
"description": "Order #12345",
"metadata": {
"order_id": "12345",
"customer_email": "customer@example.com"
},
"returnUrl": "https://merchant.com/success",
"cancelUrl": "https://merchant.com/cancel"
}
4.2 Request Parameters
| Parameter | Type | Required | Constraints |
|---|---|---|---|
amount |
integer | Yes | Min: 50, Max: 99999999 (cents) |
currency |
string | Yes | One of: usd, eur, gbp, cad, aud, jpy, chf |
description |
string | No | Max 500 characters |
metadata |
object | No | Arbitrary key-value pairs |
returnUrl |
string | Yes | Valid URL, redirect after success |
cancelUrl |
string | No | Valid URL, redirect after cancel |
4.3 Response 201 Created
{
"id": "ch_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"object": "charge",
"amount": 5000,
"currency": "usd",
"status": "pending",
"description": "Order #12345",
"metadata": { "order_id": "12345" },
"checkout_url": "https://pay.forhopp.com/checkout/ch_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"return_url": "https://merchant.com/success",
"cancel_url": "https://merchant.com/cancel",
"expires_at": 1711900800,
"created": 1711814400,
"livemode": true
}
4.4 Processing Logic
1. Validate currency ∈ {usd, eur, gbp, cad, aud, jpy, chf}
2. If Idempotency-Key present:
→ Query existing charge by (merchant_account_id, idempotency_key)
→ If found: return existing charge (no new creation)
3. Generate unique charge ID (ch_xxx)
4. Set expires_at = now + 24 hours
5. Generate checkout_url = {base_url}/checkout/{charge_id}
6. Set environment from API key (LIVE or TEST)
7. Persist charge with status PENDING
8. Return response
5. Retrieve Charge
GET /api/v1/connect/charges/ch_xxx
Authorization: Bearer sk_live_xxx
Access control: returns 404 if charge doesn't exist OR belongs to a different merchant. This prevents information leakage.
Response includes:
- Full charge object with all fields
payment_methodandpayment_method_details(if payment processed)feeandnetamounts (if captured)refundsarray (if any refunds exist)
6. List Charges
GET /api/v1/connect/charges?status=captured&limit=25&created_after=1711814400
Authorization: Bearer sk_live_xxx
| Parameter | Type | Default | Description |
|---|---|---|---|
status |
string | — | Filter by status |
limit |
integer | 10 | Results per page (1–100) |
created_after |
long | — | Unix timestamp filter |
created_before |
long | — | Unix timestamp filter |
starting_after |
string | — | Cursor for pagination |
Response:
{
"object": "list",
"data": [...],
"has_more": true,
"url": "/api/v1/connect/charges",
"total_count": 150
}
Results are ordered by created_at descending (newest first).
7. Capture Charge
7.1 Full Capture
POST /api/v1/connect/charges/ch_xxx/capture
Authorization: Bearer sk_live_xxx
7.2 Partial Capture
POST /api/v1/connect/charges/ch_xxx/capture
Authorization: Bearer sk_live_xxx
Content-Type: application/json
{
"amount": 3000
}
7.3 Capture Rules
| Rule | Constraint |
|---|---|
| Status requirement | Must be AUTHORIZED |
| Capture amount | > 0 and ≤ authorized amount |
| Default amount | Full authorized amount if not specified |
| Auto-void | Uncaptured charges void after 7 days |
7.4 Fee Calculation at Capture
fee = round(amount × 0.029 + 30)
Example: $50.00 (5000 cents)
Percentage: 5000 × 0.029 = 145
Fixed: 30
Total fee: 175 cents ($1.75)
Net: 4825 cents ($48.25)
After capture, the charge includes fee_amount_cents, net_amount_cents, and captured_at.
8. Refund Charge
8.1 Full Refund
POST /api/v1/connect/charges/ch_xxx/refunds
Authorization: Bearer sk_live_xxx
8.2 Partial Refund
POST /api/v1/connect/charges/ch_xxx/refunds
Authorization: Bearer sk_live_xxx
Content-Type: application/json
{
"amount": 2500,
"reason": "customer_request"
}
8.3 Refund Rules
| Rule | Constraint |
|---|---|
| Refundable statuses | CAPTURED or PARTIALLY_REFUNDED |
| Amount | > 0 and ≤ remaining (captured − already refunded) |
| Default amount | Full remaining if not specified |
| Multiple refunds | Allowed until fully refunded |
| Refund ID format | re_[a-zA-Z0-9]{32} |
8.4 Status Transitions on Refund
CAPTURED + partial refund → PARTIALLY_REFUNDED
CAPTURED + full refund → REFUNDED
PARTIALLY_REFUNDED + remaining refund → REFUNDED
PARTIALLY_REFUNDED + partial refund → PARTIALLY_REFUNDED
9. Charge Lifecycle State Machine
┌──────────┐
┌────────►│ EXPIRED │ (24h timeout)
│ └──────────┘
│
┌────┴────┐
│ PENDING │
└────┬────┘
│ Customer pays
▼
┌──────────┐ ┌────────┐
│AUTHORIZED├───── Void ────────►│ VOIDED │
└────┬─────┘ └────────┘
│ ▲
│ Capture │ 7-day auto-void
▼ │
┌──────────┐ │
│ CAPTURED │─────────────────────────┘
└────┬─────┘
│
├── Partial refund ──► PARTIALLY_REFUNDED ──► REFUNDED
│
└── Full refund ─────► REFUNDED
PENDING ──► FAILED (payment failed)
CAPTURED ──► DISPUTED (customer disputes)
Status Definitions
| Status | Description | Terminal |
|---|---|---|
pending |
Created, awaiting customer payment | No |
authorized |
Payment authorized, ready for capture | No |
captured |
Payment captured, funds transferred | No |
failed |
Payment failed | Yes |
voided |
Cancelled before capture | Yes |
refunded |
Fully refunded | Yes |
partially_refunded |
Partially refunded | No |
expired |
Checkout session expired (24h) | Yes |
disputed |
Customer disputed the charge | No |
10. Idempotency
The Idempotency-Key header prevents duplicate charges:
- Scoped per merchant:
UNIQUE(merchant_account_id, idempotency_key) - If a charge with the same key exists, the original charge is returned
- Keys are strings up to 100 characters
- Recommended format:
{order_id}_{attempt}(e.g.,order_12345_v1)
11. Charge Expiration
A scheduled task (ChargeExpirationScheduler) runs every 5 minutes:
PENDINGcharges older than 24 hours →EXPIREDAUTHORIZEDcharges older than 7 days →VOIDED
12. Database Schema
CREATE TABLE forhopppay.merchant_charges (
id BIGSERIAL PRIMARY KEY,
charge_id VARCHAR(50) NOT NULL UNIQUE,
merchant_account_id BIGINT NOT NULL REFERENCES forhopppay.forhopppay_accounts(id),
api_key_id BIGINT REFERENCES forhopppay.merchant_api_keys(id),
amount_cents BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
description VARCHAR(500),
metadata JSONB,
status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
payment_method VARCHAR(30),
payment_method_details JSONB,
return_url VARCHAR(500),
cancel_url VARCHAR(500),
checkout_url VARCHAR(500),
idempotency_key VARCHAR(100),
environment VARCHAR(10) NOT NULL DEFAULT 'LIVE',
fee_amount_cents BIGINT DEFAULT 0,
net_amount_cents BIGINT,
captured_at TIMESTAMP,
refunded_at TIMESTAMP,
expires_at TIMESTAMP,
disputed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
CONSTRAINT uk_merchant_charges_idempotency UNIQUE (merchant_account_id, idempotency_key)
);
13. Correctness Properties
| # | Property | Validates |
|---|---|---|
| 8 | Response includes id, status, amount, currency, created; captured charges include payment_method, fee, net | FR-5.2, FR-6.1 |
| 9 | Duplicate idempotency key returns existing charge without creating new one | FR-5.3 |
| 10 | PENDING charges expire after 24 hours | FR-5.4 |
| 11 | Only supported currencies accepted; 400 for others | FR-5.5, FR-28.1 |
| 12 | 404 for non-existent or other merchant's charges | FR-6.2 |
| 13 | Capture only allowed on AUTHORIZED charges | FR-7.1 |
| 14 | Partial capture amount > 0 and ≤ authorized amount | FR-7.2 |
| 15 | AUTHORIZED charges auto-void after 7 days | FR-7.3 |
| 16 | Refund amount > 0 and sum of refunds ≤ captured amount | FR-8.1, FR-8.2, FR-8.3 |
Last Updated: March 2026
