Idempotent payments API with Outbox pattern, worker dispatch, and a PSP stub. Built to demo reliability patterns for high-volume payments.
| Project | Description |
|---|---|
| Gateway.Api | Minimal API (+ Swagger) |
| Gateway.Domain | Entities/DTOs |
| Gateway.Data | EF Core DbContext (+ Outbox/Idempotency) |
| Gateway.Worker | Background dispatcher |
| PspStub.Api | Fake Payment Service Provider for auth/testing |
dotnet build
- Run
Gateway.Apiand browse to http://localhost:5023 to use the built-in PrivateCircle-branded page. It can derive idempotency keys for fiat and crypto payloads, submit payments, and show canonical payloads used by the middleware.
Use the built-in UI at http://localhost:5023 to show the background worker updating payment records after the simulated PSP/chain responds.
- Start PspStub.Api, Gateway.Api, and Gateway.Worker (see steps below). Keep the demo UI open in a browser.
- In the Card / Fiat card, click Derive key to populate the derived key and canonical payload, then click Submit payment. The UI displays the initial
Pendingresponse and fills the Payment ID field. - Click Fetch /payments/{paymentId} to query the API from inside the demo. As the worker authorizes the payment, repeat the fetch to watch the status move to
Authorizedwith anauthCodefrom the PSP stub. - Repeat the flow in the Crypto card. After Submit crypto, use Fetch /payments/{paymentId} and Fetch /payments/{paymentId}/crypto to watch the worker mark the transaction with confirmations and authorize the payment using the simulated
txHash. - Call either submit button with the same derived key to highlight that the stored response is replayed (idempotent behavior still works after the worker updates records).
| Service | URL | Notes |
|---|---|---|
| Postgres | port 5432 Powershell Test: Test-NetConnection -Port 5432 -ComputerName localhost |
Database |
| Gateway.Api | http://localhost:5023/health http://localhost:5023/swagger |
Payments API |
| PspStub.Api | http://localhost:5279 | fake Payment Service Provider used for testing |
| Gateway.Worker | N/A | Background processor. Reads pending outbox messages and calls the PSP to authorize/decline payments. |
| Gateway.Domain | N/A | Library: Domain model layer. Contains business entities (Payment, etc.) and domain logic shared across services. |
| Gateway.Data | N/A | Library: Data access layer. Contains EF Core GatewayDbContext, entity mapping, and database migration logic. |
These are mostly PowerShell commands.
This will build and launch the service in one command. Postgres will download and launch as part of this too.
docker compose up --buildUse Ctrl-C to stop it.
- Create and Run Postgres in Docker:
docker run --name payments-pg `
-e POSTGRES_PASSWORD=postgres `
-e POSTGRES_DB=gateway `
-p 5432:5432 `
-d postgres:16- Verify with:
docker ps- If payments-pg already exists then just start it with:
docker start payments-pgYou should see payments-pg running on port 5432.
- Create the database schema:
dotnet ef database update `
--project .\src\Gateway.Data\Gateway.Data.csproj `
--startup-project .\src\Gateway.Api\Gateway.Api.csprojVerify the tables were created:
docker exec -it payments-pg psql -U postgres -d gateway -c "\dt"You should see the three tables and a history table.
- Open four terminals. Set the current directory to the solution root in each.
- In terminal 1, start the PSP stub:
dotnet run --project .\src\PspStub.Api\PspStub.Api.csproj- Browse to http://localhost:5279/ to see the PSP is working.
- In terminal 2, start Gateway.Api (payments API) default port is 5023
dotnet run --project .\src\Gateway.Api\Gateway.Api.csprojBrowse to http://localhost:5023/health
Browse to http://localhost:5023/swagger to see the API docs.
- In terminal 3, start Gateway.Worker (background processor)
Gateway.Worker depends on:
- Postgres
- The PSP base URL configured in appsettings.json
dotnet run --project .\src\Gateway.Worker\Gateway.Worker.csproj
- In PowerShell terminal 4, run the test with derived idempotency keys (no more hard-coded demo keys)
-
First, ask the API to derive the canonical payload + key that the middleware uses:
$derive = Invoke-WebRequest ` -Uri "http://localhost:5023/tools/derive-idempotency/charge" ` -Method POST ` -Headers @{ "x-api-key" = "local-dev" } ` -ContentType "application/json" ` -Body '{ "amount":4200,"currency":"USD","sourceToken":"tok_visa","merchantRef":"order-1001" }' $derivedKey = ($derive.Content | ConvertFrom-Json).derivedKey Write-Host "Derived key:" $derivedKey -ForegroundColor Cyan
-
Create the request using that derived key (the server re-computes it and rejects mismatches):
$response = Invoke-WebRequest ` -Uri "http://localhost:5023/payments/charge" ` -Method POST ` -Headers @{ "x-api-key" = "local-dev"; "Idempotency-Key" = $derivedKey } ` -ContentType "application/json" ` -Body '{ "amount":4200,"currency":"USD","sourceToken":"tok_visa","merchantRef":"order-1001" }' $data = $response.Content | ConvertFrom-Json $paymentId = $data.paymentId Write-Host ($data | ConvertTo-Json -Depth 5)
-
Immediately check status (expect Pending) and then after a few seconds check again (should be Authorized with an authCode):
Invoke-WebRequest ` -Uri "http://localhost:5023/payments/$paymentId" ` -Headers @{ "x-api-key" = "local-dev" } | Select-Object StatusCode, Content
-
To prove idempotency, repeat the charge with the same body and same derived key. You should receive the exact same paymentId and payload because the middleware replays the stored response for the canonical hash:
$resp = Invoke-WebRequest ` -Uri "http://localhost:5023/payments/charge" ` -Method POST ` -Headers @{ "x-api-key" = "local-dev"; "Idempotency-Key" = $derivedKey } ` -ContentType "application/json" ` -Body '{ "amount":4200,"currency":"USD","sourceToken":"tok_visa","merchantRef":"order-1001" }' Write-Host "StatusCode: $($resp.StatusCode)" ($resp.Content | ConvertFrom-Json) | ConvertTo-Json -Depth 10
A lightweight crypto simulation is wired into the same payment/outbox pipeline to show how blockchain-style flows could be modeled without adding real chain dependencies.
-
Submit a crypto charge (derive the key first):
$cryptoDerive = Invoke-WebRequest ` -Uri "http://localhost:5023/tools/derive-idempotency/crypto-charge" ` -Method POST ` -Headers @{ "x-api-key" = "local-dev" } ` -ContentType "application/json" ` -Body '{ "amount": 500000, "cryptoCurrency": "USDC", "network": "Ethereum-Testnet", "fromWallet": "0xU5AF1A6000000000000000000000000000000000", "merchantRef": "order-crypto-42" }' $cryptoKey = ($cryptoDerive.Content | ConvertFrom-Json).derivedKey Write-Host "Derived crypto key:" $cryptoKey -ForegroundColor Cyan $cryptoCharge = Invoke-WebRequest ` -Uri "http://localhost:5023/payments/crypto-charge" ` -Method POST ` -Headers @{ "x-api-key" = "local-dev"; "Idempotency-Key" = $cryptoKey } ` -ContentType "application/json" ` -Body '{ "amount": 500000, "cryptoCurrency": "USDC", "network": "Ethereum-Testnet", "fromWallet": "0xU5AF1A6000000000000000000000000000000000", "merchantRef": "order-crypto-42" }' Write-Host "Crypto charge response:" -ForegroundColor Cyan Write-Host ($cryptoCharge.Content | ConvertFrom-Json | ConvertTo-Json -Depth 5)
The API stores a normal
Payment, creates aCryptoTransactionrow inPending, and enqueues aCryptoConfirmoutbox message. The response includes the generatedtxHashso you can track it. -
Let the worker pick up the new outbox entry. Instead of calling the PSP stub, the worker simulates chain confirmations by marking the crypto transaction confirmed (3 confirmations) and authorizing the payment with the
txHashas the auth code. -
Poll the payment or its crypto transaction record:
$paymentId = ($cryptoCharge.Content | ConvertFrom-Json).paymentId Write-Host "Get Payment" -ForegroundColor Cyan $resp = Invoke-WebRequest ` -Uri "http://localhost:5023/payments/$paymentId" ` -Headers @{ "x-api-key" = "local-dev" } Write-Host "StatusCode: $($resp.StatusCode)" ($resp.Content | ConvertFrom-Json) | ConvertTo-Json -Depth 10 Write-Host "Get Crypto Transaction" -ForegroundColor Cyan $resp = Invoke-WebRequest ` -Uri "http://localhost:5023/payments/$paymentId/crypto" ` -Headers @{ "x-api-key" = "local-dev" } Write-Host "StatusCode: $($resp.StatusCode)" ($resp.Content | ConvertFrom-Json) | ConvertTo-Json -Depth 10
The crypto endpoint returns network, wallet, confirmations, and timestamps for the simulated transaction.
All crypto charge requests are idempotent just like card charges—the IdempotencyMiddleware now protects both /payments/charge and /payments/crypto-charge.
The gateway API can run against either a real PostgreSQL database or the EF Core InMemory provider. This makes it easy to run the demo locally with Dockerized Postgres while allowing CI to run without a real database.