# Paynet > Paynet is a payment gateway for Armenia: one REST API, hosted checkout, payment links and CMS plugins to accept Idram, Telcell and bank cards (ARCA). Amounts are whole AMD. This file is the integration guide in one page for AI assistants and developers. ## Docs - [API reference](https://paynet.am/docs/api): every endpoint with a Try it panel and drafted requests - [OpenAPI 3.1 document](https://paynet.am/docs/api.json): exact request and response schemas - [Developer guide](https://paynet.am/en/developers): quick start, SDK and webhook samples - [Plugins](https://paynet.am/en/plugins): WooCommerce, OpenCart, PrestaShop, Tilda and Ecwid connectors that need no code - [Shopify](https://paynet.am/en/shopify): buyers finish Shopify checkout, then pay on Paynet; the order is marked paid automatically - [Pricing](https://paynet.am/en/pricing): per-payment and per-receipt prices - [System status](https://paynet.am/en/status): live state of each payment provider ## Optional - [Terms of service](https://paynet.am/en/terms) - [Privacy policy](https://paynet.am/en/privacy) ## Integration brief You are helping a developer integrate Paynet (paynet.am), a payment gateway for Armenia, into their application. Build a complete, working integration from the guide below. Ask the developer which language/framework they use, which of their verified domains to use (the examples use shop.example.com), and where their API key lives (read it from an environment variable; never hard-code it, never put it in browser code). Exact request and response schemas: https://paynet.am/docs/api.json (OpenAPI 3.1). Human reference: https://paynet.am/docs/api. Base URL for every call: https://paynet.am/api/v1. Amounts are whole Armenian dram (integer). Currency is AMD. Deliver: (1) a server-side "create payment" call that returns the checkout_url and redirects the buyer to it; (2) a return page that never trusts the redirect alone but confirms the payment with GET /v1/payments/{uuid} or GET /v1/orders/{order_id}/payment; (3) a webhook endpoint that verifies the X-Paynet-Signature HMAC exactly as described, responds 200 quickly, and then pulls the payment to read its true status; (4) a refund call with an Idempotency-Key; (5) a sandbox test plan using the test key and the test cards listed below. --- Unified REST API for Armenian payment processors. Accept Idram, Telcell, and all major bank cards with a single integration. ## Authentication Send your secret key in `X-Paynet-Key: sk_live_...` (or `Authorization: Bearer sk_live_...`). Test keys start with `sk_test_` and make every payment a sandbox payment. Keys are created in the dashboard under API Keys and shown once. CMS plugins authenticate with a connect token in `X-Paynet-Connect` instead; you never need one for your own code. A key has one of two access levels, chosen when you create it. **Full access** can do everything below. **Payments only** can create and read payments and payment links, but cannot refund or buy units: give that one to a storefront or a contractor, and keep full-access keys on your own server. A payments-only key gets `403 insufficient_scope` on the calls it cannot make. Keys created before this option existed are full access. Prefer a client app? Import the ready collection, https://paynet.am/docs/postman.json, into Postman, Insomnia or Bruno: every request is drafted and the key is one variable. ## The minimum ``` POST /api/v1/payments { "amount": 15000, "order_id": "ORD-1", "return_url": "https://shop.am/thanks" } ``` Redirect the buyer to `checkout_url`. They pick a payment method there from everything you have turned on for the domain. Add `processor` to pre-select one, `redirect_mode: "direct"` (with a processor) to skip the hosted page, `callback_url` to receive the webhook, `customer_email` and `customer_name` so you can see who paid. `domain` is inferred from `return_url` when you omit it; all three URLs must be on a verified domain of yours. ## Payment lifecycle `pending` (created) -> `processing` (buyer at the provider) -> `completed`, `failed` or `expired` (unpaid after 20 minutes). `completed` becomes `refunded` only on a full refund; partial refunds keep `completed` with `refunded_amount` above zero. A late provider confirmation can move `failed` or `expired` to `completed`, so always act on the latest state you read, not the first webhook you saw. `checkout_url` is valid for 24 hours but the payment itself expires after 20 minutes. ## Webhooks Sent to the payment's `callback_url`, else the domain's webhook URL, on every final status (`transaction.status.updated`, including failed and expired) and on refunds (`payment.refunded`). Header `X-Paynet-Signature` is the lowercase hex HMAC-SHA256 of the raw request body with your webhook secret (dashboard, Domain, Webhook settings); during a secret rotation `X-Paynet-Signature-Next` carries the signature under the new secret for 24 hours. Body: `delivery_id`, `event`, `livemode`, `timestamp`, `transaction` {uuid, order_id, status, amount, refunded_amount, currency, processor, completed_at, created_at}, `refund` (uuid, amount, reason) or null, `receipt` (receipt_id, qr_url) or null. Verify with the raw bytes and `hash_equals`, dedupe on `delivery_id`, then read the truth back with `GET /api/v1/orders/{order_id}/payment` before you mark an order paid. We wait 10 seconds for a 2xx, do not follow redirects, refuse private addresses, and retry three times (after 1, 5 and 30 minutes). If a delivery for a completed or refunded payment fails for good we email you, at most once per domain every 6 hours. ## Errors Every error is `{ "error": "", "message": "..." }` plus `request_id` (also the `X-Request-Id` header, quote it to support) and `docs_url`, the section of this guide that explains the code. | Status | Code | Meaning | |---|---|---| | 401 | `missing_credentials`, `invalid_api_key`, `invalid_connect_token` | No or wrong key | | 403 | `domain_required`, `domain_not_registered`, `domain_mismatch` | The store could not be matched to one of your verified domains | | 403 | `VERIFICATION_REQUIRED` | Live payments and purchases need a verified business; `verification_url` is included | | 402 | `BALANCE_EXHAUSTED` | Your transaction balance is used up beyond the grace floor; `top_up_url` is included | | 404 | `not_found`, `order_not_found` | Unknown, another merchant's, or the wrong environment for this key | | 409 | `conflict` | The same Idempotency-Key is still being processed | | 409 | `not_refundable`, `already_refunded`, `refund_in_progress`, `refund_outcome_unknown`, `refund_declined`, `processor_unavailable` | Refund could not be done now | | 422 | `validation_failed` (with `errors`), `no_payment_method_configured`, `amount_limit_exceeded`, `velocity_exceeded`, `refund_not_supported`, `invalid_amount`, `amount_exceeds_remaining`, `below_minimum` | The request is understood but cannot be served | | 429 | `rate_limited`, `too_many_attempts` | 60 requests per minute per account (`Retry-After` is set); 120 failed authentications per minute per IP | Send `Idempotency-Key` on payment creation to retry safely for 24 hours (the replay answers 200 instead of 201). Refunds are not idempotent: on a timeout read the payment back before retrying. ## Sandbox Install the Sandbox provider from Providers and send `"processor": "sandbox"`, or use a `sk_test_` key. No bank is contacted and nothing is counted. On the test checkout, the card number picks the outcome: 4111 1111 1111 1111 approved; 4000 0000 0000 0002 declined by issuer; 4000 0000 0000 0069 insufficient funds; 4000 0000 0000 0119 processor timeout; 4000 0000 0000 0101 3-D Secure failed; 4000 0000 0000 0127 amount limit exceeded; 4000 0000 0000 0200 duplicate transaction; 4000 0000 0000 0259 transaction expired; 4000 0000 0000 0309 processor unavailable; 4000 0000 0000 0341 general failure. ## Domains Every request is tied to one of your verified domains: the `Origin` header from a browser, the `domain` field, or (since 22 Aug 2026) the host of `return_url`. Requests sent from this reference page carry our own origin, which is skipped, so the `domain` field (or `return_url`) decides. **Test keys need no domain at all**: a sandbox payment binds to your hosted domain (`m.pay.paynet.am`, created for every account, verified by construction, always offering the sandbox) and may return the buyer anywhere, `localhost` included. The hosted domain also carries your payment links when you have no website. `return_url`, `cancel_url` and `callback_url` must point at a verified domain, so verify your staging hostname in the dashboard before testing from it; `localhost` cannot be verified. ## Build it with an AI assistant The whole of this guide, with the sandbox cards and a task list, is available as one plain-text brief at `/llms.txt` (the **Copy AI prompt** button at the top copies it). Paste it into ChatGPT, Claude, Cursor or Copilot, say which language and framework you use, and the assistant has everything it needs to write a working integration: create the payment, redirect, confirm on return, verify the webhook, refund. Signed in, the brief already names your verified domain. Keep your API key in an environment variable; never paste it into a chat. ## Versioning This is API v1 and it stays v1. Changes are additive: new optional fields, new response keys and new endpoints may appear at any time, and your integration must ignore keys it does not know. A field is never removed, renamed or re-typed, an error code never changes meaning, and a webhook payload only ever gains keys. If something ever has to go, it is announced here and by email at least 90 days ahead and keeps working until then. ## Changelog - **2026-08-24**: test keys need no domain (sandbox payments bind to your hosted domain `m.pay.paynet.am`); every error carries `docs_url`; the reference is also a Postman collection at `/docs/postman.json`. - **2026-08-23**: "Try it" in this reference now works: every request comes pre-filled with example headers and data, a signed-in merchant can pick one of their keys at the top, and a request sent from this page is no longer refused as `domain_not_registered` (the page's own origin is skipped, the `domain` field decides). Every account now starts with a **Default** test key. `/llms.txt` holds the guide as an AI brief. - **2026-08-23**: API keys can be created as payments-only (no refunds, no purchases): `403 insufficient_scope`. `Idempotency-Key` on `POST /payments/{uuid}/refund` makes retries safe: the same key returns the same refund. - **2026-08-22**: `processor` and `currency` became optional on `POST /payments`; `Authorization: Bearer` accepted; the store is inferred from `return_url` when `domain` is absent; `description` is kept with the payment; `payment.refunded` webhooks also go to the payment's `callback_url`; every merchant endpoint is in this reference. ## Server-side helpers (PHP, Node.js) Drop-in clients for the four calls most integrations need. Keep the secret key in an environment variable. ```php final class Paynet { public function __construct(private string $key, private string $base = 'https://paynet.am') {} /** @return array{uuid:string, checkout_url:string, status:string} */ public function createPayment(int $amountAmd, string $orderId, string $returnUrl, array $extra = []): array { return $this->call('POST', '/api/v1/payments', ['amount' => $amountAmd, 'order_id' => $orderId, 'return_url' => $returnUrl] + $extra, ['Idempotency-Key' => $orderId . ':' . ($extra['attempt'] ?? 1)]); } public function payment(string $uuid): array { return $this->call('GET', "/api/v1/payments/{$uuid}"); } public function paymentForOrder(string $orderId): array { return $this->call('GET', '/api/v1/orders/' . rawurlencode($orderId) . '/payment'); } public function refund(string $uuid, ?int $amountAmd, string $idempotencyKey, ?string $reason = null): array { return $this->call('POST', "/api/v1/payments/{$uuid}/refund", array_filter(['amount' => $amountAmd, 'reason' => $reason]), ['Idempotency-Key' => $idempotencyKey]); } private function call(string $method, string $path, array $body = [], array $headers = []): array { $ch = curl_init($this->base . $path); $hdr = ['Authorization: Bearer ' . $this->key, 'Accept: application/json', 'Content-Type: application/json']; foreach ($headers as $k => $v) { $hdr[] = "$k: $v"; } curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $hdr, CURLOPT_POSTFIELDS => $method === 'POST' ? json_encode($body) : null, CURLOPT_TIMEOUT => 15]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $data = json_decode((string) $raw, true) ?? []; if ($status >= 400) { throw new RuntimeException(($data['error'] ?? 'http_' . $status) . ': ' . ($data['message'] ?? '')); } return $data; } } // $paynet = new Paynet(getenv('PAYNET_API_KEY')); // $p = $paynet->createPayment(15000, 'ORD-1', 'https://shop.am/thanks', ['customer_email' => $email]); // header('Location: ' . $p['checkout_url']); ``` ```js class Paynet { constructor(key, base = 'https://paynet.am') { this.key = key; this.base = base; } createPayment(amountAmd, orderId, returnUrl, extra = {}) { return this.call('POST', '/api/v1/payments', { amount: amountAmd, order_id: orderId, return_url: returnUrl, ...extra }, { 'Idempotency-Key': `${orderId}:${extra.attempt ?? 1}` }); } payment(uuid) { return this.call('GET', `/api/v1/payments/${uuid}`); } paymentForOrder(orderId) { return this.call('GET', `/api/v1/orders/${encodeURIComponent(orderId)}/payment`); } refund(uuid, amountAmd, idempotencyKey, reason) { return this.call('POST', `/api/v1/payments/${uuid}/refund`, { amount: amountAmd ?? undefined, reason }, { 'Idempotency-Key': idempotencyKey }); } async call(method, path, body, headers = {}) { const r = await fetch(this.base + path, { method, headers: { Authorization: `Bearer ${this.key}`, Accept: 'application/json', 'Content-Type': 'application/json', ...headers }, body: method === 'POST' ? JSON.stringify(body) : undefined }); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(`${data.error ?? 'http_' + r.status}: ${data.message ?? ''}`); return data; } } // const paynet = new Paynet(process.env.PAYNET_API_KEY); // const p = await paynet.createPayment(15000, 'ORD-1', 'https://shop.am/thanks', { customer_email: email }); // res.redirect(p.checkout_url); ``` ## Verify a webhook (PHP, Node.js) ```php // PHP: raw body, hex HMAC, constant-time compare, then read the truth back. $raw = file_get_contents('php://input'); $secret = getenv('PAYNET_WEBHOOK_SECRET'); $given = $_SERVER['HTTP_X_PAYNET_SIGNATURE'] ?? ''; $next = $_SERVER['HTTP_X_PAYNET_SIGNATURE_NEXT'] ?? ''; $mine = hash_hmac('sha256', $raw, $secret); if (!hash_equals($mine, $given) && !hash_equals($mine, $next)) { http_response_code(401); exit; } $event = json_decode($raw, true); // dedupe on $event['delivery_id'], then: $payment = json_decode(file_get_contents( 'https://paynet.am/api/v1/orders/' . rawurlencode($event['transaction']['order_id']) . '/payment', false, stream_context_create(['http' => ['header' => "X-Paynet-Key: " . getenv('PAYNET_API_KEY')]]) ), true); if (($payment['status'] ?? null) === 'completed') { /* mark the order paid */ } http_response_code(200); ``` ```js // Node.js (Express): keep the raw body for the HMAC, never a re-encoded one. app.post('/webhooks/paynet', express.raw({ type: '*/*' }), async (req, res) => { const mine = crypto.createHmac('sha256', process.env.PAYNET_WEBHOOK_SECRET).update(req.body).digest('hex'); const ok = [req.get('X-Paynet-Signature'), req.get('X-Paynet-Signature-Next')] .some(h => h && h.length === mine.length && crypto.timingSafeEqual(Buffer.from(h), Buffer.from(mine))); if (!ok) return res.sendStatus(401); const event = JSON.parse(req.body); // dedupe on event.delivery_id, then confirm: const r = await fetch(`https://paynet.am/api/v1/orders/${encodeURIComponent(event.transaction.order_id)}/payment`, { headers: { 'X-Paynet-Key': process.env.PAYNET_API_KEY } }); const payment = await r.json(); if (payment.status === 'completed') { /* mark the order paid */ } res.sendStatus(200); }); ``` ## Testing There are two ways in. Install the **Sandbox (Test)** provider from your dashboard and send `"processor": "sandbox"` - this also works in the CMS plugins, so you can test a real storefront end to end. Or create an API key with the **test** environment, which turns every payment made with it into a sandbox payment. Either way: no bank is contacted, no money moves, nothing is billed or counted as revenue, and the buyer is sent to a test payment page instead of a real bank. The card number decides the outcome: | Card number | Result | | --- | --- | | `4111 1111 1111 1111` | Approved | | `4000 0000 0000 0002` | Declined by issuer | | `4000 0000 0000 0069` | Insufficient funds | | `4000 0000 0000 0119` | Processor timeout | | `4000 0000 0000 0101` | 3-D Secure failed | | `4000 0000 0000 0127` | Amount limit exceeded | | `4000 0000 0000 0200` | Duplicate transaction | | `4000 0000 0000 0259` | Transaction expired | | `4000 0000 0000 0309` | Processor unavailable | | `4000 0000 0000 0341` | General failure | Any other 16-digit number is approved. Expiry, CVV and cardholder name are not checked - enter anything. Test payments appear under the Test tab in your dashboard and fire webhooks with `livemode: false`, so you can prove your integration end to end before going live.