# Satqo API

Satqo lets a business accept stablecoin payments (USDT, USDC and more on TRON, TON, Ethereum, Arbitrum, Base), keep the money on a Satqo balance, pay out to any wallet and spend it with a virtual card. This page is the whole API in one file, written so that a developer **or an AI coding assistant** can build a complete integration from it.

- Base URL: `https://api.satqo.com`
- OpenAPI 3.1: `https://api.satqo.com/openapi.yaml` (Swagger UI: `/swagger-ui.html`)
- Keys: dashboard → **Developers** (https://dash.satqo.com/settings): API key + signing secret, webhook URL
- Support: support@satqo.com

---

## 1. Quickstart (5 minutes)

1. Sign up at https://dash.satqo.com and open **Developers**. Copy the **API key** and the **signing secret**. Keep both on your server only.
2. Set your **webhook URL** there (a public `https://` URL of your backend). Satqo POSTs signed callbacks to it.
3. Create an invoice and send the customer to `data.url`:

```bash
BODY='{"foreign_id":"order-1001","amount":"49.00","currency":"USD","sender_currency":"USDT","chain":"tron","description":"Pro plan"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha512 -hmac "$SATQO_SECRET" | sed 's/^.* //')
curl -s https://api.satqo.com/api/v2/invoices/create \
  -H 'Content-Type: application/json' \
  -H "X-Processing-Key: $SATQO_KEY" \
  -H "X-Processing-Signature: $SIG" \
  -d "$BODY"
```

4. When the customer pays, your webhook gets `{"type":"invoice","status":"paid", "foreign_id":"order-1001", …}`. Verify the signature, mark the order paid, answer `200`.

---

## 2. Authentication and signing

Every request (except `GET /api/health` and the public invoice status) has two headers:

| Header | Value |
|---|---|
| `X-Processing-Key` | your API key |
| `X-Processing-Signature` | `hex(HMAC-SHA512(raw_body, signing_secret))` |

- Sign **the exact bytes you send**. Serialize the JSON once, sign that string, send that string.
- **GET requests sign the empty string** (`""`).
- Reference vector: secret `AbCdEfG123456`, body `{"currency":"BTC","foreign_id":"123456"}` →
  `03c25fcf7cd35e7d995e402cd5d51edd72d48e1471e865907967809a0c189ba55b90815f20e2bb10f82c7a9e9d865546fda58989c2ae9e8e2ff7bc29195fa1ec`
- Wrong or missing headers → `401`.

### PHP

```php
function satqo(string $method, string $path, ?array $body = null): array {
    $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES);
    $ch = curl_init('https://api.satqo.com' . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'X-Processing-Key: ' . getenv('SATQO_KEY'),
            'X-Processing-Signature: ' . hash_hmac('sha512', $raw, getenv('SATQO_SECRET')),
        ],
    ] + ($raw !== '' ? [CURLOPT_POSTFIELDS => $raw] : []));
    $res = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    return ['status' => $code, 'body' => json_decode((string)$res, true)];
}

$r = satqo('POST', '/api/v2/invoices/create', ['foreign_id' => 'order-1001', 'amount' => '49.00', 'currency' => 'USD', 'sender_currency' => 'USDT', 'chain' => 'tron']);
header('Location: ' . $r['body']['data']['url']);
```

### Node.js (18+)

```js
import crypto from 'node:crypto';

const BASE = 'https://api.satqo.com';
export async function satqo(method, path, body) {
  const raw = body === undefined ? '' : JSON.stringify(body);
  const sig = crypto.createHmac('sha512', process.env.SATQO_SECRET).update(raw).digest('hex');
  const res = await fetch(BASE + path, {
    method,
    headers: { 'Content-Type': 'application/json', 'X-Processing-Key': process.env.SATQO_KEY, 'X-Processing-Signature': sig },
    body: raw === '' ? undefined : raw,
  });
  return { status: res.status, body: await res.json() };
}

const { body } = await satqo('POST', '/api/v2/invoices/create', { foreign_id: 'order-1001', amount: '49.00', currency: 'USD', sender_currency: 'USDT', chain: 'tron' });
console.log(body.data.url);
```

### Python 3

```python
import hashlib, hmac, json, os, requests

BASE = "https://api.satqo.com"

def satqo(method, path, body=None):
    raw = "" if body is None else json.dumps(body, separators=(",", ":"))
    sig = hmac.new(os.environ["SATQO_SECRET"].encode(), raw.encode(), hashlib.sha512).hexdigest()
    r = requests.request(method, BASE + path, data=raw or None, timeout=30, headers={
        "Content-Type": "application/json",
        "X-Processing-Key": os.environ["SATQO_KEY"],
        "X-Processing-Signature": sig,
    })
    return r.status_code, r.json()

status, res = satqo("GET", "/api/v2/account/balances")
```

---

## 3. Conventions

- **Amounts** are decimal strings in the currency: `"25.50"` USDT, `"100"` EUR. JSON numbers are accepted for amounts but strings avoid float rounding. Responses add atomic values (`*_atomic`) where exact math matters: USDT/USDC/TRX 6 decimals, TON 9, ETH 18, BTC 8, fiat 2.
- **Networks** (`chain`): `tron`, `ton`, `ethereum`, `arbitrum`, `base`, `bitcoin`. What is switched on right now: `GET /api/v2/currencies`. Never hard-code; the list changes.
- **Times** are UTC, ISO 8601 (`2026-10-07T17:01:41Z`). AlphaPo fields `fixed_at` / `release_at` are unix seconds.
- **IDs**: Satqo ids are integers. `foreign_id` is yours (order id, user id), unique per account for invoices and payouts.
- **Lists**: `?limit=` (1–100, default 50) and `?offset=`; `meta` has `limit`, `offset`, `count`, `has_more`. Newest first.
- **Errors**: account endpoints answer `{"message","code","errors"?}`; the AlphaPo ones answer `{"errors":{"field":"msg"}}` or `{"message":"…"}`. Check the HTTP status first.

| Status | Meaning |
|---|---|
| 400 | Bad input (`errors` says which field) |
| 401 | Missing/wrong key or signature |
| 403 / 404 | Not yours / not found |
| 409 | Idempotency key reused for a different payout |
| 422 | Not allowed in this state (e.g. cancel a paid invoice) |
| 429 | Rate limit: 60/min, 1000/h per key by default; wait `retry_after` seconds |
| 5xx | Our side; retry with backoff |

- **Idempotency**: payouts accept an `Idempotency-Key` header. Retrying after a timeout with the same key and body returns the same payout (`Idempotent-Replayed: true`), never a second one.
- **Test mode**: there is no sandbox. Test with small real amounts on TRON (fees are cents; up to $1,000 is credited in ~10 seconds) and `POST /api/v2/ipn/test` for webhooks.

---

## 4. Endpoints

### Account

`GET /api/v2/account`

```json
{"data":{"id":10,"name":"Acme","email":"owner@acme.com","status":"active","mode":"live","callback_url":"https://acme.com/satqo/webhook","payout_limits_usd":{"daily":"300000","weekly":"1000000"},"rate_limits":{"per_minute":60,"per_hour":1000},"payouts":{"saved_recipients_only":false,"saved_recipients_only_ends_at":null,"frozen":false},"created_at":"2026-09-20T12:00:00Z"}}
```

`GET /api/v2/account/balances` — what you can spend now (payouts, conversions, card), plus deposits still confirming:

```json
{"data":{"balances":[{"asset":"USDT","available":"120.5","available_atomic":"120500000","incoming":"19.9","incoming_atomic":"19900000","usd_value":"120.50"}],"total_usd":"120.50"}}
```

(`GET /api/v2/balances` is the older on-chain view of your deposit addresses; slow, not what you can spend.)

`GET /api/v2/currencies` — networks and assets switched on, with minimums and fees:

```json
{"data":{"networks":[{"chain":"tron","name":"TRON","address_family":"tron","memo":false,"confirmations":19,"confirmations_fast":{"confirmations":3,"max_usd":"1000"},
  "assets":[{"asset":"USDT","standard":"TRC-20","currency_code":"USDTT","decimals":6,"deposits":true,"invoices":true,"payouts":true,
             "min_deposit":"3","min_deposit_atomic":"3000000","deposit_fee_percent":"0.5","min_payout":"1","payout_fee":"0"}]}],
  "fiat":["USD","EUR"],"invoice_expiry_minutes":{"default":60,"max":1440}}}
```

`confirmations` is how many blocks a deposit waits for before it is credited. On TRON it is tiered: a payment worth up to `confirmations_fast.max_usd` is credited after `confirmations_fast.confirmations` blocks (3 blocks, about 10 seconds); a bigger one waits for the full `confirmations` (19, about a minute). `confirmations_fast` is `null` on networks without a fast tier. The AML check runs either way, and a payment it flags is held for review rather than credited.

`GET /api/v2/rates` — USD prices used for invoices and conversions: `{"data":{"base":"USD","crypto":{"USDT":"1","TRX":"0.16",…},"fiat":{"USD":"1","EUR":"1.08"}}}`

### Invoices (hosted payment page)

`POST /api/v2/invoices/create`

| Field | Required | Notes |
|---|---|---|
| `foreign_id` | yes | your order id, unique |
| `amount` | yes | decimal in `currency` |
| `currency` | yes | `USD`, `EUR`, or a coin (`USDT`, `USDC`, `ETH`, `TRX`, `TON`) |
| `sender_currency` | when `currency` is fiat | coin the customer pays, e.g. `USDT` |
| `chain` | for coins on several networks | `tron`, `ton`, `ethereum`, `arbitrum` (or codes like `USDT_TRC20`) |
| `description` / `title` | no | shown to the customer |
| `email_user` | no | customer email |
| `expiry_minutes` | no | default 60, max 1440 |
| `url_success`, `url_failed` | no | where the payment page sends the customer |
| `callback_url` | no | webhook for this invoice only (public `https://`); without it callbacks go to your account webhook URL. Store plugins use it, so one account can serve several shops |

Fiat-priced invoices are converted to the coin at today's rate when created; the customer pays exactly `sender_amount_decimal`.

Response (also for `GET /api/v2/invoices/{id}`):

```json
{"data":{"id":42,"url":"https://pay.satqo.com/aB3dE5fG","foreign_id":"order-1001","name":"Pro plan","status":"created",
  "currency":"USD","amount":"4900","amount_decimal":"49","sender_currency":"USDT","sender_amount":"49000000","sender_amount_decimal":"49",
  "received_amount":"0","remaining_amount":"49","overpaid_amount":"0","chain":"tron",
  "payment_address":"TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE","payment_tag":null,"description":"Pro plan",
  "fixed_at":1791392170,"release_at":1791395770,"paid_at":null,"expires_at":"2026-10-07T18:56:10Z","created_at":"2026-10-07T17:56:10Z"}}
```

Invoice statuses (API):

```
created ──(part paid)──> pending ──(rest paid)──> completed
   │                        │   └─(more than asked)─> overpaid
   │                        └──(link expires)──> failed     (the part received is on your balance)
   ├──(paid exactly)──> completed
   ├──(paid more)────> overpaid      (all of it is on your balance)
   ├──(link expires, nothing paid)──> expired
   └──(you cancel)──> cancelled

completed / overpaid / underpaid ──(a fast-credited TRON payment is taken back)──> reversed
```

Callbacks use the stored names: `paid` = API `completed`, and `underpaid`/`overpaid`/`expired`/`failed`/`reversed` as above.

`reversed` is rare: TRON payments up to `confirmations_fast.max_usd` are credited after 3 blocks, before the network makes them final (19 blocks, ~1 minute). If such a transaction then disappears from the chain, our team checks it by hand and, if it is really gone, takes the credit back: the invoice becomes `reversed` (its `remaining_amount` counts only what is still received) and you get an `invoice` callback with `status: "reversed"` plus a `deposit` callback with `status: "reverted"`. Treat it like a chargeback: don't ship, or claw back. To avoid it entirely for an order, wait for the transaction's `confirmations` to reach 19 before fulfilling.

- `GET /api/v2/invoices?status=&foreign_id=&from=&to=&limit=&offset=` — list (`status` takes the API names).
- `POST /api/v2/invoices/{id}/cancel` (empty body) — stops an invoice nothing has been paid into yet. `422` if it is already final or a payment has already come in (status `pending`): such an invoice runs to its end (`completed`, or `failed` when it expires part-paid) and the money stays on your balance.
- Asking for the rest of a part-paid invoice: create a new invoice for `remaining_amount` in the same coin and `chain`.
- `GET /api/v2/invoice/{code}/status` — public, unsigned status by the short code in the payment URL (what the payment page polls).

### Permanent deposit addresses (wallet per user)

`POST /api/v2/addresses/take` `{"foreign_id":"user-2048","currency":"USDT","chain":"tron"}` → `201` first time, `200` after (same address):

```json
{"data":{"id":81,"currency":"USDTT","convert_currency":"USDT","chain":"tron","address":"TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE","tag":null,"foreign_id":"user-2048"}}
```

- Anything sent there lands on your balance (minus the 0.5% incoming fee) and fires a `deposit` callback with your `foreign_id`.
- EVM networks (Ethereum, Arbitrum, Base) share one address per `foreign_id`.
- **TON**: one shared Satqo wallet; the payer **must** put `tag` in the transfer comment (memo), or the payment can't be matched to the user.
- Below the minimum deposit (`/currencies`) a transfer isn't credited.
- `GET /api/v2/addresses?foreign_id=&chain=` — list.

### Payouts

Check first (nothing is sent): `POST /api/v2/withdrawal/estimate` `{"currency":"USDT","chain":"tron","amount":"25","address":"T…"}`

```json
{"data":{"currency":"USDTT","asset":"USDT","chain":"tron","amount":"25","receiver_amount":"25","fee":"0","network_fee_paid_by":"satqo",
  "min_amount":"1","available":"120.5","needs_memo":false,"address":{"valid":true,"normalized":"T…","error":null},
  "review":"automatic","ok":true,"errors":{}}}
```

Send: `POST /api/v2/withdrawal/crypto` with header `Idempotency-Key: payout-7781`

```json
{"foreign_id":"payout-7781","currency":"USDT","chain":"tron","amount":"25","address":"TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE"}
```

```json
{"data":{"id":512,"foreign_id":"payout-7781","type":"withdrawal","status":"processing","amount":"25","currency":"USDTT","convert_currency":"USDT",
  "chain":"tron","address":"TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE","tag":null,"tx_hash":null,"network_fee":"3","network_fee_currency":"TRX",
  "network_fee_paid_by":"satqo","error":null,"created_at":"2026-10-07T18:00:00Z","sent_at":null,"confirmed_at":null}}
```

- Spends your Satqo balance; Satqo pays the network fee; no payout fee.
- Addresses are checked by checksum (TRON base58check, EVM EIP-55). TON payouts take an optional `tag` (memo), up to 120 characters.
- Payout statuses: `requested` → `processing` → `sent` → `confirmed`; or `pending_approval` (large payouts are reviewed, usually within hours), `pending_risk_check`/`held` (AML screening), `failed`, `cancelled`.
- `GET /api/v2/withdrawal/{id}`, `GET /api/v2/withdrawals?status=&asset=&chain=&foreign_id=`.
- `GET /api/v2/recipients` — saved recipients from the dashboard (read-only; adding one needs two-factor there).
- **Saved recipients only**: if the account owner turns this on (dashboard → Account), payouts — including API payouts — go only to saved recipients, and a newly saved one can receive 24 hours later. `GET /api/v2/account` shows it under `payouts`; `/withdrawal/estimate` reports it in `errors.recipient`.

### History

`GET /api/v2/transactions?type=deposit|payout|swap|card&asset=&chain=&status=&from=2026-10-01&to=2026-10-31&limit=&offset=`

```json
{"data":[
  {"id":"dep_9","type":"deposit","status":"confirmed","asset":"USDC","chain":"arbitrum","amount":"4.975","amount_atomic":"4975000","gross":"5","fee":"0.025",
   "tx_hash":"0x10f6…","address":"0x505C…","from_address":"0xee7a…","confirmations":76,"invoice_id":4,"foreign_id":"order-1001","created_at":"2026-10-07T17:01:41Z"},
  {"id":"wd_512","type":"payout","status":"confirmed","asset":"USDT","chain":"tron","amount":"25","address":"T…","tx_hash":"…","created_at":"…"},
  {"id":"swp_3","type":"swap","status":"completed","from_asset":"USDT","from_amount":"100","to_asset":"USDC","to_amount":"99.5","fee":"0.5","created_at":"…"},
  {"id":"crd_8","type":"card","kind":"purchase","status":"settled","amount_usd":"-27.70","merchant":"MC DONALD S","original_amount":"1012.37","original_currency":"UYU","created_at":"…"}
 ],"meta":{"limit":50,"offset":0,"count":4,"has_more":false}}
```

### Conversion between your balances

1. `POST /api/v2/swap/quote` `{"from":"USDT","to":"USDC","amount":"100"}` → price held 30 s, with a `token`.
2. `POST /api/v2/swap/execute` `{"token":"…"}` → done, balances updated.

### Virtual card

- `GET /api/v2/cards` — cards with `last4`, `status`, `balance` (USD).
- `POST /api/v2/cards/load` `{"amount":50,"asset":"USDT"}` top up from your balance; `unload` `{"amount":20}`; `freeze`; `unfreeze`.
- Card purchases appear in `/transactions?type=card`.
- `POST /api/v2/cards/reveal` returns the full card number and CVV — only from a server you trust; never log it.

### Callbacks (webhooks)

Satqo POSTs JSON to your webhook URL with `X-Processing-Signature: hex(HMAC-SHA512(raw_body, signing_secret))` and `User-Agent: Satqo-IPN/2.0`. Invoice and payout callbacks also carry `X-Satqo-Event-Id` (e.g. `invoice_42_paid`, the same on every retry of that event) and `X-Satqo-Delivery-Attempt` (1, 2, …).

| `type` | When | Retries |
|---|---|---|
| `deposit` | a transfer to a permanent address is confirmed | yes, a few times with growing pauses (30 s, 1 min, 2 min…) |
| `deposit` with `"event":"deposit.reverted"`, `"status":"reverted"` | a fast-credited TRON deposit that did not make it into the final chain was taken back from your balance (rare; checked by a person first) | yes, same schedule as invoices; `X-Satqo-Event-Id: deposit_{id}_reverted` |
| `invoice` | paid, overpaid, underpaid, or expired (`expired` / `failed` = expired after a part) | yes, until your server answers 2xx: after 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h, 24 h (9 attempts, ~2 days) |
| `withdrawal` | a payout is confirmed, failed or cancelled | yes, same schedule as invoices |
| `test` | you called `POST /api/v2/ipn/test` | — |

Delivery rules for invoice and payout callbacks:
- Any answer other than `2xx` within 10 s (including timeouts and redirects) counts as not delivered and is retried on the schedule above.
- A retry sends the **same body** (fixed when the event happened) with a fresh signature over it and the same `X-Satqo-Event-Id`. Store processed event ids and skip repeats.
- Each status is its own event: an invoice that becomes `pending`→`paid` produces one `invoice_42_paid` event; a later `/ipn/resend` creates a new event id (`invoice_42_paid_r…`) with the current state.
- After the last attempt we stop, tell you in the dashboard (bell + email) and show it under Developers → Recent deliveries, where you can resend. Every attempt is in `GET /api/v2/ipn/logs`.

Deposit callback:

```json
{"id":9,"type":"deposit","status":"confirmed",
 "crypto_address":{"id":81,"currency":"USDTT","convert_currency":"USDT","chain":"tron","address":"T…","foreign_id":"user-2048","tag":null},
 "currency_sent":{"currency":"USDTT","convert_currency":"USDT","chain":"tron","amount":"20"},
 "currency_received":{"currency":"USDTT","convert_currency":"USDT","chain":"tron","amount":"20","amount_minus_fee":"19.9"},
 "transactions":[{"id":9,"currency":"USDTT","transaction_type":"blockchain","type":"deposit","address":"T…","amount":"20","txid":"…","confirmations":19}],
 "fees":[{"type":"deposit","currency":"USDTT","amount":"0.1"}],"error":""}
```

Reverted deposit (`deposit.reverted`) — the same shape, `amount` is what was taken back, `invoice_id` is set when the deposit paid an invoice:

```json
{"id":9,"type":"deposit","event":"deposit.reverted","status":"reverted",
 "crypto_address":{"id":81,"currency":"USDTT","convert_currency":"USDT","chain":"tron","address":"T…","foreign_id":"user-2048","tag":null},
 "currency_received":{"currency":"USDTT","convert_currency":"USDT","chain":"tron","amount":"20","amount_minus_fee":"19.9"},
 "transactions":[{"id":9,"type":"deposit","amount":"20","txid":"…","confirmations":4}],
 "invoice_id":null,"fees":[],"error":"Reverted: transaction not in the final chain. The credit was taken back from your balance.","reverted_at":"2026-10-07T18:20:00Z"}
```

Invoice callback:

```json
{"id":42,"foreign_id":"order-1001","type":"invoice","status":"paid",
 "crypto_address":{"id":81,"currency":"USDT","address":"T…","tag":null},
 "currency_sent":{"currency":"USDT","amount":"49","remaining_amount":"0"},
 "currency_received":{"currency":"USD","amount":"4900"},
 "transactions":[{"id":7,"currency":"USDT","type":"deposit","amount":"49","txid":"…","confirmations":19}],
 "fees":[],"error":"","fixed_at":1791392170,"expires_at":1791395770}
```

Verify and acknowledge (answer `2xx` fast; do slow work after):

```php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha512', $raw, getenv('SATQO_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_PROCESSING_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
$event = json_decode($raw, true);
if ($event['type'] === 'invoice' && in_array($event['status'], ['paid', 'overpaid'], true)) {
    markOrderPaid($event['foreign_id'], $event['id']);   // idempotent: the same event may come twice
}
http_response_code(200);
```

```js
// Express: keep the raw body for the signature
app.post('/satqo/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto.createHmac('sha512', process.env.SATQO_SECRET).update(req.body).digest('hex');
  const got = req.get('X-Processing-Signature') || '';
  if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString('utf8'));
  // …handle event.type / event.status, idempotently…
  res.sendStatus(200);
});
```

```python
# Flask
@app.post("/satqo/webhook")
def satqo_webhook():
    raw = request.get_data()
    expected = hmac.new(os.environ["SATQO_SECRET"].encode(), raw, hashlib.sha512).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Processing-Signature", "")):
        abort(401)
    event = json.loads(raw)
    # ...handle event["type"] / event["status"], idempotently...
    return "", 200
```

Rules for a correct handler:
- Verify the signature on the **raw** body before parsing.
- Be idempotent: key on `type` + `id` (+ `status`); the same callback can arrive twice.
- Don't trust amounts in a callback blindly for large orders: re-read `GET /api/v2/invoices/{id}`.
- `underpaid` / `failed`: the customer paid less; the part is on your balance. Ask for the rest with a new invoice for `remaining_amount`, or refund.
- Tools: `POST /api/v2/ipn/test` (signed test event to your URL), `POST /api/v2/ipn/resend` `{"entity":"invoice","id":42}`, `GET /api/v2/ipn/logs?entity=invoice&id=42`.
- Your webhook URL must be public `https://` (private and local addresses are refused).

---

## 5. Integration recipes

**Store checkout (invoice per order)**
1. On "Pay with crypto": `POST /api/v2/invoices/create` with your order id as `foreign_id`, price in USD/EUR + `sender_currency` + `chain` (or let the customer choose from `/currencies`). Save `data.id`.
2. Redirect to `data.url` (hosted page: amount, address, QR, live status, EN/ES/PT).
3. Webhook `invoice`: `paid`/`overpaid` → fulfil; `underpaid`/`failed` → partial (ask for the rest or refund); `expired` → release the stock.
4. Safety net: a cron that polls `GET /api/v2/invoices?status=created&from=…` for orders still open.

**Store plugins (no code)**
Ready plugins for WooCommerce, PrestaShop, OpenCart 3/4 and WHMCS do all of the above (invoice per order with its own `callback_url`, signed webhook, status re-check on the return page): https://satqo.com/plugins/. Shopify, Tiendanube and Tilda: payment buttons below.

**Payment button (no code, any site builder)**
1. Dashboard → Pay buttons → New button: a fixed price (USD/EUR or a coin) or "customer enters amount" (optional min/max), the coins you take, an optional return link.
2. Paste the generated HTML into your site. It is one `<a href="https://pay.satqo.com/b/CODE">` with inline styles — no script. The same link works in emails, bios and as a QR code.
3. Each customer who clicks gets a checkout (amount, coin) and then their own invoice with the hosted payment page. The same browser coming back gets its still-open invoice again.
4. Optional on the link: `?coin=USDT_TRC20` picks the coin, `?amount=25` fills the amount of an open-amount button, `?lang=es|pt|en` the language. With nothing left to choose the customer goes straight to the payment page.
5. Those invoices are ordinary invoices: they appear in `GET /api/v2/invoices` with `foreign_id` `BTN-CODE-…`, and send the usual `invoice` webhooks.

**Wallet per user (exchanges, games, top-ups)**
1. `POST /api/v2/addresses/take` with your user id as `foreign_id`, once per network. Show the address (and the TON memo).
2. Webhook `deposit` (`status` `confirmed`) → credit `currency_received.amount_minus_fee` to that user, keyed by `id`.
3. Reconcile with `GET /api/v2/transactions?type=deposit`.

**Payouts (withdrawals to users, suppliers)**
1. `POST /api/v2/withdrawal/estimate` to validate the address and amount in your UI.
2. `POST /api/v2/withdrawal/crypto` with an `Idempotency-Key` (your payout id). On a timeout, retry with the same key.
3. Webhook `withdrawal` or poll `GET /api/v2/withdrawal/{id}` until `confirmed` / `failed`.

---

## 6. Test checklist

- [ ] Signature works for POST (body) and GET (empty string); a wrong secret gives 401.
- [ ] `GET /api/v2/currencies` drives the coin/network choice (no hard-coded list).
- [ ] Invoice created with your order id; `url` opens the payment page.
- [ ] `POST /api/v2/ipn/test` reaches your webhook and your code verifies the signature.
- [ ] A real 3–5 USDT payment on TRON marks the order paid exactly once (send the same callback twice with `/ipn/resend`: still once).
- [ ] Underpay on purpose: the order shows "partially paid", not "paid".
- [ ] Let an invoice expire: the order is released.
- [ ] Payout of 1–2 USDT with an `Idempotency-Key`; retry the same request: still one payout.
- [ ] Keys only on the server, never in the browser or a mobile app; logs don't print the secret.

---

## 7. Prompts for AI coding assistants

Paste one of these into Claude, ChatGPT, Cursor or Copilot. They point the assistant at this file.

**Any stack**
```
Read https://satqo.com/docs/api.md (the full Satqo API). Integrate Satqo crypto payments into this codebase:
- a server-side Satqo client that signs every request with HMAC-SHA512 of the raw body (GET signs ""), keys from env SATQO_KEY / SATQO_SECRET;
- "Pay with crypto" at checkout: create an invoice with our order id as foreign_id, price in our currency, redirect to data.url;
- a webhook endpoint that verifies X-Processing-Signature on the raw body, handles invoice paid/overpaid/underpaid/expired/failed idempotently, and answers 200;
- a status check that polls GET /api/v2/invoices/{id} for open orders;
- tests for the signature and the webhook handler.
Never expose the secret to the browser. Use GET /api/v2/currencies for the coin/network list. Show me the plan first, then the code.
```

**Laravel**
```
Using https://satqo.com/docs/api.md, add Satqo payments to this Laravel app: a SatqoClient service (Http facade, HMAC-SHA512 signing, config/services.php keys), a "Pay with crypto" action that creates an invoice for an Order and redirects to its url, a webhook route excluded from CSRF that verifies the signature on $request->getContent(), updates the order in a DB transaction idempotently, and a scheduled command that re-checks open invoices. Add Pest/PHPUnit tests with Http::fake.
```

**Next.js (App Router)**
```
Using https://satqo.com/docs/api.md, integrate Satqo into this Next.js app: lib/satqo.ts (server only, crypto HMAC-SHA512, fetch), a server action that creates an invoice for the cart and redirects to data.url, app/api/satqo/webhook/route.ts that reads the raw body with await req.text(), verifies X-Processing-Signature with timingSafeEqual, and updates the order idempotently. Keys in env, never in client components. Add tests for the signature and the webhook.
```

**Django**
```
Using https://satqo.com/docs/api.md, add Satqo payments to this Django project: a satqo.py client (requests, HMAC-SHA512 over the exact JSON string sent), a view that creates an invoice for an Order and redirects to its url, a csrf_exempt webhook view that verifies the signature on request.body and updates the order inside transaction.atomic idempotently, and a management command that re-checks open invoices. Include tests.
```

**WooCommerce / WordPress**
```
Using https://satqo.com/docs/api.md, write a small WooCommerce payment gateway plugin "Satqo": settings for API key and signing secret, process_payment() creates a Satqo invoice (foreign_id = order id, amount = order total, currency = store currency, sender_currency USDT, chain tron) and redirects to its url, a REST route for the webhook that verifies X-Processing-Signature on the raw body and marks the order processing (paid/overpaid) or on-hold (underpaid/failed), and handles expired. Escape output, no secrets in the frontend.
```

**Payouts / wallet per user**
```
Using https://satqo.com/docs/api.md, build deposits and withdrawals for our users with Satqo: one permanent address per user and network via POST /api/v2/addresses/take (foreign_id = user id; show the TON memo), a webhook that credits confirmed deposits once per callback id, and a withdrawal flow that calls /api/v2/withdrawal/estimate, then /api/v2/withdrawal/crypto with an Idempotency-Key, and tracks the payout until confirmed or failed.
```

---

## 8. Reference client (single file)

Drop-in minimal clients. They do signing, JSON and errors; nothing else.

```php
<?php
// SatqoClient.php — PHP 8.1+, ext-curl
final class SatqoClient
{
    public function __construct(private string $key, private string $secret,
        private string $base = 'https://api.satqo.com') {}

    public function get(string $path, array $query = []): array
    {
        return $this->call('GET', $path . ($query ? '?' . http_build_query($query) : ''));
    }

    public function post(string $path, array $body = [], array $headers = []): array
    {
        return $this->call('POST', $path, $body, $headers);
    }

    public function verifyCallback(string $rawBody, string $signature): bool
    {
        return hash_equals(hash_hmac('sha512', $rawBody, $this->secret), $signature);
    }

    private function call(string $method, string $path, ?array $body = null, array $headers = []): array
    {
        $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES);
        $ch = curl_init($this->base . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json', 'X-Processing-Key: ' . $this->key,
                'X-Processing-Signature: ' . hash_hmac('sha512', $raw, $this->secret)], $headers),
        ] + ($raw !== '' ? [CURLOPT_POSTFIELDS => $raw] : []));
        $res = curl_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $err = curl_error($ch);
        curl_close($ch);
        if ($res === false) {
            throw new RuntimeException("Satqo: $err");
        }
        $json = json_decode($res, true) ?? [];
        if ($code >= 400) {
            throw new RuntimeException('Satqo ' . $code . ': ' . ($json['message'] ?? json_encode($json['errors'] ?? $json)), $code);
        }
        return $json;
    }
}

// $satqo = new SatqoClient(getenv('SATQO_KEY'), getenv('SATQO_SECRET'));
// $inv = $satqo->post('/api/v2/invoices/create', ['foreign_id' => 'order-1', 'amount' => '10', 'currency' => 'USDT', 'chain' => 'tron'])['data'];
// $payout = $satqo->post('/api/v2/withdrawal/crypto', [...], ['Idempotency-Key: payout-1'])['data'];
```

```js
// satqo.mjs — Node 18+
import crypto from 'node:crypto';

export class Satqo {
  constructor(key, secret, base = 'https://api.satqo.com') {
    Object.assign(this, { key, secret, base });
  }
  get(path, query) { return this.#call('GET', path + (query ? '?' + new URLSearchParams(query) : '')); }
  post(path, body = {}, headers = {}) { return this.#call('POST', path, body, headers); }
  verifyCallback(rawBody, signature) {
    const expected = crypto.createHmac('sha512', this.secret).update(rawBody).digest('hex');
    return typeof signature === 'string' && signature.length === expected.length
      && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  }
  async #call(method, path, body, headers = {}) {
    const raw = body === undefined ? '' : JSON.stringify(body);
    const res = await fetch(this.base + path, {
      method,
      body: raw || undefined,
      headers: { 'Content-Type': 'application/json', 'X-Processing-Key': this.key,
        'X-Processing-Signature': crypto.createHmac('sha512', this.secret).update(raw).digest('hex'), ...headers },
    });
    const json = await res.json().catch(() => ({}));
    if (!res.ok) throw Object.assign(new Error(`Satqo ${res.status}: ${json.message || JSON.stringify(json.errors || json)}`), { status: res.status, body: json });
    return json;
  }
}
```
