Getting Started
Paystable is a payment state stabilizer. Use it when your app already has a gateway integration but needs safer handling for webhook lag, duplicated events, missing callbacks, stale status APIs, and amount mismatches.
It is not a payment gateway or payment router. Paystable starts after the user begins checkout and ends when your backend receives a signed final-state callback.
Install
curl -fsSL https://paystable.vercel.app | shcd paystable# edit .env./paystable doctor./paystableThe installer downloads the latest GitHub release for your OS/arch and verifies the binary against checksums.txt.
The embedded dashboard is available at:
http://localhost:8080/dashboardAdmin routes are loopback-only. Do not expose the dashboard directly to the public internet.
Set Up Postgres
Paystable needs PostgreSQL for payment state, webhook deduplication, retries, and audit history. For the binary install path, use a local Postgres service on the server or a managed Postgres database.
After installing Postgres, create a dedicated Paystable user and database:
sudo -u postgres psqlCREATE USER paystable WITH PASSWORD 'change-this-password';CREATE DATABASE paystable OWNER paystable;Set the same values in .env:
DATABASE_URL=postgres://paystable:change-this-password@localhost:5432/paystable?sslmode=disableFor managed Postgres, use the provider’s hostname, username, password, database name, and SSL mode. Do not reuse your main application database user.
If ./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 active pg_hba.conf file:
sudo -u postgres psql -c "SHOW hba_file;"Add these rules before broader ident or peer rules:
host paystable paystable 127.0.0.1/32 scram-sha-256host paystable paystable ::1/128 scram-sha-256Reload Postgres:
sudo systemctl reload postgresqlCheck the setup before starting the server:
./paystable doctordoctor loads .env, verifies required settings, connects to Postgres, and runs the same database migrations Paystable runs on startup.
Configure
Required environment variables:
| Variable | Purpose |
|---|---|
DATABASE_URL | PostgreSQL connection string. |
GATEWAY | Current adapter, normally 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 reads. |
Useful optional variables:
| Variable | Default | Purpose |
|---|---|---|
PORT | 8080 | HTTP port. |
STABILIZATION_N | 3 | Matching completed polls required for terminal success/failure. |
HOLD_MAX_TTL_S | 900 | Maximum accepted hold TTL. |
DELIVERY_TIMEOUT_S | 10 | Merchant callback timeout. |
DELIVERY_ALLOW_INSECURE_CALLBACK | false | Allows http:// callbacks in local dev only. |
SECRET_ENCRYPTION_KEY | empty | Required for encrypted webhook secret rotation. |
Integration Flow
sequenceDiagram
autonumber
participant User
participant Merchant
participant Paystable
participant Gateway
User->>Merchant: Start checkout
Merchant->>Paystable: POST /api/v1/hold
Paystable-->>Merchant: PENDING + read_token
Merchant-->>User: Redirect to gateway
User->>Gateway: Pay
Gateway->>Paystable: POST /webhooks/payu
Paystable-->>Gateway: 200 after validation/persist
Paystable->>Gateway: Status polls
Paystable->>Merchant: Signed callback
User->>Paystable: SSE or status polling
Create a Hold
POST /api/v1/holdAuthorization: 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" }}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 amount is in the smallest currency unit. For INR, 49900 means Rs 499.00.
Point Gateway Webhooks at Paystable
Use:
POST https://<paystable-host>/webhooks/payuPaystable verifies the gateway signature. Valid webhooks are stored in webhooks. Rejected webhooks are stored in webhooks_rejected.
Read Status from the Frontend
GET /api/v1/transactions/{txn_id}/status?token={read_token}GET /api/v1/transactions/{txn_id}/stream?token={read_token}Frontend reads are for display only. Fulfillment should happen from the backend callback.
Fulfill from Callback
Paystable calls the hold callback_url when the hold reaches a final state:
POST <callback_url>X-Paystable-Signature: sha256=<hmac>X-Paystable-Idempotency-Key: <opaque-key>X-Paystable-Timestamp: <unix-seconds>Always verify the signature and deduplicate by idempotency key before fulfilling.
See Callback Contract for the full payload and retry behavior.
Local Testkit
cp .env.testkit.example .env.testkitdocker compose -f docker-compose.testkit.yml --env-file .env.testkit up --buildAvailable services:
| Service | URL |
|---|---|
| Paystable | http://localhost:8080 |
| Mock gateway | http://localhost:9090 |
| Mock merchant | http://localhost:9091 |