Forhopp

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_method and payment_method_details (if payment processed)
  • fee and net amounts (if captured)
  • refunds array (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:

  1. PENDING charges older than 24 hours → EXPIRED
  2. AUTHORIZED charges 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

Was this page helpful?

Edit this page