# NetArz Payment Gateway — Integration Guide

«درگاه پرداخت واسط نِت اَرز» lets your site take money in Iranian Toman through
several methods without building any of them yourself. You create a **payment
intent**, send your customer to a hosted checkout page, and NetArz tells you when
the money arrived.

- API base URL: `https://netarz.ir/api/pay/v1`
- Hosted checkout: `https://netarz.ir/p/{token}` (NetArz builds this URL for you)
- Interactive reference: https://netarz.ir/docs/pay
- This guide in Markdown: https://netarz.ir/docs/pay.md
- All amounts are **integer Toman** (تومان), never Rial and never decimals.

> Access is limited. Apps are created by the NetArz admin for the NetArz group's
> own sites; there is no public sign-up. If you have a key, your app already exists.

---

## 1. Authentication

Every API call carries your app's secret as a bearer token:

```
Authorization: Bearer sk_pay_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Accept: application/json
```

Keep the secret on your server. It must never reach a browser, a mobile app, or a
public repository. NetArz stores only a hash of it, so a lost key cannot be
recovered — the admin issues a new one and the old one stops working immediately.

Check that a key works:

```bash
curl https://netarz.ir/api/pay/v1/account \
  -H "Authorization: Bearer $NETARZ_PAY_SECRET"
```

The response repeats your commission, the payment methods available to you, your
amount limits and your current unsettled balance.

---

## 2. How the money works

You name the amount **you want to receive**. NetArz adds its commission and the
VAT on that commission on top, and the payer pays the sum. Your amount is never
reduced.

On an amount of 100,000 Toman, with a commission of 1٪ and VAT of 9٪:

| Line | Toman |
|---|---|
| `amount` — what you receive | 100,000 |
| `commission` — NetArz fee (1٪) | 1,000 |
| `commission_vat` — VAT on the fee (9٪) | 90 |
| **`total` — what the payer pays** | **101,090** |

Show `total` to your customer as the amount due. The bank's own fee is NetArz's
cost, taken out of the commission — you are never billed for it.

Money collected for you is held by NetArz as a payable and settled to your company
separately by the NetArz admin; it is not revenue for NetArz, only the commission is.

---

## 3. The flow

1. Your server calls `POST /intents` with the amount and a `callback_url` on your
   own domain. You get back an `id` and a `checkout_url`.
2. You redirect the customer's browser to `checkout_url`.
3. The customer picks a method and pays on the NetArz page.
4. NetArz sends the browser back to your `callback_url` with
   `?payment_intent=<id>&status=<status>&order_id=<your order id>`.
5. **Your server calls `POST /intents/{id}/verify`** and only delivers the goods if
   it answers `verified: true`. The redirect alone is not proof of payment.
6. Independently, NetArz posts a signed `payment.succeeded` webhook to your app, so
   a customer who closes the browser mid-redirect is still accounted for.

Steps 5 and 6 are both needed: the verify call is what you trust, the webhook is
what catches the customer who never came back.

---

## 4. Create a payment intent

`POST https://netarz.ir/api/pay/v1/intents`

| Field | Type | Required | Notes |
|---|---|---|---|
| `amount` | integer | yes | Toman you want to receive — **your share, before the fee**. Must be at least 10,000. |
| `callback_url` | string | yes | Where the payer returns. **Must be on a domain registered for your app**, or the call is refused with `422`. |
| `cancel_url` | string | no | Where a cancelled payment returns. Same domain rule. Defaults to `callback_url`. |
| `order_id` | string | no | Your own order reference. Unique per app — reusing one is refused with `409`, which makes double-charging a customer hard by accident. |
| `description` | string | no | One line shown on the checkout page. |
| `customer_name` | string | no | Shown to nobody; kept for your own reconciliation. |
| `customer_email` | string | no | |
| `customer_mobile` | string | no | |
| `methods` | string[] | no | Restrict the methods offered: `zarinpal`, `crypto`, `wallet`. Defaults to all of them. |
| `metadata` | object | no | Anything you want returned untouched on read and on the webhook. |
| `expires_in` | integer | no | Minutes until the intent expires, 5–1440. Defaults to 30. |

```bash
curl -X POST https://netarz.ir/api/pay/v1/intents \
  -H "Authorization: Bearer $NETARZ_PAY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100000,
    "order_id": "ORD-1042",
    "description": "اشتراک یک‌ساله",
    "callback_url": "https://example.com/payment/return",
    "cancel_url": "https://example.com/cart",
    "customer_mobile": "09120000000",
    "metadata": {"plan": "yearly"}
  }'
```

```json
{
  "id": "4e1f8f2a-…",
  "object": "payment_intent",
  "status": "new",
  "amount": 100000,
  "commission": 1000,
  "commission_vat": 90,
  "total": 101090,
  "currency": "IRT",
  "checkout_url": "https://netarz.ir/p/pi_…",
  "order_id": "ORD-1042",
  "expires_at": "2026-09-21T12:30:00+00:00"
}
```

Store `id` against your order, then redirect to `checkout_url`.

---

## 5. Verify a payment

`POST https://netarz.ir/api/pay/v1/intents/{id}/verify`

`{id}` is the intent's `id`, or your own `order_id` — either works.

```bash
curl -X POST https://netarz.ir/api/pay/v1/intents/ORD-1042/verify \
  -H "Authorization: Bearer $NETARZ_PAY_SECRET"
```

Paid:

```json
{
  "verified": true,
  "first_verification": true,
  "payment_intent": { "status": "paid", "amount": 100000, "reference": "…", "paid_at": "…" }
}
```

Not paid — HTTP `402`:

```json
{ "error": {"code": "not_paid", "message": "این درخواست هنوز پرداخت نشده است."} }
```

`first_verification` is `true` only the first time; use it to make delivery
idempotent if the customer refreshes the return page.

`GET https://netarz.ir/api/pay/v1/intents/{id}` reads the same object without stamping anything, and
`POST https://netarz.ir/api/pay/v1/intents/{id}/cancel` closes an unpaid intent.

---

## 6. Webhooks

If a webhook URL is registered for your app, NetArz posts JSON to it on every
outcome and retries with backoff for about half a day until your server answers
`2xx`.

Events: `payment.succeeded`, `payment.failed`, `payment.canceled`, `payment.expired`,
`payment.refunded`.

Headers:

```
X-NetArz-Event: payment.succeeded
X-NetArz-Delivery: <uuid, stable across retries>
X-NetArz-Signature: t=1758441600,v1=<hex hmac-sha256>
```

Body:

```json
{
  "id": "evt_4e1f8f2a-…",
  "event": "payment.succeeded",
  "created": 1758441600,
  "data": { "id": "4e1f8f2a-…", "status": "paid", "amount": 100000, "total": 101090, "order_id": "ORD-1042", "metadata": {} }
}
```

### Verifying the signature

Sign `"<t>.<raw request body>"` with your webhook secret and compare in constant
time. **Use the raw body** — re-encoding the JSON changes the bytes and the
signature will not match.

```php
$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_NETARZ_SIGNATURE'] ?? '';
$secret = getenv('NETARZ_WEBHOOK_SECRET');           // whsec_…

parse_str(str_replace(',', '&', $header), $p);
$expected = hash_hmac('sha256', ($p['t'] ?? '') . '.' . $raw, $secret);

if (! hash_equals($expected, $p['v1'] ?? '') || abs(time() - (int) ($p['t'] ?? 0)) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);
// Deliver the order for $event['data']['order_id'] — idempotently.
http_response_code(200);
```

```js
// Node / Express — mount with express.raw({type: 'application/json'})
const crypto = require('crypto');

app.post('/webhooks/netarz', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = Object.fromEntries(req.get('X-NetArz-Signature').split(',').map(s => s.split('=')));
  const expected = crypto.createHmac('sha256', process.env.NETARZ_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body.toString('utf8')}`).digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) return res.sendStatus(400);
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deliver idempotently, keyed on event.data.order_id.
  res.sendStatus(200);
});
```

```python
# Python / Flask
import hmac, hashlib, time
from flask import request, abort

@app.post("/webhooks/netarz")
def netarz_webhook():
    raw = request.get_data()
    parts = dict(p.split("=", 1) for p in request.headers.get("X-NetArz-Signature", "").split(","))
    expected = hmac.new(SECRET.encode(), f"{parts.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
        abort(400)

    event = request.get_json()
    # Deliver idempotently.
    return "", 200
```

Answer `2xx` quickly and do the slow work afterwards; a timeout is treated as a
failure and retried. Retries repeat the same `X-NetArz-Delivery`, so key your
idempotency on that or on `order_id`.

---

## 7. Statuses

| `status` | Meaning |
|---|---|
| `new` | Created; the payer has not picked a method yet. |
| `pending` | The payer is inside a payment (bank page, USDT transfer). |
| `paid` | Money received and confirmed. The only status you may deliver on. |
| `failed` | The payment did not go through. Nothing was taken. |
| `expired` | The window closed unpaid. Create a new intent. |
| `canceled` | Closed through the API before payment. |
| `refunded` | Fully refunded after payment. Returning the money to a card or a USDT wallet is arranged by a NetArz admin outside the API, so this arrives as a `payment.refunded` webhook rather than something you trigger. Reverse whatever you delivered. |

---

## 8. Errors

Every error is a JSON object shaped like this, with a Persian message safe to show
a customer:

```json
{ "error": {"code": "request_failed", "message": "مبلغ کمتر از حداقل مجاز این اپ است."} }
```

| HTTP | When |
|---|---|
| `401` | Missing or wrong key. |
| `403` | The app is suspended. |
| `404` | No intent with that id or `order_id` under your app. |
| `409` | `order_id` already used, or the intent is no longer cancellable. |
| `410` | The intent expired. |
| `422` | A field failed validation — amount out of range, `callback_url` off-domain. |
| `429` | Over 300 requests a minute. Back off and retry. |
| `503` | The gateway is switched off site-wide. |

---

## 9. Checklist for a correct integration

- Keep the secret server-side, in an environment variable.
- Use `total` as the amount you show the customer; store `id` against your order.
- Never deliver on the browser redirect alone — call `verify` (or wait for the
  webhook) and check `status == "paid"`.
- Make delivery idempotent; both `verify` and the webhook can arrive for the same
  payment, and webhooks retry.
- Register the webhook URL and verify its signature against the raw body.
- Set `order_id` on every intent: it blocks accidental double charges and makes
  reconciliation possible.
- Handle `expired` by creating a fresh intent rather than reusing the old one.
