Skip to content

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

Terminal window
curl -fsSL https://paystable.vercel.app | sh
cd paystable
# edit .env
./paystable doctor
./paystable

The 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/dashboard

Admin 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:

Terminal window
sudo -u postgres psql
CREATE 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=disable

For 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:

Terminal window
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-256
host paystable paystable ::1/128 scram-sha-256

Reload Postgres:

Terminal window
sudo systemctl reload postgresql

Check the setup before starting the server:

Terminal window
./paystable doctor

doctor loads .env, verifies required settings, connects to Postgres, and runs the same database migrations Paystable runs on startup.

Configure

Required environment variables:

VariablePurpose
DATABASE_URLPostgreSQL connection string.
GATEWAYCurrent adapter, normally payu.
WEBHOOK_SECRETGateway webhook signing secret. For PayU this is the salt.
GATEWAY_API_KEYGateway credential. For PayU this is the merchant key.
PAYU_STATUS_URLPayU status API endpoint.
MERCHANT_CALLBACK_SECRETSecret used to sign callbacks to your app.
ADMIN_API_KEYBearer token for hold creation and backend reads.

Useful optional variables:

VariableDefaultPurpose
PORT8080HTTP port.
STABILIZATION_N3Matching completed polls required for terminal success/failure.
HOLD_MAX_TTL_S900Maximum accepted hold TTL.
DELIVERY_TIMEOUT_S10Merchant callback timeout.
DELIVERY_ALLOW_INSECURE_CALLBACKfalseAllows http:// callbacks in local dev only.
SECRET_ENCRYPTION_KEYemptyRequired 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/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"
}
}

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/payu

Paystable 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

Terminal window
cp .env.testkit.example .env.testkit
docker compose -f docker-compose.testkit.yml --env-file .env.testkit up --build

Available services:

ServiceURL
Paystablehttp://localhost:8080
Mock gatewayhttp://localhost:9090
Mock merchanthttp://localhost:9091