PayGate integration guide
PayGate is the card payment gateway our wallet uses for card top-ups. It is a sandbox: no real money moves, but it behaves like a real payment service provider (PSP). Integrate with it exactly as you would with Stripe, Checkout.com, LiqPay, etc. Then switching to a real PSP later is mostly a change of adapter.
You get from the mentor, per merchant environment (e.g. kvitka-uat). All environments share one
PayGate instance; your API key decides which merchant you are.
| Value | Example | Where it goes |
|---|---|---|
| Base URL | https://paygate.wallet.shatori.com | backend config |
| API key | pg_test_... | backend secret, Authorization header |
| Webhook secret | whsec_... | backend secret, used to verify webhooks |
Never commit them and never send them to the frontend.
Quick start
Create a payment with curl (replace the key), then open the returned checkout_url in a browser:
curl -s -X POST https://paygate.wallet.shatori.com/v1/payments \
-H "Authorization: Bearer $PAYGATE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"amount":10050,"currency":"UAH","order_id":"try-1",
"return_url":"https://example.com/return",
"webhook_url":"https://example.com/webhooks/paygate"}'
The flow
Browser wallet-core (you) PayGate
| "top up 100 UAH" | |
|------------------->| POST /v1/payments |
| |-------------------------------->| 201 {id, checkout_url, status: pending}
| |<--------------------------------|
| redirect to checkout_url (save payment id first!) |
|----------------------------------------------------->| hosted page: card, Pay / Cancel
| 303 to return_url?payment_id=...&status=... |
|<-----------------------------------------------------|
| show "processing…"| |
| | POST webhook_url (signed) |
| |<--------------------------------| async, retried until 2xx
| | 200 |
| |-------------------------------->|
| | credit wallet (once!) |
The webhook is the source of truth. The redirect only tells the browser where to go.
API reference
All requests: Authorization: Bearer <api_key>, JSON body, Content-Type: application/json.
Amounts are integer minor units: 10050 = 100.50. Currencies: UAH, USD, EUR, PLN.
Errors
Every error has this shape:
{ "error": { "code": "invalid_request", "message": "currency must be one of UAH, USD, EUR, PLN" } }
| HTTP | code | When |
|---|---|---|
| 400 | invalid_request | Malformed JSON, missing or unknown field, invalid value |
| 401 | unauthorized | Missing/invalid API key |
| 404 | not_found | Payment does not exist or belongs to another merchant |
| 409 | idempotency_conflict | Same order_id already used with different parameters |
| 500 | internal_error | Bug on our side, retry later |
POST /v1/payments — create a payment
Request:
| Field | Type | Required | Notes |
|---|---|---|---|
amount | integer | yes | > 0, minor units |
currency | string | yes | UAH, USD, EUR, PLN |
order_id | string | yes | your id for this top-up, 1–255 chars, unique per merchant |
return_url | string | yes | absolute http(s) URL; browser is sent here after checkout |
webhook_url | string | yes | absolute http(s) URL of your backend endpoint |
description | string | no | ≤ 1000 chars, shown on the checkout page |
No other fields are accepted.
POST /v1/payments
Authorization: Bearer pg_test_...
Content-Type: application/json
{
"amount": 10050,
"currency": "UAH",
"order_id": "topup_01JBX3...",
"return_url": "https://kvitka-uat.example.dev/top-up/result",
"webhook_url": "https://api.kvitka-uat.example.dev/webhooks/paygate",
"description": "Wallet top-up"
}
Response 201 Created (new) or 200 OK (same request repeated):
{
"id": "pay_6f1c0d2e9b8a4c3f8e7d6c5b4a392817",
"status": "pending",
"amount": 10050,
"currency": "UAH",
"order_id": "topup_01JBX3...",
"description": "Wallet top-up",
"checkout_url": "https://paygate.wallet.shatori.com/checkout/pay_6f1c0d2e9b8a4c3f8e7d6c5b4a392817",
"failure_reason": null,
"created_at": "2026-11-01T12:00:00.000Z",
"paid_at": null
}
Idempotency: repeating the request with the same order_id and the same amount, currency,
description, return_url, webhook_url returns the existing payment (200) — whatever its
current status. Different parameters with the same order_id → 409. So if your request times out,
just repeat it with the same body.
GET /v1/payments/{id} — get a payment
Response 200 — same object as above. Possible values:
status | failure_reason | paid_at |
|---|---|---|
pending | null | null |
succeeded | null | timestamp |
failed | card_declined | insufficient_funds | cancelled | null |
succeeded and failed are final and never change.
Hosted checkout — checkout_url
Redirect the user's browser to checkout_url. After Pay or Cancel the browser is sent
(303) to:
<return_url>?payment_id=pay_...&status=succeeded|failed
Existing query parameters of your return_url are kept.
Webhooks
PayGate sends POST <webhook_url> when the payment becomes final:
POST /webhooks/paygate
Content-Type: application/json
PayGate-Signature: t=1793537249,v1=27f38fd18b66de37e9732cd1168d4cc75baa1dbf1ff7948799a73f36726d2deb
User-Agent: PayGate/0.1.0
{"id":"evt_301877bb7df9482681f2dff134f2155d","type":"payment.succeeded","created_at":"2026-11-01T12:46:54.526Z","data":{"payment_id":"pay_b0f0bb57202245fc98e09fd1c6ab12f4","order_id":"smoke-1","amount":10050,"currency":"UAH","status":"succeeded","failure_reason":null}}
type:payment.succeededorpayment.failed.data.failure_reason:null,card_declined,insufficient_fundsorcancelled.- Event
idis stable: retries, duplicates and manual resends of the same event have the sameidand the same body (only the signature timestamp changes).
Delivery rules
- Your endpoint must answer 2xx within 10 seconds. Anything else (4xx, 5xx, timeout, connection error, even a 301/302 redirect) is a failure.
- Failed deliveries are retried after 5 s, 30 s, 2 min, 10 min, 30 min, then PayGate gives up (6 attempts).
- Delivery is at least once: you can receive the same event more than once, possibly at the same time.
- The mentor can turn on duplicate webhooks and webhook delay, and can resend any webhook.
Signature
PayGate-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(webhook_secret, "<t>.<raw body>")>
To verify:
- Take the raw request body bytes (not a re-serialized JSON object).
- Parse
tandv1from the header. - Compute
HMAC-SHA256(key = webhook_secret, message = t + "." + rawBody)as lowercase hex. - Compare with
v1using a constant-time comparison. - Reject if
|now - t| > 300seconds (replay protection). - Only then
JSON.parsethe body.
NestJS example
Enable raw body in main.ts (NestJS ≥ 9):
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { rawBody: true });
await app.listen(3000);
}
bootstrap();
Signature verification (pure function, easy to unit-test):
// paygate-signature.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyPayGateSignature(
rawBody: Buffer,
header: string | undefined,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
if (!header) return false;
const parts = new Map<string, string>();
for (const item of header.split(',')) {
const [key, value] = item.split('=', 2);
if (key && value) parts.set(key.trim(), value.trim());
}
const t = parts.get('t');
const v1 = parts.get('v1');
if (!t || !v1 || !/^\d+$/.test(t) || !/^[0-9a-f]{64}$/.test(v1)) return false;
if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(v1, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
Controller (REST, next to your GraphQL API):
// paygate-webhook.controller.ts
import {
Controller, Post, Req, Headers, HttpCode, UnauthorizedException, BadRequestException,
} from '@nestjs/common';
import type { RawBodyRequest } from '@nestjs/common';
import type { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import { verifyPayGateSignature } from './paygate-signature';
import { TopUpService } from './top-up.service';
interface PayGateEvent {
id: string;
type: 'payment.succeeded' | 'payment.failed';
created_at: string;
data: {
payment_id: string;
order_id: string;
amount: number;
currency: 'UAH' | 'USD' | 'EUR' | 'PLN';
status: 'succeeded' | 'failed';
failure_reason: 'card_declined' | 'insufficient_funds' | 'cancelled' | null;
};
}
@Controller('webhooks/paygate')
export class PayGateWebhookController {
constructor(
private readonly config: ConfigService,
private readonly topUps: TopUpService,
) {}
@Post()
@HttpCode(200)
async handle(
@Req() req: RawBodyRequest<Request>,
@Headers('paygate-signature') signature: string | undefined,
): Promise<{ received: true }> {
const secret = this.config.getOrThrow<string>('PAYGATE_WEBHOOK_SECRET');
if (!req.rawBody || !verifyPayGateSignature(req.rawBody, signature, secret)) {
throw new UnauthorizedException('invalid signature');
}
let event: PayGateEvent;
try {
event = JSON.parse(req.rawBody.toString('utf8'));
} catch {
throw new BadRequestException('invalid JSON');
}
// Keep this fast: record the event and enqueue work (e.g. BullMQ), then return 200.
await this.topUps.handlePayGateEvent(event);
return { received: true };
}
}
Idempotent processing (sketch, Knex + MySQL):
// top-up.service.ts (excerpt)
async handlePayGateEvent(event: PayGateEvent): Promise<void> {
await this.knex.transaction(async (trx) => {
// Lock our own record of this top-up.
const topUp = await trx('top_ups')
.where({ paygate_payment_id: event.data.payment_id })
.forUpdate()
.first();
if (!topUp) throw new Error(`unknown payment ${event.data.payment_id}`); // -> 500, PayGate retries
// Already final? This is a duplicate or a late retry: do nothing, still answer 200.
if (topUp.status !== 'pending') return;
// Never trust amounts from anywhere but your own DB; just cross-check.
if (topUp.amount_minor !== event.data.amount || topUp.currency !== event.data.currency) {
throw new Error('amount/currency mismatch');
}
if (event.data.status === 'succeeded') {
await trx('top_ups').where({ id: topUp.id }).update({ status: 'succeeded' });
await this.ledger.credit(trx, topUp.wallet_id, topUp.amount_minor, topUp.currency, {
reference: `paygate:${event.data.payment_id}`, // unique index => second credit is impossible
});
} else {
await trx('top_ups')
.where({ id: topUp.id })
.update({ status: 'failed', failure_reason: event.data.failure_reason });
}
});
}
Quick manual check of your verifier (bash):
BODY='{"hello":"world"}'; T=$(date +%s); SECRET='whsec_...'
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -i -X POST http://localhost:3000/webhooks/paygate \
-H 'Content-Type: application/json' -H "PayGate-Signature: t=$T,v1=$SIG" -d "$BODY"
Test cards
Any expiry and CVC. Spaces and dashes in the number are fine; the number must have 12–19 digits.
| Card number | Result |
|---|---|
4242 4242 4242 4242 (any number not listed below) | succeeded |
ends with 0002, e.g. 4000 0000 0000 0002 | failed / card_declined |
ends with 0051, e.g. 4000 0000 0000 0051 | failed / insufficient_funds |
| Cancel button | failed / cancelled |
Typical pitfalls
- Trusting the redirect.
return_url?status=succeededcan be typed by anyone in the address bar and is not signed. Never credit the wallet from it. On the return page, show "processing…" and read the status from your backend (which learns it from the webhook orGET /v1/payments/{id}). - Crediting twice. Webhooks arrive more than once (retries, duplicates, resends, two at the same
time). Make crediting idempotent: lock the top-up row, transition
pending → finalonly once, and put a unique constraint on the ledger reference (e.g.paygate:<payment_id>). - Verifying the signature over re-serialized JSON.
JSON.stringify(JSON.parse(body))may not equal the bytes that were signed. Use the raw body (rawBody: truein NestJS). A globalexpress.json()middleware registered before Nest's can also swallow the raw body. - No fallback when a webhook is lost. Your endpoint may be down for longer than the retry window
(~45 min). Run a periodic job (e.g. BullMQ repeatable, every few minutes) that calls
GET /v1/payments/{id}for top-ups stillpendingafter, say, 2 minutes, and processes the result through the same idempotent code path as the webhook. - Slow webhook handler. More than 10 s = failure and a retry, while your first run may still be in progress. Do the minimum (verify, store, enqueue), answer 200, process in a worker.
- Returning non-2xx for "already processed". A duplicate is not an error: answer 200, otherwise PayGate keeps retrying.
- Losing the payment id. Store
id(and yourorder_id) before redirecting the user tocheckout_url. Otherwise the webhook arrives for a payment you don't know. - Reusing
order_id. Oneorder_id= one payment attempt. A new top-up (even for the same amount after a failure) needs a neworder_id; reusing it with a different amount gives409, reusing it with the same body returns the old (maybe already failed) payment. - Floats for money.
amountis an integer in minor units.100.50 UAHis10050. Do not send100.5. - Webhook URL not reachable. A deployed PayGate cannot call
http://localhost:3000on your laptop. Use the deployed backend URL, or a tunnel for local testing. The URL must not redirect (http→https, missing trailing slash): redirects are treated as failures. - Ignoring
t. Without the timestamp check an attacker who captured one webhook can replay it. - Leaking keys. The API key and webhook secret live only in backend env/secret storage.