a payment state stabilizer for teams that cannot afford to trust one webhook too early.
Paystable is a small open-source Go service that sits after checkout and before fulfillment. It does not replace your payment gateway, route payments, vault cards, or compete with payment orchestrators. You keep using PayU today. Paystable gives your app a safer state machine around the messy part that happens after a customer pays: webhook delivery, gateway status lag, conflicting signals, callback retries, and audit trails.
The core rule is simple:
never take an irreversible action on one unverified payment signal.
That matters when a gateway says failed while the bank debit is still reconciling, when a webhook arrives late, or when your app is down for the one request that mattered.
Paystable is a truth/stabilization layer for a single merchant deployment:
- accepts gateway webhooks and verifies their signature
- stores valid webhooks before doing any processing
- polls the gateway status API on a controlled schedule
- requires stable agreement before
CONFIRMEDorFAILED - marks amount disagreements as
MISMATCH - marks unresolved cases as
INDETERMINATE - sends signed, idempotent callbacks to your app from a Postgres outbox
- keeps an append-only ledger for support, finance, and gateway disputes
Paystable is intentionally narrow. It is not a PSP, not a checkout SDK, not a Hyperswitch-style router, and not a reconciliation product for every bank statement format.
The customer should not stare at a spinner for a minute.
Recommended flow:
-
Your backend creates a hold in Paystable before redirecting the user to the gateway.
-
The gateway redirects the user back to your payment result page.
-
The page opens Paystable SSE or polls the status endpoint with the
read_token. -
For the first few seconds, show a normal verifying state.
-
If Paystable is still
VERIFYINGafter roughly 8-15 seconds, let the user leave:"We received your payment attempt and are verifying it with the bank. You can close this page. We will update your order automatically."
-
Only fulfill on the signed backend callback, not on frontend text.
For physical goods, tickets, and seat reservations, keep the order reserved until the hold resolves. For wallet credits or digital goods, do not credit balance until CONFIRMED. For low-risk products, merchants can choose their own provisional-access policy, but Paystable's trusted final state remains the callback.
| Status | Meaning | Merchant action |
|---|---|---|
PENDING |
Hold exists. No terminal evidence yet. | Reserve inventory. Show neutral processing copy. |
VERIFYING |
A webhook or scheduled check triggered gateway verification. | Keep the hold. Do not show a hard failure. |
CONFIRMED |
Gateway success was observed consistently and amount matched. | Fulfill safely. |
FAILED |
Gateway failure was observed consistently, or TTL final check verified failure. | Release inventory or offer retry. |
MISMATCH |
Gateway reported success but the verified amount did not match the hold. | Stop automation. Review manually. |
INDETERMINATE |
Paystable could not reach safe consensus before the verification window ended. | Escalate to ops/support. |
REFUNDED |
Reserved in the schema for post-confirmation reversal flows. | Do not rely on this as a complete refund workflow yet. |
Gateway webhooks hit:
POST /webhooks/{gateway}Paystable verifies the gateway signature. Valid webhooks are persisted in Postgres. Invalid webhooks are stored in webhooks_rejected for forensics and metrics.
The stabilizer stores poll jobs in verification_polls and claims them with SELECT ... FOR UPDATE SKIP LOCKED. It checks gateway status with jittered scheduling and a per-gateway token bucket.
Success requires:
- a captured/success status
- amount equality with the hold
- enough consecutive matching completed polls, controlled by
STABILIZATION_N
Failure also requires stable failure observations. Ambiguous, missing, inconsistent, or exhausted checks go to INDETERMINATE, not silent release.
When a hold expires, Paystable does not fail it on the timer alone. It runs one final gateway verification:
- success + matching amount ->
CONFIRMED - success + wrong amount ->
MISMATCH - verified failure ->
FAILED - no client, timeout, pending, not found, or inconclusive result ->
INDETERMINATE
Final states are delivered to your backend using signed HTTP callbacks. Delivery is at-least-once, so merchants must deduplicate with X-Paystable-Idempotency-Key.
Install the latest release:
curl -fsSL https://paystable.vercel.app | sh
cd paystable
# edit .env
./paystable doctor
./paystableThe installer prints each step with [INFO] messages, downloads the correct binary for your OS/arch, and verifies it against the release checksums.txt.
The example DATABASE_URL expects a local Postgres database with user paystable, password change-this-password, and database paystable; change the password before production.
Create a local database before starting the binary:
sudo -u postgres psqlCREATE USER paystable WITH PASSWORD 'change-this-password';
CREATE DATABASE paystable OWNER paystable;Then set:
DATABASE_URL=postgres://paystable:change-this-password@localhost:5432/paystable?sslmode=disableIf ./paystable doctor reports Ident authentication failed or Peer authentication failed, your Postgres pg_hba.conf is not allowing password auth for this local connection. Find the file:
sudo -u postgres psql -c "SHOW hba_file;"Add these rules before broader ident or peer rules, then reload Postgres:
host paystable paystable 127.0.0.1/32 scram-sha-256
host paystable paystable ::1/128 scram-sha-256
Run ./paystable doctor to check the .env, connect to Postgres, and apply pending migrations before starting the server.
Dashboard:
http://localhost:8080/dashboard
Admin dashboard APIs are loopback-only. Put Paystable behind your own reverse proxy or SSH tunnel if you need remote access.
For local end-to-end testing:
cp .env.testkit.example .env.testkit
docker compose -f docker-compose.testkit.yml --env-file .env.testkit up --buildPOST /api/v1/hold
Authorization: Bearer <ADMIN_API_KEY>
Content-Type: application/json{
"txn_id": "order_abc123",
"gateway": "payu",
"amount": 49900,
"currency": "INR",
"ttl_seconds": 300,
"callback_url": "https://merchant.example/paystable/callback",
"metadata": {
"order_id": "order_abc123",
"customer_email": "student@example.com"
}
}Response:
{
"txn_id": "order_abc123",
"status": "PENDING",
"read_token": "pst_rt_...",
"expires_at": "2026-06-24T12:05:00Z",
"created_at": "2026-06-24T12:00:00Z"
}The frontend can read status with:
GET /api/v1/transactions/{txn_id}/status?token={read_token}
GET /api/v1/transactions/{txn_id}/stream?token={read_token}The backend can read status with:
GET /api/v1/transactions/{txn_id}/status
Authorization: Bearer <ADMIN_API_KEY>Paystable sends final outcomes to the hold callback_url:
POST <callback_url>
Content-Type: application/json
X-Paystable-Signature: sha256=<hmac>
X-Paystable-Idempotency-Key: <opaque-key>
X-Paystable-Timestamp: <unix-seconds>{
"txn_id": "order_abc123",
"event": "transaction.confirmed",
"status": "CONFIRMED",
"amount": 49900,
"currency": "INR",
"gateway": "payu",
"verified_at": "2026-06-24T12:00:19Z",
"metadata": {
"order_id": "order_abc123",
"customer_email": "student@example.com"
}
}Verify X-Paystable-Signature before parsing or fulfilling anything.
Required:
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string. |
GATEWAY |
Active gateway. Current adapter: payu. |
WEBHOOK_SECRET |
Gateway webhook signing secret. For PayU this is the salt. |
GATEWAY_API_KEY |
Gateway credential. For PayU this is the merchant key. |
PAYU_STATUS_URL |
PayU status API endpoint. |
MERCHANT_CALLBACK_SECRET |
Secret used to sign callbacks to your app. |
ADMIN_API_KEY |
Bearer token for hold creation and backend status reads. |
Optional:
| Variable | Default | Purpose |
|---|---|---|
PORT |
8080 |
HTTP port. |
STABILIZATION_N |
3 |
Consecutive matching polls required for terminal success/failure. |
MAX_BACKOFF_S |
160 |
Legacy cap used by older scheduler paths. |
HOLD_MAX_TTL_S |
900 |
Maximum hold TTL accepted by the API. |
DELIVERY_TIMEOUT_S |
10 |
Merchant callback timeout. |
DELIVERY_WORKER_CONCURRENCY |
20 |
Concurrent outbox deliveries. |
DELIVERY_ALLOW_INSECURE_CALLBACK |
false |
Allows http:// callbacks for local development only. |
SECRET_ENCRYPTION_KEY |
empty | Required for encrypted webhook secret rotation. |
LOG_LEVEL |
info |
Log level. |
Secret rotation is available through the localhost-only admin API:
curl -X POST http://localhost:8080/api/v1/admin/config/rotate-secret \
-H 'content-type: application/json' \
-d '{"gateway":"payu","new_secret":"NEW_SECRET","window_hours":24}'Set SECRET_ENCRYPTION_KEY before using rotation. During the rotation window, Paystable accepts webhooks signed with either the old or new secret.
MIT.