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.

ValueExampleWhere it goes
Base URLhttps://paygate.wallet.shatori.combackend config
API keypg_test_...backend secret, Authorization header
Webhook secretwhsec_...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" } }
HTTPcodeWhen
400invalid_requestMalformed JSON, missing or unknown field, invalid value
401unauthorizedMissing/invalid API key
404not_foundPayment does not exist or belongs to another merchant
409idempotency_conflictSame order_id already used with different parameters
500internal_errorBug on our side, retry later

POST /v1/payments — create a payment

Request:

FieldTypeRequiredNotes
amountintegeryes> 0, minor units
currencystringyesUAH, USD, EUR, PLN
order_idstringyesyour id for this top-up, 1–255 chars, unique per merchant
return_urlstringyesabsolute http(s) URL; browser is sent here after checkout
webhook_urlstringyesabsolute http(s) URL of your backend endpoint
descriptionstringno≤ 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:

statusfailure_reasonpaid_at
pendingnullnull
succeedednulltimestamp
failedcard_declined | insufficient_funds | cancellednull

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}}

Delivery rules

Signature

PayGate-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(webhook_secret, "<t>.<raw body>")>

To verify:

  1. Take the raw request body bytes (not a re-serialized JSON object).
  2. Parse t and v1 from the header.
  3. Compute HMAC-SHA256(key = webhook_secret, message = t + "." + rawBody) as lowercase hex.
  4. Compare with v1 using a constant-time comparison.
  5. Reject if |now - t| > 300 seconds (replay protection).
  6. Only then JSON.parse the 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 numberResult
4242 4242 4242 4242 (any number not listed below)succeeded
ends with 0002, e.g. 4000 0000 0000 0002failed / card_declined
ends with 0051, e.g. 4000 0000 0000 0051failed / insufficient_funds
Cancel buttonfailed / cancelled

Typical pitfalls

  1. Trusting the redirect. return_url?status=succeeded can 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 or GET /v1/payments/{id}).
  2. 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 → final only once, and put a unique constraint on the ledger reference (e.g. paygate:<payment_id>).
  3. 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: true in NestJS). A global express.json() middleware registered before Nest's can also swallow the raw body.
  4. 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 still pending after, say, 2 minutes, and processes the result through the same idempotent code path as the webhook.
  5. 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.
  6. Returning non-2xx for "already processed". A duplicate is not an error: answer 200, otherwise PayGate keeps retrying.
  7. Losing the payment id. Store id (and your order_id) before redirecting the user to checkout_url. Otherwise the webhook arrives for a payment you don't know.
  8. Reusing order_id. One order_id = one payment attempt. A new top-up (even for the same amount after a failure) needs a new order_id; reusing it with a different amount gives 409, reusing it with the same body returns the old (maybe already failed) payment.
  9. Floats for money. amount is an integer in minor units. 100.50 UAH is 10050. Do not send 100.5.
  10. Webhook URL not reachable. A deployed PayGate cannot call http://localhost:3000 on 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.
  11. Ignoring t. Without the timestamp check an attacker who captured one webhook can replay it.
  12. Leaking keys. The API key and webhook secret live only in backend env/secret storage.