EXPERIMENTAL
A lightweight Go mock of the GateHub API for local development and testing of wallet applications that integrate with GateHub.
Official GateHub documentation can be found here.
MockGatehub provides a drop-in replacement for GateHub's sandbox environment, enabling developers to:
- Develop and test wallet integrations without real GateHub credentials
- Run locally without external API dependencies
- Test multi-currency operations (11 supported currencies)
- Verify KYC flows with a realistic iframe and server-side approval
- Test webhook delivery mechanisms
- UNSTABLE - Test card issuance, transactions, and 3DS challenge flows
- UNSTABLE - Configure transaction fees for deposit/withdrawal testing
- Full API Coverage: Authentication, KYC, wallets, transactions, rates, fees, and cards
- Multi-Currency Support: XRP, USD, EUR, GBP, ZAR, MXN, SGD, CAD, EGG, PEB, PKR
- Realistic KYC Flow: Iframe-based form with
action_required→acceptedlifecycle - Card Services: Full card lifecycle — issuance, lock/unlock/block, limits, transactions, 3DS challenges
- Encrypted Card Data: Card details and PIN returned RSA-encrypted to the caller's own key, via short-lived signed tokens
- Card Transaction Catalogue: 17 realistic GateHub transaction payloads — purchases, FX, ATM, cash advance, pre-authorizations, reversals and declines — simulatable against a real card
- Statements: Account confirmation, monthly statement and transfer confirmation, rendered as PDFs describing the account they belong to
- Admin UI: A browser UI at
/uifor driving KYC events, card transactions and withdrawal settlement without writing requests by hand - Webhook Delivery: Redis-backed job queue with configurable delay, retry, and HMAC signing
- Configurable Fees: Runtime-adjustable deposit and withdrawal fee percentages via admin API
- Dual Storage: In-memory (development) and Redis (runtime) backends
- HMAC Authentication: Enforced by default, matching real GateHub signature validation
- Pre-seeded Users: Test users with balances ready to use
- BDD Test Suite: 144 Gherkin scenarios run with godog against real containers, including a consumer-contract suite that pins every endpoint downstream integrations call
docker compose up -d mockgatehubThe service will be available at http://localhost:8080
| Variable | Default | Description |
|---|---|---|
MOCKGATEHUB_PORT |
8080 |
Application API port — the GateHub API and the iframes |
MOCKGATEHUB_ADMIN_PORT |
8081 |
Admin UI and test-support port. Must differ from MOCKGATEHUB_PORT; the process refuses to start otherwise |
LOG_LEVEL |
info |
Log level (debug, info, warn, error) |
MOCKGATEHUB_REDIS_URL |
— | Redis connection URL (enables Redis storage) |
MOCKGATEHUB_REDIS_DB |
0 |
Redis database number |
MOCKGATEHUB_ENFORCE_AUTHENTICATION |
true |
Enable HMAC signature validation |
MOCKGATEHUB_VALID_CREDENTIALS |
local-test-app-id:local-test-app-secret |
Comma-separated appId:secret pairs |
WEBHOOK_URL |
— | Application webhook endpoint URL (fallback if no org config) |
WEBHOOK_SECRET |
mock-secret |
Secret for signing outgoing webhooks |
WEBHOOK_MIN_DELAY_SEC |
0.05 |
Minimum seconds before webhooks become eligible for delivery |
DEFAULT_ORGANIZATION_ID |
default-org |
Organization ID for callback routing |
MOCKGATEHUB_PUBLIC_BASE_URL |
http://localhost:8080 |
Externally reachable base URL, used for absolute links a browser follows directly (card-data and PIN) |
MOCKGATEHUB_CARD_DATA_TOKEN_SECRET |
random per process | HMAC secret signing card-data and PIN tokens. Set it only if tokens must survive a restart |
MOCKGATEHUB_ASYNC_WITHDRAWALS |
false |
When enabled, withdrawals stay pending until settled through the admin API instead of completing immediately. See Withdrawal Settlement |
Note: Redis is optional. Without it, storage is in-memory and webhook delivery is disabled — enough for exploring the API, but webhook-driven flows need
MOCKGATEHUB_REDIS_URLset.
Two test users are automatically created at startup:
| User 1 | User 2 | |
|---|---|---|
testuser1@mockgatehub.local |
testuser2@mockgatehub.local |
|
| User ID | 00000000-0000-0000-0000-000000000001 |
00000000-0000-0000-0000-000000000002 |
| Balance | 10,000 USD | 10,000 EUR |
| KYC State | action_required |
action_required |
MockGatehub serves two separate ports:
| Port | Serves | Authentication |
|---|---|---|
MOCKGATEHUB_PORT (8080) |
The GateHub API, the iframes, and the browser-facing card-data and PIN endpoints | HMAC, except the iframe and token-data paths listed under Public Endpoints |
MOCKGATEHUB_ADMIN_PORT (8081) |
The admin UI and the test-support endpoints | None |
The split exists so the admin surface can be closed off at the network level.
It carries no authentication of its own and several of its endpoints mutate
state — settling a withdrawal, setting a KYC state, simulating a card
transaction — so it is guarded by not being reachable rather than by being
authenticated. Publish only 8080 and the admin surface is unreachable from
outside; the separation is structural, so it holds even with
MOCKGATEHUB_ENFORCE_AUTHENTICATION=false.
The process refuses to start if the two ports are equal, so a misconfiguration cannot silently merge the surfaces back onto one listener.
/health is served on both, so each listener can be probed independently.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Service health status |
GET |
/api/user-currencies |
Currencies with non-zero balances for a user |
| Method | Path | Description |
|---|---|---|
POST |
/tokens |
Generate iframe access token |
POST |
/users/managed |
Create managed user |
GET |
/users/managed |
Get managed user by email |
PUT |
/users/managed/email |
Update user email |
PATCH |
/users/organization/{orgID} |
Update organization configuration |
| Method | Path | Description |
|---|---|---|
GET |
/users/{userID} |
Get user state and profile |
POST |
/users/{userID}/hubs/{gatewayID} |
Start KYC process |
PUT |
/hubs/{gatewayID}/users/{userID} |
Update KYC state |
POST |
/hubs/{gatewayID}/users/{userID}/overrideRiskLevel |
Override risk level |
| Method | Path | Description |
|---|---|---|
GET |
/iframe/onboarding |
Serve KYC iframe HTML |
POST |
/iframe/submit |
KYC iframe form submission |
GET |
/ |
Serve deposit/withdrawal iframe |
POST |
/transaction/complete |
Iframe transaction completion callback |
| Method | Path | Description |
|---|---|---|
GET |
/users/{userID} |
Get user wallets (auto-creates if none) |
POST |
/users/{userID}/wallets |
Create new wallet |
GET |
/users/{userID}/wallets/{walletID} |
Get wallet details |
GET |
/wallets/{walletID}/balances |
Get multi-currency balance |
POST |
/transactions |
Create deposit/hosted/withdrawal transaction |
GET |
/transactions/{txID} |
Get transaction details |
| Method | Path | Description |
|---|---|---|
GET |
/rates/current |
Get current exchange rates |
GET |
/liquidity_provider/vaults |
Get vault UUIDs |
| Method | Path | Description |
|---|---|---|
GET |
/admin/fees |
Get current fee percentages |
PUT |
/admin/fees |
Set deposit/withdrawal fee percentages (0–100) |
| Method | Path | Description |
|---|---|---|
POST |
/customers |
Create customer |
POST |
/customers/managed |
Create managed customer with account and card |
POST |
/customers/{customerID}/addresses |
Add delivery address |
GET |
/customers/{customerID}/addresses |
List delivery addresses |
POST |
/cards |
Create card |
GET |
/cards/{customerID} |
List cards for customer |
GET |
/cards/{cardID}/card |
Get card details |
DELETE |
/cards/{cardID}/card |
Delete (soft-delete) card |
PUT |
/cards/{cardID}/lock |
Lock card temporarily |
PUT |
/cards/{cardID}/unlock |
Unlock card |
PUT |
/cards/{cardID}/block |
Block card permanently |
GET |
/cards/{cardID}/limits |
Get card spending limits |
PUT |
/cards/{cardID}/limits |
Update card spending limits |
POST |
/token/card-data |
Generate card data token |
POST |
/accounts/{accountID}/cards |
Order additional card |
POST |
/cards/{cardID}/plastic |
Order physical plastic card |
POST |
/transactions |
Create card transaction |
GET |
/transactions/{txID} |
Get card transaction |
GET |
/cards/{cardID}/transactions |
List card transactions |
GET |
/transaction/pending-confirmations |
List pending 3DS challenges |
POST |
/test/3ds/challenge |
Create 3DS challenge (testing) |
POST |
/transaction/{txID} |
Confirm/decline 3DS challenge |
GET |
/card-applications/{appID}/card-products |
Get card product catalog |
POST |
/token/{tokenType} |
Mint a short-lived token (card-data, pin, pin-change) |
GET |
/token/card-data/data |
Card PAN, expiry and CVC, RSA-encrypted to the caller's key |
GET |
/token/pin/data |
Card PIN, RSA-encrypted to the caller's key |
POST |
/token/pin/data |
Set a new PIN, supplied encrypted to this service's key |
GET |
/token/pin/public-key |
The key a caller encrypts a new PIN to |
Card limits, the product catalog and a customer's card list are also served
under a bare /v1 prefix (/v1/cards/{cardID}/limits,
/v1/card-applications/{appID}/card-products,
/cards/v1/customers/{customerID}/cards), matching how GateHub exposes them.
Each returns a PDF describing the account or transaction it belongs to, rather than one indistinguishable placeholder.
| Method | Endpoint | Description |
|---|---|---|
GET |
/statements/account-confirmation/{walletAddress} |
Confirmation that an account exists |
GET |
/statements/account-statement/{walletAddress}/{year}/{month} |
One month's activity |
GET |
/statements/transfer-confirmation/{transactionUUID} |
Confirmation of one deposit or withdrawal |
Not part of the GateHub API, and served on the admin port (8081 by
default), not alongside the application API. These exist so an automated test
can arrange state and observe what was emitted.
| Method | Endpoint | Description |
|---|---|---|
PUT |
/admin/users/{userID}/kyc-state |
Set a KYC state without emitting a webhook |
GET |
/admin/card-transactions/scenarios |
List the card transaction catalogue |
POST |
/admin/card-transactions/simulate |
Materialise a scenario against a card and emit its webhook |
POST |
/admin/card-transactions/{txID}/status |
Advance a card transaction's status |
GET |
/admin/users/{userID}/withdrawals |
List withdrawals, optionally by status |
POST |
/admin/withdrawals/{txID}/trigger-event |
Settle a pending withdrawal |
POST |
/test-webhook |
Webhook sink; records deliveries and verifies their signature |
GET |
/admin/received-webhooks |
Inspect recorded deliveries |
DELETE |
/admin/received-webhooks |
Clear recorded deliveries |
A browser UI for driving the mock by hand. Served on the admin port, so by default http://localhost:8081/ui. Needs no credentials.
| Path | Description |
|---|---|
/ui |
Users and their balances |
/ui/users/{userID} |
Balances, cards and transactions for one user, with withdrawal settlement |
/ui/actions/kyc |
Send a verification event or document notice |
/ui/actions/card-transaction |
Simulate a card transaction from the catalogue |
| Currency | Code | Vault UUID |
|---|---|---|
| US Dollar | USD | 450d2156-132a-4d3f-88c5-74822547658d |
| Euro | EUR | a09a0a2c-1a3a-44c5-a1b9-603a6eea9341 |
| British Pound | GBP | 992b932d-7e9e-44b0-90ea-b82a530b6784 |
| South African Rand | ZAR | f1c412ce-5e2b-4737-9121-b7c11d6c3f93 |
| Mexican Peso | MXN | 426c2e30-111e-4273-92b3-508445a6bb58 |
| Singapore Dollar | SGD | e2914c33-2e57-49a5-ac06-25c006497b3d |
| Canadian Dollar | CAD | bd5af6fe-5d92-4b20-9bd4-1baa52b7a02e |
| EGG (Test) | EGG | 9a550347-799e-4c10-9142-f1a2e1c084e7 |
| PEB (Test) | PEB | 0ba2b0d1-b7a2-416c-a4ac-1cb3e5281300 |
| Pakistani Rupee | PKR | 2868b4e5-7178-4945-8ec5-8208fac2a22d |
| XRP | XRP | 6e1f2a3b-4c5d-6e7f-8a9b-0c1d2e3f4a5b |
MockGatehub delivers webhooks via a Redis Streams consumer group with configurable minimum delay, 10 retry attempts, and a 3-second retry backoff. Webhooks are signed with X-GH-Webhook-Signature using HMAC-SHA256.
The worker blocks on the Redis stream rather than polling, so a webhook is picked up as soon as it is enqueued.
| Event | Trigger |
|---|---|
id.verification.accepted |
KYC approved (iframe submit) |
id.verification.rejected |
KYC rejected |
id.verification.action_required |
KYC requires action |
id.verification.resubmission |
KYC documents must be resubmitted |
id.document_notice.warning |
An identity document is expiring |
id.document_notice.expired |
An identity document has expired |
core.deposit.completed |
Deposit/hosted transfer completed |
core.withdrawal.completed |
Withdrawal settled (asynchronous withdrawals only) |
more-bridge.withdrawal.rejected |
Withdrawal rejected (asynchronous withdrawals only) |
cards.card.created |
Card created; reports the assigned customer, account and card ids |
cards.transaction.authorization |
Card transaction, carrying the transaction under authorizationData |
cards.transaction.event |
Card transaction notification, carrying identifiers only |
cards.3ds.auth_3ds_confirmation |
3DS challenge confirmation |
Consumers that mirror card spend into their own ledger want
cards.transaction.authorization: it carries the transaction itself.cards.transaction.eventcarries only identifiers.
The three verification outcomes carry a synthesised verified summary. A
resubmission request and the document notices deliberately do not — they are not
verdicts, and summarising them as such would report a verification that has not
concluded.
{
"uuid": "webhook-uuid",
"timestamp": "1768920404045",
"event_type": "core.deposit.completed",
"user_uuid": "user-id",
"environment": "sandbox",
"data": { ... }
}The
timestampis milliseconds since epoch as a string. Theenvironmentis always"sandbox".
- Start KYC (
POST /id/v1/users/{userID}/hubs/{gatewayID}) — sets user toaction_required - Load iframe (
GET /iframe/onboarding?bearer={token}) — serves KYC form fromweb/kyc-iframe.html - Submit form (
POST /iframe/submit) — acceptsmultipart/form-dataor URL-encoded, updates user toaccepted, triggersid.verification.acceptedwebhook - Parent notification — iframe posts
{ type: 'OnboardingCompleted', value: '{"applicantStatus":"submitted"}' }to the parent window
The bearer token is required. The user_id form field is optional — if omitted, it is resolved from the token-to-user mapping created via /auth/v1/tokens.
The submit accepts an optional kyc_outcome field so a consumer can be
exercised against something other than the happy path:
kyc_outcome |
Resulting state | Webhook |
|---|---|---|
omitted or accepted |
accepted |
id.verification.accepted |
rejected |
rejected |
id.verification.rejected |
action_required |
action_required |
id.verification.action_required |
resubmission |
resubmission |
id.verification.resubmission |
An unrecognised value is refused with 400 rather than quietly accepted, so a
typo cannot read as a passing verification.
Re-submitting for a user already in resubmission moves them to accepted
without emitting the accepted webhook, since the consumer drives that transition
itself.
To arrange a starting state without emitting anything, use
PUT /admin/users/{userID}/kyc-state.
The KYC iframe includes an optional "Trigger 2FA TOTP verification" checkbox. When checked:
- User enters a TOTP code in the revealed input field
- On form submit, MockGateHub calls the integrator's 2FA endpoint before completing KYC:
POST {org.apiBaseUrl}/v1/users/managed/{userId}/2fa {"action": "VERIFY", "code": "{entered_code}"} - If integrator returns
{"success": true}→ KYC proceeds normally - If integrator returns
{"success": false}or non-2xx → form submission fails with error - If no organization
apiBaseUrlis configured → form submission fails with clear error
This requires the organization configuration to be set via PATCH /auth/v1/users/organization/{orgID} with a valid apiBaseUrl.
POST /core/v1/transactionscreates a transaction inpendingstate (status1)- A pending webhook (
core.deposit.completed) fires immediately - After a ~2-second delay, the transaction is marked
completed(status100), the balance is credited, and a completed webhook fires - If no webhook URL is configured, the transaction completes synchronously
Transaction types: 0 = Withdrawal, 1 = Deposit (external), 2 = Hosted transfer
Fee behavior: Deposit fees reduce the credited amount. Withdrawal fees increase the total deducted. Hosted transfers always have 0% fee.
Hosted transfer direction: a hosted transfer can move money either way. If
sending_address resolves to a wallet the user owns, the transfer debits them;
otherwise it credits them.
By default a withdrawal completes as soon as it is requested: the balance is charged and the outcome is reported in the response. No webhook is emitted.
Setting MOCKGATEHUB_ASYNC_WITHDRAWALS=true switches to the asynchronous model
a real provider uses:
- The withdrawal is created
pendingand nothing is charged. The balance is still checked up front, so an unaffordable withdrawal is refused immediately. GET /admin/users/{userID}/withdrawals?status=pendingfinds it.POST /admin/withdrawals/{txID}/trigger-eventsettles it with eithercore.withdrawal.completedormore-bridge.withdrawal.rejected.- Completion charges the balance and emits the webhook; rejection leaves the balance untouched. The balance moves at settlement, not at request time, so a rejected withdrawal never shows a balance that dropped and came back.
- Settling is once-only. A second attempt, or completing something already
rejected, returns
409.
It is opt-in because a consumer that handles no withdrawal webhooks would otherwise see its withdrawals never complete.
All API requests require HMAC-SHA256 signatures by default, matching real GateHub behavior.
- App ID:
local-test-app-id - Secret:
local-test-app-secret
Include these headers on every request:
x-gatehub-app-id— application identifierx-gatehub-timestamp— Unix timestamp (seconds)x-gatehub-signature—HMAC-SHA256(timestamp|method|full_url|body, secret), hex-encoded
TIMESTAMP=$(date +%s)
BODY='{"email":"user@example.com"}'
SIGNATURE=$(echo -n "${TIMESTAMP}POST/auth/v1/tokens${BODY}" | openssl dgst -sha256 -hmac "local-test-app-secret" -hex | cut -d' ' -f2)
curl -X POST http://localhost:8080/auth/v1/tokens \
-H "x-gatehub-app-id: local-test-app-id" \
-H "x-gatehub-timestamp: $TIMESTAMP" \
-H "x-gatehub-signature: $SIGNATURE" \
-H "Content-Type: application/json" \
-d "$BODY"On the application port: /health, /, /iframe/onboarding, /iframe/submit, /transaction/complete, /api/user-currencies.
The whole admin port is unauthenticated by design — see Two listeners.
The card-data and PIN data endpoints (/cards/v1/token/card-data/data,
/cards/v1/token/pin/data, /cards/v1/token/pin/public-key) are also exempt
from HMAC: a browser reaches them directly holding only a short-lived token,
which is how real GateHub authenticates them.
MOCKGATEHUB_ENFORCE_AUTHENTICATION=falseWarning: Only use this in development. Always enable authentication for realistic testing.
- Go 1.24+
- Docker & Docker Compose
- Redis (required for webhook queue, optional for app storage)
go build -o mockgatehub ./cmd/mockgatehub# No dependencies. Storage is in-memory and webhook delivery is disabled.
./mockgatehub
# With Redis, so webhooks are delivered
MOCKGATEHUB_REDIS_URL=redis://localhost:6379 ./mockgatehub
# Disable auth for quick iteration
MOCKGATEHUB_ENFORCE_AUTHENTICATION=false ./mockgatehubThen open http://localhost:8081/ui — the admin UI is on the admin port.
# All tests (unit + E2E)
make test
# Unit tests only
make unit-tests
# BDD E2E tests (requires Docker)
make e2e-tests
# Coverage report
make coveragecmd/mockgatehub/ # Application entry point and route setup
internal/
├── auth/ # HMAC signature validation + HTTP middleware
├── config/ # Environment variable configuration
├── consts/ # Constants (currencies, vault IDs, rates, statuses)
├── handler/ # HTTP handlers (auth, identity, core, cards, fees, rates)
├── logger/ # Zap structured logging
├── models/ # Domain models and API DTOs
├── storage/ # Storage layer (interface + memory/Redis implementations)
├── utils/ # Utilities (UUID, address generation)
└── webhook/ # Redis-backed webhook queue, worker, and delivery
features/ # Gherkin BDD feature files
test/integration/ # Go integration tests
testenv/ # Godog E2E test runner with Docker Compose
web/ # Static HTML (KYC iframe, deposit/withdraw iframe)
docs/ # GateHub API reference documentation
- Sandbox Only: Designed for development, not production use
- Redis for webhooks: Webhook delivery needs Redis. Without it everything else still works, but no webhooks are sent.
- PIN change is unverified:
POST /cards/v1/token/pin/datais implemented symmetrically with the read path, but no known consumer exercises it yet, so the contract is inferred rather than confirmed. - Withdrawal rejection payload: the shape of
more-bridge.withdrawal.rejectedhas not been confirmed against real GateHub. - Endpoints not yet served: some calls real consumers make are still
unrouted —
GET /auth/v1/users/organization/{orgID}(onlyPATCHexists),PUT /auth/v1/users/managed,PUT /core/v1/transactions/{uuid}/serviceStatus,POST /core/v1/gateways/{uuid}/transactions, andPOST /core/v1/users/{orgUuid}/accounts.
docker compose logs mockgatehubredis-cli -n 1 KEYS "balance:*"curl http://localhost:8080/healthdocker compose logs <your-app-service> | grep webhookSee AGENTS.md for AI agent development guidelines.
Maintained by the Interledger Foundation.