# Fees
URL: /fees
`fees.preview` returns the exact **total fee** (and your **net** after it) for any amount and payment
type before you transact — no funds move. Use it to show a customer the total, price a payout, or
decide what to charge. Needs the `balances:read` [scope](/authentication).
## Preview a fee
```ts
const preview = await ap.fees.preview({
amount: "100.00", // decimal string, ≤ 6 dp
currency: "USDT",
paymentType: "CHECKOUT", // optional, defaults to CHECKOUT
});
console.log(preview.fee, preview.net); // total fee, amount after fee
```
```python
preview = ap.fees.preview(
amount="100.00", # decimal string, ≤ 6 dp
currency="USDT",
payment_type="CHECKOUT", # optional, defaults to CHECKOUT
)
print(preview["fee"], preview["net"]) # total fee, amount after fee
```
```go
preview, err := ap.Fees.Preview(ctx, "100.00", "USDT", absolutepay.PaymentCheckout)
if err != nil {
log.Fatal(err)
}
fmt.Println(preview.Fee, preview.Net) // total fee, amount after fee
```
```bash
curl "https://api.absolutepay.io/v1/fees/preview?amount=100.00¤cy=USDT&paymentType=CHECKOUT" \
-H "Authorization: Bearer $APP_TOKEN" \
$(sign GET "/v1/fees/preview?amount=100.00¤cy=USDT&paymentType=CHECKOUT" "")
```
```json
{
"amount": "100.000000",
"currency": "USDT",
"paymentType": "CHECKOUT",
"fee": "0.750000",
"net": "99.250000"
}
```
All values are decimal strings. `fee` is the total charged on the amount and `net = amount − fee` — so
you can pass the fee through to your customer or absorb it, either way.
## How the fee is computed
- Pricing differs per operation — pass the `paymentType` you're about to perform:
| `paymentType` | Prices |
| --- | --- |
| `CHECKOUT` (default) | a pay-in checkout or invoice |
| `WITHDRAWAL` / `PAYOUT` | an on-chain [payout](/payouts) (`PAYOUT` is an alias for `WITHDRAWAL`) |
| `OFFRAMP` | a crypto → fiat [bank withdrawal](/offramp) |
| `GIFTCARD` | a [gift-card](/giftcards) issuance |
[Conversions](/conversions) and [subscriptions](/subscriptions) aren't priced here: a conversion's cost is the spread inside its live quote, and subscription cycle fees are settled per cycle. Passing either returns `400` — read the cost from the conversion quote or the subscription plan instead.
The preview reflects your tier and network conditions at call time — it doesn't reserve a rate.
Call it right before creating the order so the number you show matches the fee that's actually
charged.
## Next
- [Accept payments](/accept-payments) — create the checkout you just priced.
- [Send payouts](/payouts) — `WITHDRAWAL` fees also appear in `payouts.options()`.
- [Gift cards](/giftcards) · [Conversions](/conversions)
- [API reference](/api) — `GET /v1/fees/preview` full field tables.
---
# Balances & reconciliation
URL: /balances-reconciliation
Read what you hold, then pull the settled ledgers to reconcile against your own records. Each read is
least-privilege scoped: balances and deposits need `balances:read`, checkouts `checkouts:read`,
invoices `invoices:read`, refunds `payments:read`, conversions `convert:read`, and the reconciliation
endpoints `ledger:read`.
## Balances
Per-currency `available` and `locked` amounts, as `{ items }`:
```ts
const { items } = await ap.balances.list();
// [{ currency: "USDT", available: "842.500000", locked: "0.000000" }]
```
```python
result = ap.balances.list()
items = result["items"]
# [{ currency: "USDT", available: "842.500000", locked: "0.000000" }]
```
```go
page, err := ap.Balances.List(ctx)
if err != nil {
log.Fatal(err)
}
// page.Items: []absolutepay.Balance{{Currency: "USDT", Available: "842.500000", Locked: "0.000000"}}
fmt.Println(page.Items)
```
```bash
curl https://api.absolutepay.io/v1/balances \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/balances "")
```
`available` is what you can pay out now; `locked` is reserved against in-flight operations and clears
when they settle. All amounts are decimal strings — parse them with a decimal type, never floats.
## Reconcile money-in
There's no single "everything" ledger. Reconcile from the resource that produced each movement — each
is a settled, authoritative list. For pay-ins, `/v1/reconciliation/payments` is the accounting-grade
feed across **all** kinds (checkouts and invoices); use the per-resource lists when you want just one
product. [Checkouts and invoices are separate resources](/accept-payments) — `/v1/invoices` returns
invoices only, `/v1/checkouts` returns checkouts only.
| Source of truth | Method | Endpoint |
| --- | --- | --- |
| Settled pay-ins — all kinds, keyed for accounting (**use this to reconcile**) | `ap.reconciliation.payments({ from, to })` | `GET /v1/reconciliation/payments` |
| Settled hosted checkouts | `ap.checkouts.list({ status: "PAID" })` | `GET /v1/checkouts?status=PAID` |
| Settled invoices | `ap.invoices.list({ status: "PAID" })` | `GET /v1/invoices?status=PAID` |
| On-chain deposits credited to your balance | `ap.deposits.list()` | `GET /v1/deposits` |
| Settled refunds you issued | `ap.refunds.list()` | `GET /v1/refunds` |
| Settled conversions | `ap.conversions.list()` | `GET /v1/conversions` |
Every list returns `{ items, nextCursor }` and pages the same way (`limit` + `before` → `nextCursor`,
see [Pagination](/pagination)). The `reconciliation`, `refunds`, and `conversions` lists additionally
carry a `total`. The history endpoints accept a time range (`from`/`to`, **epoch milliseconds**,
inclusive); `refunds`/`conversions` also take a `currency` filter.
```ts
const range = { from: 1717200000000, to: 1719792000000, limit: 100 };
const payments = await ap.reconciliation.payments(range); // all pay-ins (checkouts + invoices)
const checkouts = await ap.checkouts.list({ status: "PAID", limit: 100 }); // checkouts only
const invoices = await ap.invoices.list({ status: "PAID", limit: 100 }); // invoices only
const deposits = await ap.deposits.list({ ...range });
const refunds = await ap.refunds.list(range);
const converts = await ap.conversions.list(range);
```
```python
range_ = {"from_": 1717200000000, "to": 1719792000000, "limit": 100}
payments = ap.reconciliation.payments(**range_) # all pay-ins (checkouts + invoices)
checkouts = ap.checkouts.list(status="PAID", limit=100) # checkouts only
invoices = ap.invoices.list(status="PAID", limit=100) # invoices only
deposits = ap.deposits.list(from_=range_["from_"], to=range_["to"]) # deposits: no limit param
refunds = ap.refunds.list(**range_)
converts = ap.conversions.list(**range_)
```
```go
rq := absolutepay.ReconciliationQuery{From: 1717200000000, To: 1719792000000, Limit: 100}
payments, err := ap.Reconciliation.Payments(ctx, rq) // all pay-ins → page.Items, page.Total, page.NextCursor
checkouts, err := ap.Checkouts.List(ctx, absolutepay.ListQuery{Status: "PAID", Limit: 100}) // checkouts only
invoices, err := ap.Invoices.List(ctx, absolutepay.ListQuery{Status: "PAID", Limit: 100}) // invoices only
deposits, err := ap.Deposits.List(ctx, absolutepay.DepositHistoryQuery{From: rq.From, To: rq.To})
refunds, err := ap.Refunds.List(ctx, absolutepay.LedgerQuery{From: rq.From, To: rq.To, Limit: 100})
converts, err := ap.Conversions.List(ctx, absolutepay.LedgerQuery{From: rq.From, To: rq.To, Limit: 100})
_ = err
fmt.Println(payments, checkouts, invoices, deposits, refunds, converts)
```
```bash
R="from=1717200000000&to=1719792000000&limit=100"
# All pay-ins (checkouts + invoices), keyed for accounting — use this to reconcile
curl "https://api.absolutepay.io/v1/reconciliation/payments?$R" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/reconciliation/payments?$R" "")
# Or list a single product — each is kind-scoped
curl "https://api.absolutepay.io/v1/checkouts?status=PAID&limit=100" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/checkouts?status=PAID&limit=100" "")
curl "https://api.absolutepay.io/v1/invoices?status=PAID&limit=100" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/invoices?status=PAID&limit=100" "")
curl "https://api.absolutepay.io/v1/deposits?$R" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/deposits?$R" "")
curl "https://api.absolutepay.io/v1/refunds?$R" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/refunds?$R" "")
curl "https://api.absolutepay.io/v1/conversions?$R" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/conversions?$R" "")
```
- **Settled pay-ins** (`/v1/reconciliation/payments`) key on `merchantTradeNo` + `paidAt`.
- **Payouts** reconcile from `/v1/reconciliation/withdrawals` (keyed on `merchantBatchNo` +
`suborderId` + `txId`) — see [Payouts](/payouts).
Match `merchantTradeNo` (pay-ins) and `merchantBatchNo` (payouts) — the ids you set or received at
creation — against your ledger. Run it on a schedule (e.g. hourly/daily) using the time range, and
treat the settled lists as authoritative over webhook timing.
## Next
- [Webhooks](/webhooks) — the real-time complement to scheduled reconciliation.
- [Accept payments](/accept-payments) and [Payouts](/payouts) — where `merchantTradeNo` and
`merchantBatchNo` are set.
- [Pagination](/pagination) — every list pages by `limit` + `before` → `nextCursor`.
- [API reference](/api) — full query params and response fields for each endpoint.
---
# Agent skill
URL: /agent-skill
If you build with an **AI coding agent** (Claude Code, Cursor, …), install the AbsolutePay agent skill and
your agent will scaffold the integration for you — via the SDKs or the signed REST API — with the
guardrails already baked in: server-side keys, webhook verification on the raw body, idempotent payouts,
and decimal-string amounts.
## Install
```bash
npx skills add AbsolutePay/absolutepay-skill
```
This uses the [`skills`](https://github.com/vercel-labs/skills) CLI (GitHub is the registry; it
auto-detects Claude Code / Cursor / …). For the full install including the bundled reference files:
```bash
npx github:AbsolutePay/absolutepay-skill
```
## What it gives your agent
- **Client setup** for Node, Python, and Go — plus the signed REST scheme for any other language.
- The **complete API surface**: every endpoint mapped to its exact Node / Python / Go SDK call.
- Webhook verification, idempotency, pagination, and the money-safety rules, built into the guidance.
## Then just ask
Prompt your agent — for example, *“add AbsolutePay checkout to this app”* or *“wire an AbsolutePay
payout with a webhook.”* It picks up the skill and follows it.
Source + issues: [github.com/AbsolutePay/absolutepay-skill](https://github.com/AbsolutePay/absolutepay-skill).
## Next
Prefer to write it yourself? Install a client library directly.
Make your first successful call.
Every endpoint, interactive.
---
# Send payouts
URL: /payouts
A payout sends funds from your workspace balance to external addresses. You submit a **batch** of one
or more recipients in one call; each recipient is a **suborder** you track to completion. The batch is
accepted synchronously (`202`) and settles asynchronously — you learn the outcome via a
`payout.settled` / `payout.partial` / `payout.failed` [webhook](/webhooks).
**Before you start:** the key needs `payouts:write` (reads need `payouts:read`), and the batch draws
from your `available` balance ([Balances](/balances-reconciliation)). Amounts are decimal strings
(e.g. `"25.00"`).
## 1. Check options first
Fees, minimums, and which chains a currency supports:
```ts
const { options } = await ap.payouts.options({ currency: "USDT" });
// options: [{ currency, chain, label, withdrawFee, withdrawFeePercent, minWithdraw, maxWithdraw, minConfirm }, …]
```
```python
options = ap.payouts.options(currency="USDT")["options"]
# options: [{ currency, chain, label, withdrawFee, withdrawFeePercent, minWithdraw, maxWithdraw, minConfirm }, …]
```
```go
res, err := ap.Payouts.Options(ctx, "USDT")
if err != nil {
log.Fatal(err)
}
options := res["options"]
// options: [{ currency, chain, label, withdrawFee, withdrawFeePercent, minWithdraw, maxWithdraw, minConfirm }, …]
```
```bash
curl "https://api.absolutepay.io/v1/payouts/options?currency=USDT" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/payouts/options?currency=USDT" "")
```
Validate every recipient's address + chain against these options before you submit — an invalid
address or unsupported chain fails that suborder.
## 2. Create the batch
All three SDKs take an idempotency key on `create`; it becomes the batch reference
(`merchantBatchNo`) you poll later.
```ts
const batch = await ap.payouts.create(
{
items: [
{ recipientAddress: "0xabc…", chain: "MATIC", amount: { amount: "25.00", currency: "USDT" }, memo: "June payout" },
// …more recipients in the same batch
],
},
{ idempotencyKey: "payout_2026_07_03_001" },
);
console.log(batch.merchantBatchNo, batch.status); // "payout_2026_07_03_001", "PENDING"
```
```python
batch = ap.payouts.create(
[
{"recipientAddress": "0xabc…", "chain": "MATIC", "amount": {"amount": "25.00", "currency": "USDT"}, "memo": "June payout"},
# …more recipients in the same batch
],
idempotency_key="payout_2026_07_03_001",
)
print(batch["merchantBatchNo"], batch["status"]) # "payout_2026_07_03_001", "PENDING"
```
```go
batch, err := ap.Payouts.Create(ctx,
[]absolutepay.PayoutItem{
{RecipientAddress: "0xabc…", Chain: "MATIC", Amount: absolutepay.Money{Amount: "25.00", Currency: "USDT"}, Memo: "June payout"},
// …more recipients in the same batch
},
absolutepay.WithIdempotencyKey("payout_2026_07_03_001"),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(batch["merchantBatchNo"], batch["status"]) // "payout_2026_07_03_001", "PENDING"
```
```bash
BODY='{"items":[{"recipientAddress":"0xabc…","chain":"MATIC","amount":{"amount":"25.00","currency":"USDT"},"memo":"June payout"}]}'
curl https://api.absolutepay.io/v1/payouts \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: payout_2026_07_03_001" \
$(sign POST /v1/payouts "$BODY") -d "$BODY"
```
```json
{ "merchantBatchNo": "payout_2026_07_03_001", "status": "PENDING", "subOrders": [ { "recipientAddress": "0xabc…", "status": "PENDING" } ] }
```
Network retries happen. Send a unique key per batch so a retried request never pays twice — the same
key returns the **original** batch instead of creating a new one. Generate it once per batch and
reuse it on every retry. Keep keys to letters, digits, and underscores, and short (they're
normalized to `[A-Za-z0-9_]` and truncated, so two long keys that only differ near the end could
collide).
Batches accrue as `PENDING` and are submitted on-chain asynchronously; depending on your
workspace's review status they can be held before dispatch. An unusual burst of payout batches in a
short window automatically freezes the account for review (`429 velocity_frozen`). Design around the
webhook and status poll, never around the create response.
## 3. Track settlement
A batch moves `PENDING → SUBMITTED → PROCESSING → SUCCESS | PARTIAL | FAILED`, and each recipient
settles independently. Watch the webhooks — `payout.settled` (all succeeded), `payout.partial` (some
failed), `payout.failed` (batch failed) — and poll the batch by its `merchantBatchNo` as backup:
```ts
const batch = await ap.payouts.get("payout_2026_07_03_001");
for (const s of batch.subOrders) console.log(s.recipientAddress, s.status);
```
```python
batch = ap.payouts.get("payout_2026_07_03_001")
for s in batch["subOrders"]:
print(s["recipientAddress"], s["status"])
```
```go
batch, err := ap.Payouts.Get(ctx, "payout_2026_07_03_001")
if err != nil {
log.Fatal(err)
}
for _, s := range batch["subOrders"].([]any) {
sub := s.(map[string]any)
fmt.Println(sub["recipientAddress"], sub["status"])
}
```
```bash
curl "https://api.absolutepay.io/v1/payouts/payout_2026_07_03_001" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/payouts/payout_2026_07_03_001 "")
```
On `PARTIAL`, reconcile per suborder — each carries its own `status`, `txid`, and delivered
amount/fee.
## Gotchas
- **Payouts are irreversible.** Once a suborder is on-chain there is no recall and no chargeback —
validate addresses before you send.
- **Balance** — the batch draws from `available` balance; check it first ([Balances](/balances-reconciliation)).
- **Memo chains** — some networks require a memo/tag; `memo` is optional, max 100 chars.
- **Fees** — the network fee from `options()` (`withdrawFee` fixed, plus `withdrawFeePercent` × amount — `0%` on most chains) is charged per suborder on top of the amount.
- **Sandbox** — on `https://sandbox-api.absolutepay.io` no real funds move; batches auto-settle
after a short delay and fire the same `payout.*` webhooks, so you can test the full loop.
Full request/response fields are on `POST /v1/payouts` in the [API reference](/api).
## Next
- [Off-ramp](/offramp) — pay out to a **bank account** (crypto → fiat).
- [Webhooks](/webhooks) — verify and handle `payout.*` events.
- [Balances & reconciliation](/balances-reconciliation) — pre-check funds, reconcile suborders.
- [API reference](/api) · [Authentication](/authentication)
---
# Subscriptions
URL: /subscriptions
Bill a customer on a schedule. The model is **plan → subscription → recurring deductions**: define a
plan once, subscribe any number of customers to it, and each billing cycle produces a **deduction**
that fires the same `payment.succeeded` [webhook](/webhooks) as a one-off charge.
Creating plans and subscriptions needs the `subscriptions:write` [scope](/authentication); reads need
`subscriptions:read`.
## 1. Create a plan
A plan is the reusable template: the per-cycle amount, the billing interval, and how many cycles to
bill in total.
```ts
const plan = await ap.subscriptions.createPlan({
merchantPlanNo: "pro-monthly", // your unique plan reference
name: "Pro (monthly)",
amount: { amount: "9.99", currency: "USDT" }, // decimal string, ≤ 6 dp
interval: "MONTH", // DAY | WEEK | MONTH | YEAR
intervalCount: 1, // e.g. MONTH × 3 = quarterly
totalCycles: 12, // total charges before the plan completes
// trialDays: 14, // optional free trial: no charge for N days after the customer authorizes
});
console.log(plan.planNo);
```
```python
plan = ap.subscriptions.create_plan(
merchant_plan_no="pro-monthly", # your unique plan reference
name="Pro (monthly)",
amount={"amount": "9.99", "currency": "USDT"}, # decimal string, ≤ 6 dp
interval="MONTH", # DAY | WEEK | MONTH | YEAR
interval_count=1, # e.g. MONTH × 3 = quarterly
total_cycles=12, # total charges before the plan completes
# trial_days=14, # optional free trial: no charge for N days after the customer authorizes
)
print(plan["planNo"])
```
```go
plan, err := ap.Subscriptions.CreatePlan(ctx, absolutepay.PlanParams{
MerchantPlanNo: "pro-monthly", // your unique plan reference
Name: "Pro (monthly)",
Amount: absolutepay.Money{Amount: "9.99", Currency: "USDT"}, // decimal string, ≤ 6 dp
Interval: "MONTH", // DAY | WEEK | MONTH | YEAR
IntervalCount: 1, // e.g. MONTH × 3 = quarterly
TotalCycles: 12, // total charges before the plan completes
// TrialDays: 14, // optional free trial: no charge for N days after the customer authorizes
})
if err != nil {
log.Fatal(err)
}
fmt.Println(plan["planNo"])
```
```bash
BODY='{"merchantPlanNo":"pro-monthly","name":"Pro (monthly)","amount":{"amount":"9.99","currency":"USDT"},"interval":"MONTH","intervalCount":1,"totalCycles":12}'
curl https://api.absolutepay.io/v1/subscription-plans \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/subscription-plans "$BODY") -d "$BODY"
```
```json
{ "planNo": "plan_01JX…", "merchantPlanNo": "pro-monthly", "name": "Pro (monthly)", "amount": { "amount": "9.99", "currency": "USDT" }, "interval": "MONTH", "intervalCount": 1, "totalCycles": 12 }
```
Keep the returned `planNo` — it's what you pass when subscribing a customer. `merchantPlanNo` must be
unique per workspace (a duplicate is a `409`). List existing plans with `ap.subscriptions.listPlans()`
/ `GET /v1/subscription-plans`.
**Free trial.** Set `trialDays` on the plan (0–365) to give every subscriber a trial: the customer
authorizes but isn't charged until the trial ends, then billing begins automatically. A subscription
in its trial reports the `TRIALING` status. Omit `trialDays` (or `0`) to bill immediately on
authorization.
## 2. Subscribe a customer
```ts
const sub = await ap.subscriptions.create({
merchantSubNo: "sub-1001", // your reference + idempotency key
planNo: plan.planNo,
// callbackUrl: "https://…", // optional per-subscription notification URL
});
console.log(sub.subscribeUrl); // send the customer here to authorize
```
```python
sub = ap.subscriptions.create(
merchant_sub_no="sub-1001", # your reference + idempotency key
plan_no=plan["planNo"],
# callback_url="https://…", # optional per-subscription notification URL
)
print(sub["subscribeUrl"]) # send the customer here to authorize
```
```go
sub, err := ap.Subscriptions.Create(ctx, absolutepay.SubscribeParams{
MerchantSubNo: "sub-1001", // your reference + idempotency key
PlanNo: plan["planNo"].(string),
// CallbackURL: "https://…", // optional per-subscription notification URL
})
if err != nil {
log.Fatal(err)
}
fmt.Println(sub["subscribeUrl"]) // send the customer here to authorize
```
```bash
BODY='{"merchantSubNo":"sub-1001","planNo":"plan_01JX…"}'
curl https://api.absolutepay.io/v1/subscriptions \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/subscriptions "$BODY") -d "$BODY"
```
```json
{ "merchantSubNo": "sub-1001", "subId": "…", "status": "PENDING", "planNo": "plan_01JX…", "subscribeUrl": "https://…" }
```
The subscription starts `PENDING`. Send the customer to the hosted `subscribeUrl` to authorize; once
they do, it goes `ACTIVE` and bills automatically each cycle (or `TRIALING` first, if the plan has a
trial). `merchantSubNo` is your idempotency key — re-POSTing the same one won't create a duplicate
subscription.
A subscription created with an API key carries an `appId` — the id of the key that created it — so a
workspace running several integrations can attribute each subscription to the right one. Subscriptions
created from the dashboard have no `appId`.
## 3. Track the cycles
Each successful cycle fires a `payment.succeeded` [webhook](/webhooks) — handle it exactly like a
one-off payment (idempotent on `event.id`). To reconcile or backfill, pull the per-cycle history:
```ts
const history = await ap.subscriptions.deductions("sub-1001");
// each deduction: paymentOrderNo, amount, status (SUCCESS | FAILED | PENDING | BLOCKED), ts
```
```python
history = ap.subscriptions.deductions("sub-1001")
# each deduction: paymentOrderNo, amount, status (SUCCESS | FAILED | PENDING | BLOCKED), ts
```
```go
history, err := ap.Subscriptions.Deductions(ctx, "sub-1001")
if err != nil {
log.Fatal(err)
}
fmt.Println(history) // paymentOrderNo, amount, status (SUCCESS | FAILED | PENDING | BLOCKED), ts
```
```bash
curl "https://api.absolutepay.io/v1/subscriptions/sub-1001/deductions" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/subscriptions/sub-1001/deductions "")
```
List all subscriptions with `ap.subscriptions.list()` / `GET /v1/subscriptions` — keyset-paginated
(`limit`, `before`, optional `status` filter; see [Pagination](/pagination)).
## 4. Cancel
```ts
await ap.subscriptions.cancel("sub-1001"); // stop all future billing
```
```python
ap.subscriptions.cancel("sub-1001") # stop all future billing
```
```go
_, err := ap.Subscriptions.Cancel(ctx, "sub-1001") // stop all future billing
if err != nil {
log.Fatal(err)
}
```
```bash
curl -X POST https://api.absolutepay.io/v1/subscriptions/sub-1001/cancel \
-H "Authorization: Bearer $APP_TOKEN" $(sign POST /v1/subscriptions/sub-1001/cancel "")
```
Cancelling stops future cycles only — settled deductions are crypto payments and can't be clawed
back. To return one, issue a [refund](/refunds).
`PENDING` — created, waiting for the customer to authorize on `subscribeUrl`. `TRIALING` — authorized
and in its free trial; billing hasn't started yet. `ACTIVE` — billing each cycle. `PAST_DUE` — a
cycle's charge failed. `COMPLETED` — all `totalCycles` billed. `CANCELLED` / `BLOCKED` — no further billing.
## Next
- [Webhooks](/webhooks) — receive each cycle's `payment.succeeded` reliably.
- [Fees](/fees) — how platform fees are disclosed. Subscription cycle fees are settled per cycle and aren't priced by the preview endpoint.
- [Refunds](/refunds) — return a settled cycle's charge.
- [API reference](/api) — full parameter and response tables for every subscription endpoint.
---
# Payment links & invoices
URL: /payment-links
A **payment link** mints an unguessable token and a hosted page at `/pay/` where your customer
pays. There are two symmetric resources — pick by whether the payer chooses the network or you fix it:
| Resource | Create with | Use it for |
| --- | --- | --- |
| **checkout** | `POST /v1/checkouts` (`ap.checkouts.create`) | A shareable hosted link — the payer chooses which token/chain to pay with. |
| **invoice** | `POST /v1/invoices` (`ap.invoices.create`, **`chain` required**) | Billing an amount on a specific `chain` — mints the deposit address up front. |
Both settle the same way and fire the same `payment.succeeded` [webhook](/webhooks). Each is its own
resource with the **same** `create` / `list` / `get` / `update` / `del` methods — the examples below
use `checkouts`; swap in `ap.invoices.*` (on `/v1/invoices`) for the address flow.
**Before you start:** creating, updating, and voiding need the `invoices:write` scope; listing and
reading need `invoices:read` ([Authentication](/authentication)).
## Create a hosted checkout link
Returns a hosted URL to send the customer to; they pick the asset on the page.
```ts
const link = await ap.checkouts.create({
reference: "order-123",
amount: { amount: "25.00", currency: "USDT" },
});
console.log(link.token, link.checkoutUrl);
```
```python
link = ap.checkouts.create(
reference="order-123",
amount={"amount": "25.00", "currency": "USDT"},
)
print(link["token"], link["checkoutUrl"])
```
```go
link, err := ap.Checkouts.Create(ctx, absolutepay.CheckoutParams{
Reference: "order-123",
Amount: absolutepay.Money{Amount: "25.00", Currency: "USDT"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(link.Token, link.CheckoutURL)
```
```bash
BODY='{"reference":"order-123","amount":{"amount":"25.00","currency":"USDT"}}'
curl https://api.absolutepay.io/v1/checkouts \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/checkouts "$BODY") -d "$BODY"
```
The `reference` is your reconciliation handle — keep it unique per order. Amounts are decimal strings
(`"25.00"`), ≤ 6 fraction digits.
## Create an invoice (mint a deposit address up front)
An invoice always mints the on-chain deposit address up front for the `chain` you pass (required), and
the payer sends funds directly to it. If you'd rather let the payer pick the network on the hosted page,
create a [checkout link](#create-a-hosted-checkout-link) instead.
```ts
const invoice = await ap.invoices.create({
reference: "order-123",
amount: { amount: "25.00", currency: "USDT" },
chain: "MATIC", // mint the deposit address now
});
console.log(invoice.token, invoice.address);
```
```python
invoice = ap.invoices.create(
reference="order-123",
amount={"amount": "25.00", "currency": "USDT"},
chain="MATIC", # mint the deposit address now
)
print(invoice["token"], invoice["address"])
```
```go
invoice, err := ap.Invoices.Create(ctx, absolutepay.InvoiceParams{
Reference: "order-123",
Amount: absolutepay.Money{Amount: "25.00", Currency: "USDT"},
Chain: "MATIC", // mint the deposit address now
})
if err != nil {
log.Fatal(err)
}
fmt.Println(invoice.Token, invoice.Address)
```
```bash
BODY='{"reference":"order-123","amount":{"amount":"25.00","currency":"USDT"},"chain":"MATIC"}'
curl https://api.absolutepay.io/v1/invoices \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/invoices "$BODY") -d "$BODY"
```
## Redirect the payer back after payment
Set an optional `redirectUrl` on create (works for both checkout links and invoices). Once the hosted
page reaches a terminal state it sends the payer back to that URL with the outcome as query params —
so your success page knows which order completed and how it ended.
```ts
const link = await ap.checkouts.create({
reference: "order-123",
amount: { amount: "25.00", currency: "USDT" },
redirectUrl: "https://yourstore.com/thank-you",
});
```
```python
link = ap.checkouts.create(
reference="order-123",
amount={"amount": "25.00", "currency": "USDT"},
redirect_url="https://yourstore.com/thank-you",
)
```
```go
link, err := ap.Checkouts.Create(ctx, absolutepay.CheckoutParams{
Reference: "order-123",
Amount: absolutepay.Money{Amount: "25.00", Currency: "USDT"},
RedirectURL: "https://yourstore.com/thank-you",
})
```
```bash
BODY='{"reference":"order-123","amount":{"amount":"25.00","currency":"USDT"},"redirectUrl":"https://yourstore.com/thank-you"}'
curl https://api.absolutepay.io/v1/checkouts \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/checkouts "$BODY") -d "$BODY"
```
After the payment settles, the payer lands on `https://yourstore.com/thank-you?token=&status=SUCCESS`.
The `token` is the link token (the same id in `/pay/`); `status` is the final outcome:
| Link state | `status` |
| --- | --- |
| `PAID` | `SUCCESS` |
| `EXPIRED` | `EXPIRED` |
| `VOID` | `CANCELED` |
Any query string you already put on `redirectUrl` is preserved (`?ref=42` → `?ref=42&token=…&status=SUCCESS`).
The redirect is a browser navigation — treat `token`/`status` as **untrusted display hints**, never fulfill
an order off them alone. Confirm the real outcome server-side via the `payment.succeeded` [webhook](/webhooks)
(primary) or `ap.checkouts.get("")`. `redirectUrl` must be an `http(s)` URL.
## List, get, update & void
Every list returns `{ items, nextCursor }` — page by echoing `nextCursor` back as `before`
(see [Pagination](/pagination)). Filter by `status` (`OPEN` \| `PAID` \| `EXPIRED` \| `VOID`) and sort
with `order` (`asc` \| `desc`); `?status=PAID` is your settled pay-ins.
```ts
// List (newest first) and page:
const { items, nextCursor } = await ap.checkouts.list({ status: "OPEN", order: "desc" });
// next page: ap.checkouts.list({ status: "OPEN", before: nextCursor })
// Read one:
const link = await ap.checkouts.get("");
// Pause / resume (reversible):
await ap.checkouts.update("", { paused: true }); // stop accepting payment
await ap.checkouts.update("", { paused: false }); // resume
// Void (terminal — cannot be paid):
await ap.checkouts.del("");
```
```python
# List (newest first) and page:
result = ap.checkouts.list(status="OPEN", order="desc")
items, next_cursor = result["items"], result["nextCursor"]
# next page: ap.checkouts.list(status="OPEN", before=next_cursor)
# Read one:
link = ap.checkouts.get("")
# Pause / resume (reversible):
ap.checkouts.update("", paused=True) # stop accepting payment
ap.checkouts.update("", paused=False) # resume
# Void (terminal — cannot be paid):
ap.checkouts.delete("")
```
```go
// List (newest first) and page:
page, err := ap.Checkouts.List(ctx, absolutepay.PageQuery{Status: "OPEN", Order: "desc"})
if err != nil {
log.Fatal(err)
}
fmt.Println(page.Items, page.NextCursor)
// Read one:
link, err := ap.Checkouts.Get(ctx, "")
// Pause / resume (reversible):
_, err = ap.Checkouts.Update(ctx, "", absolutepay.CheckoutPatch{Paused: absolutepay.Bool(true)})
// Void (terminal — cannot be paid):
_, err = ap.Checkouts.Delete(ctx, "")
```
```bash
# List + filter + sort:
curl "https://api.absolutepay.io/v1/checkouts?status=OPEN&order=desc&limit=50" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/checkouts?status=OPEN&order=desc&limit=50" "")
# Get one:
curl "https://api.absolutepay.io/v1/checkouts/" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/checkouts/" "")
# Pause (PATCH):
BODY='{"paused":true}'
curl -X PATCH https://api.absolutepay.io/v1/checkouts/ \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign PATCH /v1/checkouts/ "$BODY") -d "$BODY"
# Void (DELETE — terminal):
curl -X DELETE https://api.absolutepay.io/v1/checkouts/ \
-H "Authorization: Bearer $APP_TOKEN" $(sign DELETE /v1/checkouts/ "")
```
`update` patches a link in place — `paused` toggles whether it accepts payment (reversible), and you
can also revise `redirectUrl`, `expiresAt`, or `description` (pass `null` to clear a field). `del`
**voids** the link: it's terminal and can never be paid. The same methods exist on `ap.invoices.*`.
## Confirm settlement
Don't fulfill until a link reaches `PAID`. Use the `payment.succeeded` [webhook](/webhooks) as the
primary signal; as a fallback, read the link back with `ap.checkouts.get("")` (or
`ap.invoices.get`) and check `status === "PAID"`.
## Gotchas
- **The token IS the credential.** Anyone with the link can view and pay it — share the URL only with
the intended payer, and void links you no longer want payable.
- **Send the exact quoted amount** to the deposit address. The quote is live; the invoice response
includes the precise amount the payer must transfer.
- **Amounts are decimal strings** (`"25.00"`), positive, ≤ 6 fraction digits.
- **In the sandbox**, links don't settle on their own — trigger settlement yourself ("Simulate
payment" in the dashboard); the same `payment.succeeded` webhook fires.
## Next
- [Accept payments](/accept-payments) — the hosted checkout flow for in-app orders.
- [Receiving deposits](/deposits) — permanent top-up addresses for your own workspace balance.
- [Webhooks](/webhooks) — fulfill on `payment.succeeded`.
- [API reference](/api) — every field on checkouts + invoices.
---
# Introduction
URL: /
AbsolutePay is a crypto payments API. With one integration you can **accept payments** from
your customers, **send payouts** to anyone, and **reconcile** every movement — settled in
stablecoins, on the chains you choose.
These docs are for developers integrating an existing AbsolutePay **account**. If you don't
have one yet, sign up in the [dashboard](https://app.absolutepay.io) first.
## Pick your integration path
Three ways in — all hit the same API, so you can mix them freely.
### 1. Agent skill — fastest with an AI coding agent
If you build with Claude Code, Cursor, or any agent that supports skills, install the official
[AbsolutePay skill](https://github.com/AbsolutePay/absolutepay-skill):
```bash
npx skills add AbsolutePay/absolutepay-skill
```
It teaches your coding agent to integrate AbsolutePay end to end — via the SDKs or raw REST —
with the guardrails (request signing, webhook verification, idempotency) built in. Tell your
agent "add AbsolutePay checkout to this app" and it knows what to do.
`npx skills add` installs the skill itself. To also pull the bundled reference files, run
`npx github:AbsolutePay/absolutepay-skill`.
### 2. Official SDK — Node, Python, or Go
The SDKs sign every request for you, raise typed errors, and handle pagination:
```bash
npm install absolutepay
```
```bash
pip install absolutepay
```
```bash
go get github.com/AbsolutePay/absolutepay-go
```
See [SDK setup](/sdk) for configuration and your first call. Every guide in these docs shows
Node, Python, Go, and `curl` side by side.
### 3. REST API — any language
Plain REST over HTTPS, JSON in and out. The full interactive reference lives at
[/api](/api). Requests from non-SDK languages are HMAC-signed — the
[Authentication](/authentication) guide shows the canonical string and a signing helper you
can port to any language.
## How it fits together
- **Account & workspace** — you sign up, create a workspace, and pass verification (KYC/KYB)
in the dashboard. A verified workspace is provisioned with its own settlement account.
- **API keys** — each workspace issues API keys (`ap_test_…` and `ap_live_…`). Every API
call authenticates with a key; the key determines the workspace and whether you're in
**test** (sandbox) or **live** mode. See [Authentication](/authentication).
- **The API** — REST over HTTPS. Base URLs:
```
Production https://api.absolutepay.io
Sandbox https://sandbox-api.absolutepay.io
```
- **Webhooks** — the platform tells you the moment anything reaches a terminal state:
`payment.succeeded` / `payment.failed`, `charge.refunded`, and
`payout.settled` / `payout.partial` / `payout.failed`. Verify each delivery on the **raw
body** and act idempotently on `event.id`. See [Webhooks](/webhooks).
## What you can do
Create a checkout your customer pays. Funds settle to your workspace.
Shareable, reusable payment pages — no code on the paying side.
Give each customer a deposit address and credit what arrives.
Return funds from a settled payment, in full or partially.
Pay out to any address on a supported chain, in one batch call.
Convert between supported assets inside your workspace.
Convert crypto balances to fiat and settle to a bank account.
Recurring crypto billing — plans, subscribers, renewal webhooks.
Issue and redeem branded gift cards backed by your balance.
What each operation costs and how to read the fee fields.
Read balances and the settled record of every pay-in and payout.
Get notified the moment a payment or payout reaches a terminal state.
Walk any list endpoint (the SDKs handle this for you).
The error shape, retry strategy, and what each code means.
The checklist to flip from test keys to live traffic.
## Build in the sandbox first
Two ways to test without moving real funds — both mock-backed, both fire the same webhooks as production:
- **Test mode on production** — use an `ap_test_` key against `https://api.absolutepay.io`. Same base
URL and request shapes as live; only the key prefix changes.
- **The dedicated sandbox** — sign up at [sandbox.absolutepay.io](https://sandbox.absolutepay.io) and
point your calls at `https://sandbox-api.absolutepay.io`. A fully isolated playground where **apps are
auto-approved** (no review wait) and you trigger settlement yourself.
### Next steps
Node, Python, or Go — configured and making signed calls in minutes.
Create your first payment end to end, in test mode.
`npx skills add AbsolutePay/absolutepay-skill` — let your coding agent do the integration.
## Reference
- **[API reference](/api)** — every endpoint, parameter, and response, interactive.
- **SDK references** (generated per symbol):
[Node.js](https://absolutepay.github.io/absolutepay-js) ·
[Python](https://absolutepay.github.io/absolutepay-python) ·
[Go](https://pkg.go.dev/github.com/AbsolutePay/absolutepay-go)
---
# Webhooks
URL: /webhooks
Webhooks are how you learn — reliably and in real time — that something settled. Set a webhook URL on
an [app](/authentication#apps), verify each delivery, and act idempotently.
## Set a webhook URL
Each app can have one webhook URL (Developers → Apps, or `PATCH /v1/apps/:id`). When you set it, the
app gets a `whsec_…` **webhook signing secret** (shown once — store it with your other secrets) that
signs every delivery. We POST a JSON event to that URL on each relevant lifecycle change.
Webhooks deliver only to **approved** apps. Editing app details — including the webhook URL —
resubmits the app for review, and it can't authenticate or receive deliveries until re-approved
(rotating the token does **not** trigger re-review). In the sandbox, apps auto-approve. Plan your
production cutover accordingly — see [Going live](/going-live).
### Which app receives a payment callback
If a checkout/invoice was **created via an app's API key**, its `payment.succeeded` callback is delivered
to **that app only** — not fanned out to your other apps. Checkouts created from the **dashboard** (no
creating app) fan out to every approved app on the workspace, as before. So an app that creates a payment
gets its own callback and won't see payments created by a sibling app. (The hosted checkout page also shows
`via ` when created through an app.)
## Events
| Event | Fires when |
| --- | --- |
| `payment.succeeded` | A payment (checkout or invoice) settled. |
| `payment.failed` | A payment expired, closed, or errored. |
| `charge.refunded` | A refund completed. |
| `payout.settled` | A payout settled on-chain. |
| `payout.partial` | A payout batch settled partially. |
| `payout.failed` | A payout failed. |
| `account.activated` | An account became able to transact. Sent once per account; resellers receive it for each sub-merchant they create. |
Every delivery is a JSON object:
```json
{
"id": "evt_1f3c9a8b2d4e6f7081a2b3c4",
"type": "payment.succeeded",
"data": {
"id": "evt_1f3c9a8b2d4e6f7081a2b3c4",
"createdAt": "2026-06-18T01:35:42.697Z",
"orderRef": "order-1024",
"invoiceId": "inv-9f3a2b-c4d5",
"status": "PAY_SUCCESS",
"amount": "0.500000",
"currency": "USDT",
"txHash": "0xd612",
"chain": "MATIC"
}
}
```
`data.orderRef` is the identifier you already know — the `reference` you set on the checkout/invoice
(`merchantTradeNo`) for payments, `merchantBatchNo` for payouts — so you correlate the event straight
back to your order. For pay-ins, `data.invoiceId` additionally carries our internal payment id (present
only on payments). The top-level `id` is unique per event: **key your processing on `event.id`** so
retries never double-credit.
## Verify every delivery
Each request carries `X-AbsolutePay-Timestamp` (epoch milliseconds) and `X-AbsolutePay-Signature`
(hex). The signature is **HMAC-SHA512** over `` `${timestamp}.${rawBody}` `` using the app's `whsec_…`
secret. **Verify the raw body before trusting it** — the SDKs' `constructEvent` / `construct_event` /
`ConstructEvent` does the verification, freshness check, and parse in one call.
```ts
import { constructEvent } from "absolutepay";
// Verifies the signature AND enforces a freshness window (default 5 min); throws on failure.
const event = constructEvent(rawBody, req.headers, process.env.ABSOLUTEPAY_WEBHOOK_SECRET!);
if (event.type === "payment.succeeded") {
// fulfill — event.data.orderRef, event.data.amount, …
}
```
```python
from absolutepay import construct_event
# Verifies the signature AND enforces a freshness window (default 5 min); raises on failure.
event = construct_event(raw_body, request.headers, os.environ["ABSOLUTEPAY_WEBHOOK_SECRET"])
if event["type"] == "payment.succeeded":
... # fulfill — event["data"]["orderRef"], event["data"]["amount"], …
```
```go
// Verifies the signature AND enforces a freshness window (default 5 min); errors on failure.
event, err := absolutepay.ConstructEvent(rawBody, r.Header, os.Getenv("ABSOLUTEPAY_WEBHOOK_SECRET"))
if err != nil {
// reject with 400
}
if event.Type == "payment.succeeded" {
// fulfill — json.Unmarshal(event.Data, &yourType)
}
```
```ts
import crypto from "node:crypto";
function verify(rawBody: string, timestamp: string, signature: string, secret: string): boolean {
const expected = crypto.createHmac("sha512", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// verify(rawBody, headers["x-absolutepay-timestamp"], headers["x-absolutepay-signature"], secret)
```
Verify the exact bytes you received, before any JSON parse or re-serialization — reformatting changes
the bytes and breaks verification. Reject deliveries whose `timestamp` is more than a few minutes old
to stop replays. The SDK helpers enforce a 5-minute window by default; opt out with `toleranceMs: 0`
(Node), `tolerance_ms=0` (Python), or `absolutepay.WithTolerance(0)` (Go) — e.g. when replaying
stored events in tests.
## A complete handler
```ts
import express from "express";
import { constructEvent } from "absolutepay";
const app = express();
// Use the RAW body — not express.json() — so the bytes match the signature.
app.post("/webhooks/absolutepay", express.raw({ type: "application/json" }), async (req, res) => {
// 1. Verify (signature + freshness) on the raw bytes.
let event;
try {
event = constructEvent(req.body.toString("utf8"), req.headers, process.env.ABSOLUTEPAY_WEBHOOK_SECRET!);
} catch {
return res.status(400).send("bad signature");
}
// 2. De-dupe on event.id — deliveries are at-least-once. In production, back this
// with a unique index (INSERT … ON CONFLICT DO NOTHING) instead of memory.
if (!(await markProcessedOnce(event.id))) {
return res.status(200).json({ ok: true, duplicate: true });
}
// 3. Ack fast, then do heavy work async (a job queue beats inline work here).
res.status(200).json({ ok: true });
switch (event.type) {
case "payment.succeeded": /* fulfill order event.data.orderRef */ break;
case "payment.failed": /* release cart / notify customer */ break;
case "charge.refunded": /* mark refunded */ break;
case "payout.settled": /* mark batch paid */ break;
case "payout.partial": /* reconcile per-item results */ break;
case "payout.failed": /* alert + retry payout flow */ break;
}
});
```
```python
import os
from flask import Flask, request
from absolutepay import construct_event, WebhookSignatureError
app = Flask(__name__)
@app.post("/webhooks/absolutepay")
def absolutepay_webhook():
# 1. Verify (signature + freshness) on the RAW body (request.get_data()).
try:
event = construct_event(request.get_data(), dict(request.headers), os.environ["ABSOLUTEPAY_WEBHOOK_SECRET"])
except WebhookSignatureError:
return "bad signature", 400
# 2. De-dupe on event["id"] — deliveries are at-least-once. In production, back
# this with a unique index (INSERT … ON CONFLICT DO NOTHING) instead of memory.
if not mark_processed_once(event["id"]):
return {"ok": True, "duplicate": True}, 200
# 3. Handle. Return 200 fast — push heavy work to a job queue.
kind = event["type"]
if kind == "payment.succeeded":
... # fulfill order event["data"]["orderRef"]
elif kind == "payment.failed":
... # release cart / notify customer
elif kind == "charge.refunded":
... # mark refunded
elif kind in ("payout.settled", "payout.partial", "payout.failed"):
... # reconcile the batch — event["data"]["orderRef"] is your merchantBatchNo
return {"ok": True}, 200
```
```go
package main
import (
"io"
"net/http"
"os"
absolutepay "github.com/AbsolutePay/absolutepay-go"
)
func main() {
secret := os.Getenv("ABSOLUTEPAY_WEBHOOK_SECRET")
http.HandleFunc("/webhooks/absolutepay", func(w http.ResponseWriter, r *http.Request) {
// 1. Verify (signature + freshness) on the RAW bytes — do not re-serialize.
raw, _ := io.ReadAll(r.Body)
event, err := absolutepay.ConstructEvent(raw, r.Header, secret)
if err != nil { // errors.Is(err, absolutepay.ErrInvalidSignature)
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
// 2. De-dupe on event.ID — deliveries are at-least-once. In production, back
// this with a unique index (INSERT … ON CONFLICT DO NOTHING) instead of memory.
if !markProcessedOnce(event.ID) {
w.WriteHeader(http.StatusOK)
return
}
// 3. Ack fast, then do heavy work async (event.Data is json.RawMessage — unmarshal per type).
w.WriteHeader(http.StatusOK)
switch event.Type {
case "payment.succeeded": // fulfill order — json.Unmarshal(event.Data, &…)
case "payment.failed": // release cart / notify customer
case "charge.refunded": // mark refunded
case "payout.settled", "payout.partial", "payout.failed": // reconcile the batch
}
})
http.ListenAndServe(":4242", nil)
}
```
## Be idempotent, respond fast, don't rely on webhooks alone
- **Idempotent** — the same event may arrive more than once (at-least-once delivery). Key on
`event.id` and ignore duplicates.
- **Respond fast** — return `2xx` quickly and do heavy work asynchronously. A non-`2xx` or timeout is
treated as a failed delivery and retried with backoff.
- **Poll as a fallback** — receivers go down. Treat webhooks as the fast path and poll the order/batch
as backup (see [Accept payments](/accept-payments) and [Payouts](/payouts)).
## Delivery records & local testing
- **Delivery log** — open an app under **Developers → Apps → Deliveries** to see event id, type, URL,
status, HTTP code, and attempt count. Failed deliveries are retried.
- **Request/response bodies** — expand a delivery to see the exact signed payload we POSTed and your
server's response (captured on success *and* failure, so you can debug a rejected delivery; the response
is capped at 16KB). Reflects the latest attempt. Also available via `GET /v1/apps/:id/webhooks/:deliveryId`.
- **Test locally** — expose your local receiver with a tunnel (e.g. `ngrok http 4455`) and set that
URL as the app's webhook URL in the **sandbox**, then trigger a test payment. See [Going live](/going-live).
## Next
- [Accept payments](/accept-payments) — the flow `payment.succeeded` / `payment.failed` confirm.
- [Payouts](/payouts) — `payout.settled` / `payout.partial` / `payout.failed` in context.
- [Going live](/going-live) — test webhooks end to end before launch.
- [Errors](/errors) and the [API reference](/api) — retries, idempotency, and every endpoint.
---
# Off-ramp
URL: /offramp
Off-ramp pays out to a **bank account** in fiat instead of to a crypto address. The flow: **pick a
country → register a bank account (once, reviewed) → quote → withdraw → track**. Settlement is
asynchronous and reports via the same `payout.*` [webhooks](/webhooks).
**Before you start:** the key needs `payouts:write` (reads need `payouts:read`); off-ramp is a fiat
payout, so it shares the payout scopes. There's a **minimum of ~1,000 USDT-equivalent** per order
(the quote enforces it), and the rail is enabled per workspace — if these endpoints return `404`,
it isn't enabled on your account yet; contact support.
## 1. Countries & your banks
```ts
const countries = await ap.offramp.countries(); // [{ id: 1, name: "United States" }, …]
const banks = await ap.offramp.banks(); // your registered banks + review status
```
```python
countries = ap.offramp.countries() # [{ "id": 1, "name": "United States" }, …]
banks = ap.offramp.banks() # your registered banks + review status
```
```go
countries, err := ap.OffRamp.Countries(ctx) // [{ id: 1, name: "United States" }, …]
if err != nil {
log.Fatal(err)
}
banks, err := ap.OffRamp.Banks(ctx) // your registered banks + review status
if err != nil {
log.Fatal(err)
}
fmt.Println(countries, banks)
```
```bash
curl "https://api.absolutepay.io/v1/offramp/countries" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/offramp/countries "")
curl "https://api.absolutepay.io/v1/offramp/banks" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/offramp/banks "")
```
Note the **numeric** country `id` — you pass it as `countryId` when registering a bank.
## 2. Register a bank account
Off-ramp needs an **approved** bank account as the destination. Register one once with a
bank-statement **image** (jpg/png, ≤4MB, dated within 3 months — PDF is not accepted here), then
wait for review. Files ride as `{ filename, contentType, dataBase64 }` (base64, no data-URL prefix).
```ts
const bank = await ap.offramp.registerBank({
bankAccountName: "Acme Inc",
bankName: "Bank of America",
countryId: 1, // numeric id from countries()
iban: "US64SVBKUS6S3300958879",
swift: "SVBKUS6S",
file: { filename: "statement.jpg", contentType: "image/jpeg", dataBase64: "…" },
});
// { bankAccountId, status: "pending" | "materials" | "approved" | "rejected", … }
// If review asks for more ("materials"), upload certificate + passport docs (PDF allowed here):
if (bank.status === "materials") {
await ap.offramp.submitBankMaterials(bank.bankAccountId as string, {
certificate: [{ filename: "license.pdf", contentType: "application/pdf", dataBase64: "…" }],
passport: [{ filename: "id.jpg", contentType: "image/jpeg", dataBase64: "…" }],
});
}
// Remove one you no longer need:
await ap.offramp.deleteBank("bank_123");
```
```python
bank = ap.offramp.register_bank(
bank_account_name="Acme Inc",
bank_name="Bank of America",
country_id=1, # numeric id from countries()
iban="US64SVBKUS6S3300958879",
swift="SVBKUS6S",
file={"filename": "statement.jpg", "contentType": "image/jpeg", "dataBase64": "…"},
)
# { bankAccountId, status: "pending" | "materials" | "approved" | "rejected", … }
# If review asks for more ("materials"), upload certificate + passport docs (PDF allowed here):
if bank["status"] == "materials":
ap.offramp.submit_bank_materials(
bank["bankAccountId"],
certificate=[{"filename": "license.pdf", "contentType": "application/pdf", "dataBase64": "…"}],
passport=[{"filename": "id.jpg", "contentType": "image/jpeg", "dataBase64": "…"}],
)
# Remove one you no longer need:
ap.offramp.delete_bank("bank_123")
```
```go
bank, err := ap.OffRamp.RegisterBank(ctx, absolutepay.BankParams{
BankAccountName: "Acme Inc",
BankName: "Bank of America",
CountryID: 1, // numeric id from Countries()
IBAN: "US64SVBKUS6S3300958879",
Swift: "SVBKUS6S",
File: absolutepay.DocFile{Filename: "statement.jpg", ContentType: "image/jpeg", DataBase64: "…"},
})
if err != nil {
log.Fatal(err)
}
// { bankAccountId, status: "pending" | "materials" | "approved" | "rejected", … }
// If review asks for more ("materials"), upload certificate + passport docs (PDF allowed here):
if bank["status"] == "materials" {
_, err = ap.OffRamp.SubmitBankMaterials(ctx, bank["bankAccountId"].(string), absolutepay.BankMaterialsParams{
Certificate: []absolutepay.DocFile{{Filename: "license.pdf", ContentType: "application/pdf", DataBase64: "…"}},
Passport: []absolutepay.DocFile{{Filename: "id.jpg", ContentType: "image/jpeg", DataBase64: "…"}},
})
if err != nil {
log.Fatal(err)
}
}
// Remove one you no longer need:
if err := ap.OffRamp.DeleteBank(ctx, "bank_123"); err != nil {
log.Fatal(err)
}
```
```bash
BODY='{"bankAccountName":"Acme Inc","bankName":"Bank of America","countryId":1,"iban":"US64SVBKUS6S3300958879","swift":"SVBKUS6S","file":{"filename":"statement.jpg","contentType":"image/jpeg","dataBase64":"…"}}'
curl https://api.absolutepay.io/v1/offramp/banks \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/offramp/banks "$BODY") -d "$BODY"
# supplementary docs (when status is "materials"):
BODY='{"certificate":[{"filename":"license.pdf","contentType":"application/pdf","dataBase64":"…"}],"passport":[{"filename":"id.jpg","contentType":"image/jpeg","dataBase64":"…"}]}'
curl https://api.absolutepay.io/v1/offramp/banks/bank_123/materials \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/offramp/banks/bank_123/materials "$BODY") -d "$BODY"
# remove a bank:
curl -X DELETE "https://api.absolutepay.io/v1/offramp/banks/bank_123" \
-H "Authorization: Bearer $APP_TOKEN" $(sign DELETE /v1/offramp/banks/bank_123 "")
```
Review is manual and asynchronous. A bank moves `pending → approved`, or to `materials` (more
documents needed — send **both** a `certificate` (business license) and a `passport` (government
ID) group, 1–5 files each), or `rejected` (the `memo` field carries the reason). Poll `banks()`
until the account is **approved** — only then can it be a `withdraw` destination.
## 3. Quote & withdraw
Quote the crypto → fiat conversion, then execute the withdrawal by echoing the quote back with your
approved bank:
```ts
const quote = await ap.offramp.quote({
cryptoCurrency: "USDT",
fiatCurrency: "USD",
cryptoAmount: "1000",
});
// { quoteToken, validPeriod, cryptoAmount, fiatAmount, fiatRate, … }
const order = await ap.offramp.withdraw({
quoteToken: quote.quoteToken as string,
bankAccountId: "bank_123",
cryptoCurrency: "USDT",
fiatCurrency: "USD",
cryptoAmount: "1000",
fiatAmount: quote.fiatAmount as string,
});
```
```python
quote = ap.offramp.quote(
crypto_currency="USDT",
fiat_currency="USD",
crypto_amount="1000",
)
# { quoteToken, validPeriod, cryptoAmount, fiatAmount, fiatRate, … }
order = ap.offramp.withdraw(
quote_token=quote["quoteToken"],
bank_account_id="bank_123",
crypto_currency="USDT",
fiat_currency="USD",
crypto_amount="1000",
fiat_amount=quote["fiatAmount"],
)
```
```go
quote, err := ap.OffRamp.Quote(ctx, absolutepay.OffRampQuoteParams{
CryptoCurrency: "USDT",
FiatCurrency: "USD",
CryptoAmount: "1000",
})
if err != nil {
log.Fatal(err)
}
// { quoteToken, validPeriod, cryptoAmount, fiatAmount, fiatRate, … }
order, err := ap.OffRamp.Withdraw(ctx, absolutepay.OffRampWithdrawParams{
QuoteToken: quote["quoteToken"].(string),
BankAccountID: "bank_123",
CryptoCurrency: "USDT",
FiatCurrency: "USD",
CryptoAmount: "1000",
FiatAmount: quote["fiatAmount"].(string),
})
if err != nil {
log.Fatal(err)
}
fmt.Println(order)
```
```bash
BODY='{"cryptoCurrency":"USDT","fiatCurrency":"USD","cryptoAmount":"1000"}'
curl https://api.absolutepay.io/v1/offramp/quote \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/offramp/quote "$BODY") -d "$BODY"
BODY='{"quoteToken":"qt_…","bankAccountId":"bank_123","cryptoCurrency":"USDT","fiatCurrency":"USD","cryptoAmount":"1000","fiatAmount":"921.00"}'
curl https://api.absolutepay.io/v1/offramp/withdraw \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/offramp/withdraw "$BODY") -d "$BODY"
```
The `quoteToken` is short-lived (`validPeriod` is its validity in seconds) — if it expires, get a
fresh quote. All four currency/amount fields on `withdraw` must **match the quote exactly**.
## 4. Track
Orders move `PENDING → DISPATCHED → SUCCESS | FAILED`; settled orders carry a `bankSlipInfo`
receipt. Watch the `payout.*` [webhooks](/webhooks) and poll the (keyset-paginated) order list as
backup:
```ts
const page = await ap.offramp.orders({ limit: 50 });
// page.items, page.nextCursor — pass nextCursor back as `before` for the next page
```
```python
page = ap.offramp.orders(limit=50)
# page["items"], page["nextCursor"] — pass nextCursor back as `before` for the next page
```
```go
page, err := ap.OffRamp.Orders(ctx, absolutepay.PageQuery{Limit: 50})
if err != nil {
log.Fatal(err)
}
fmt.Println(page.Items, page.NextCursor) // pass NextCursor back as Before for the next page
```
```bash
curl "https://api.absolutepay.io/v1/offramp/orders?limit=50" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/offramp/orders?limit=50" "")
```
## Gotchas
- **Minimum ~1,000 USDT-equivalent** per order (maximum 3M) — the quote rejects anything below it.
- **Withdrawals are irreversible** once dispatched to the bank — double-check the `bankAccountId`.
- **Document formats differ:** registration takes a single statement **image** (jpg/png); only the
supplementary `materials` upload accepts PDF. Everything is base64, ≤4MB per file.
- **Amounts are decimal strings**; the withdraw body must echo the quote's exact amounts.
- **Pagination** — `orders()` is keyset-paginated (`nextCursor` → `before`) and accepts a `status`
filter.
- **Sandbox** — on `https://sandbox-api.absolutepay.io` no real funds move; orders settle
end-to-end automatically so you can test the full loop.
Full request/response fields are on the `/v1/offramp/*` endpoints in the [API reference](/api).
## Next
- [Send payouts](/payouts) — pay out to a crypto address instead.
- [Conversions](/conversions) — consolidate assets before you off-ramp.
- [Webhooks](/webhooks) — receive settlement events.
- [API reference](/api) · [Authentication](/authentication)
---
# Quickstart
URL: /quickstart
This walks you from an empty project to a paid test order. All [three integration paths](/)
work here — pick **Node**, **Python**, **Go**, or **curl** below and the choice sticks across
every guide.
Install the official skill first — `npx skills add AbsolutePay/absolutepay-skill` — and your
agent learns this entire flow (SDK calls, request signing, webhook verification). You can then
say "add AbsolutePay checkout to this app" and skim this page as the reference for what it does.
## 1. Create an app
In the [dashboard](https://app.absolutepay.io), go to **Developers → Apps** and create an app.
You'll get three secrets **once**: the `ap_test_…` token, an `apisign_…` **signing secret**, and
(if you set a webhook URL) a `whsec_…` webhook signing secret. Use the **test** token while you build.
A new app starts **pending review** — an admin must approve it before it can authenticate. To skip
the wait, build in the **dedicated sandbox** ([sandbox.absolutepay.io](https://sandbox.absolutepay.io),
API at `https://sandbox-api.absolutepay.io`), where apps are auto-approved.
## 2. Install the SDK
The SDKs sign every request for you, raise typed errors, and paginate lists. Going raw REST
instead? Nothing to install — you'll sign each request yourself.
```bash
npm install absolutepay
```
```bash
pip install absolutepay
```
```bash
go get github.com/AbsolutePay/absolutepay-go
```
```bash
# No SDK — export your secrets and grab the sign() shell helper
# from the Authentication guide; every curl sample below uses it.
export APP_TOKEN="ap_test_…"
export SIGNING_SECRET="apisign_…"
```
Export your secrets as `ABSOLUTEPAY_API_KEY` and `ABSOLUTEPAY_SIGNING_SECRET` (the samples below
read them from the environment). See [SDK setup](/sdk) for full configuration options.
## 3. Confirm your credentials
Read your balances. With the SDK, signing is automatic; with curl you sign each request (see
[Authentication](/authentication#request-signing-required) for the `sign()` helper).
```ts
import { AbsolutePay } from "absolutepay";
const ap = new AbsolutePay({
apiKey: process.env.ABSOLUTEPAY_API_KEY!, // ap_test_…
signingSecret: process.env.ABSOLUTEPAY_SIGNING_SECRET!,
// sandbox: true, // if you're on the dedicated sandbox
});
console.log(await ap.balances.list());
```
```python
import os
from absolutepay import AbsolutePay
ap = AbsolutePay(
api_key=os.environ["ABSOLUTEPAY_API_KEY"], # ap_test_…
signing_secret=os.environ["ABSOLUTEPAY_SIGNING_SECRET"],
# sandbox=True, # if you're on the dedicated sandbox
)
print(ap.balances.list())
```
```go
ap, err := absolutepay.New(
os.Getenv("ABSOLUTEPAY_API_KEY"), // ap_test_…
absolutepay.WithSigningSecret(os.Getenv("ABSOLUTEPAY_SIGNING_SECRET")),
// absolutepay.WithSandbox(true),
)
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
balances, err := ap.Balances.List(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(balances)
```
```bash
curl https://api.absolutepay.io/v1/balances \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/balances "")
```
```json
{ "items": [{ "currency": "USDT", "available": "1000.000000", "locked": "0.000000" }] }
```
A test workspace starts with sandbox funds, so you'll see a balance immediately. Every list endpoint
returns this `{ items, nextCursor }` shape — see [Pagination](/pagination).
## 4. Create a payment
Create a checkout and send your customer to the returned `checkoutUrl` (they pick the asset on the
page). `reference` is **your** order id — keep it unique per order; it's also how you poll status
later. Amounts are **decimal strings** (`"10.00"`), never floats.
```ts
const checkout = await ap.checkouts.create({
reference: "order-1024", // your order id
amount: { amount: "10.00", currency: "USDT" },
});
console.log(checkout.token, checkout.checkoutUrl);
```
```python
checkout = ap.checkouts.create(
reference="order-1024", # your order id
amount={"amount": "10.00", "currency": "USDT"},
)
print(checkout["token"], checkout["checkoutUrl"])
```
```go
checkout, err := ap.Checkouts.Create(ctx, absolutepay.CheckoutParams{
Reference: "order-1024", // your order id
Amount: absolutepay.Money{Amount: "10.00", Currency: "USDT"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(checkout.Token, checkout.CheckoutURL)
```
```bash
BODY='{"reference":"order-1024","amount":{"amount":"10.00","currency":"USDT"}}'
curl https://api.absolutepay.io/v1/checkouts \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/checkouts "$BODY") -d "$BODY"
```
```json
{ "token": "chk_…", "kind": "checkout", "status": "OPEN", "checkoutUrl": "https://pay.absolutepay.io/pay/chk_…" }
```
## 5. Watch it get paid
In **test mode** the order is auto-paid a few moments after creation and a `payment.succeeded`
[webhook](/webhooks) fires — exactly the flow you'll see in production. On the **dedicated
sandbox** you drive settlement yourself: open the link in the dashboard and hit
**Simulate payment**.
Don't fulfill until the order reaches a terminal paid state. Use the webhook as the primary
signal and read the checkout back by its token as a fallback:
```ts
const checkout = await ap.checkouts.get("chk_…");
if (checkout.status === "PAID") { /* fulfill */ }
```
```python
checkout = ap.checkouts.get("chk_…")
if checkout["status"] == "PAID":
... # fulfill
```
```go
checkout, err := ap.Checkouts.Get(ctx, "chk_…")
if err != nil {
log.Fatal(err)
}
if checkout.Status == "PAID" {
// fulfill
}
```
```bash
curl "https://api.absolutepay.io/v1/checkouts/chk_…" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/checkouts/chk_…" "")
```
Crypto payments are **irreversible** — there are no chargebacks, so never fulfill on a redirect,
only on `PAID`. Money-moving POSTs (payouts, refunds, conversions) accept an
**`Idempotency-Key`**; reuse the same key on retries so a request applies at most once.
## 6. Go live
Swap the `ap_test_` app for an `ap_live_` one (re-approved + re-signed) — same base URL, same request
shapes. Walk the [Go-live checklist](/going-live) first.
## Next
- [Accept payments](/accept-payments) · [Send payouts](/payouts) · [Webhooks](/webhooks)
- [SDK setup](/sdk) · [Authentication](/authentication) · [API reference](/api)
---
# Receiving deposits
URL: /deposits
Fund your workspace balance directly by receiving crypto to a **deposit address**. Each address is
permanent and reusable — receive to it as many times as you like, and every incoming transfer credits
your workspace balance for the coin that was sent.
**Before you start:** both calls need the `balances:read` scope ([Authentication](/authentication)).
These addresses top up **your own** workspace — to collect a specific amount from a customer, use a
[payment link or invoice](/payment-links) instead (those mint per-order addresses and settle as
payments).
## Supported networks
List the networks (and their coins) you can deposit on:
```ts
const { chains } = await ap.deposits.chains();
// [{ chain: "ETH", … }, { chain: "TRX", … }]
```
```python
chains = ap.deposits.chains()
# { "chains": [{ "chain": "ETH", … }, { "chain": "TRX", … }] }
```
```go
chains, err := ap.Deposits.Chains(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(chains)
```
```bash
curl https://api.absolutepay.io/v1/deposits/chains \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/deposits/chains "")
```
## Get a deposit address
Create (or fetch) the address for a network. Show it to the sender — or render it as a QR code — and
funds land on your balance once the transfer confirms.
```ts
const address = await ap.deposits.createAddress({ chain: "ETH" });
// { chain: "ETH", address: "0x…" }
```
```python
address = ap.deposits.create_address(chain="ETH")
# { "chain": "ETH", "address": "0x…" }
```
```go
address, err := ap.Deposits.CreateAddress(ctx, "ETH")
if err != nil {
log.Fatal(err)
}
fmt.Println(address)
```
```bash
BODY='{"chain":"ETH"}'
curl https://api.absolutepay.io/v1/deposits/address \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/deposits/address "$BODY") -d "$BODY"
```
The address returned for a network is permanent and reusable, so store it once instead of creating a
new one per top-up. Calling create again for the same network returns the same address.
## Confirm the funds arrived
Deposits credit your balance once the on-chain transfer confirms. Verify by reading your
[balances](/balances-reconciliation) (`GET /v1/balances`) or by finding the credit in your settled
deposit history (`GET /v1/deposits` · `ap.deposits.list()`).
## Gotchas
- **Match asset to network.** Only send assets supported on that chain (check
[`deposits.chains()`](#supported-networks)) — a transfer to the right address on the wrong network
is irrecoverable. Crypto transfers can't be reversed.
- **Any supported coin credits the matching balance** — the address isn't bound to one asset; the
credited balance follows whatever coin was sent.
- **In the sandbox**, no real chain exists — deposits are simulated, so test the read path
(`chains` → `createAddress` → balances) rather than waiting for on-chain confirmations.
## Next
- [Balances & reconciliation](/balances-reconciliation) — see credited funds and reconcile.
- [Send payouts](/payouts) — move the balance back out to a crypto address.
- [Payment links & invoices](/payment-links) — per-order deposit addresses for customer payments.
- [API reference](/api) — full fields on the deposit endpoints.
---
# Authentication
URL: /authentication
Every request authenticates with an **app's API key** (a bearer token) **and** a per-request
HMAC **signature**. The bearer key identifies the app; the signature proves the request wasn't
tampered with or replayed. Both are required.
## Apps
An API key belongs to an **app** you create in the dashboard (**Developers → Apps**). Creating an
app returns three secrets **once** — store them:
| Secret | Header / use | Purpose |
| --- | --- | --- |
| `ap_live_…` / `ap_test_…` token | `Authorization: Bearer …` | identifies the app |
| `apisign_…` signing secret | request signature (below) | signs every request |
| `whsec_…` webhook signing secret | — | verifies our [payment webhooks](/webhooks) (only if you set a webhook URL) |
A new app starts **pending review** and cannot authenticate until an admin approves it (a
compliance gate). Rotating an app keeps its approval. Secrets are shown once — rotate the app to
get fresh ones if lost.
## Request signing (required)
Using an [SDK](/sdk) (Node, Python, or Go)? You never write any of this — constructing the
client with your `apiKey` + `signingSecret` computes the timestamp, nonce, and signature on
every request automatically. This section is for raw HTTP integrations (and the curious).
Every API-key request MUST carry three headers:
| Header | Value |
| --- | --- |
| `X-AbsolutePay-Timestamp` | current time in **epoch milliseconds** |
| `X-AbsolutePay-Nonce` | a unique random value per request |
| `X-AbsolutePay-Signature` | `hex(HMAC-SHA512(signingSecret, canonical))` |
The **canonical string** binds the method, path, time, nonce, and a hash of the body — so a
signature can't be replayed or redirected to another endpoint. Each part is joined by a single
newline (`\n`):
```
{METHOD}\n{path-and-query}\n{timestamp}\n{nonce}\n{sha256hex(body)}
```
| Part | Rule |
| --- | --- |
| `METHOD` | Uppercase HTTP verb — `GET`, `POST`, … |
| `path-and-query` | The request path **including the query string**, exactly as sent — e.g. `/v1/invoices?limit=2`. Not the host. |
| `timestamp` | Epoch **milliseconds**; must be within **±5 minutes** of our clock. Reuse the same value in the header. |
| `nonce` | A unique random value per request (e.g. 16 random bytes, hex). Accepted **once** — a repeat is rejected as a replay. |
| `sha256hex(body)` | Lowercase hex SHA-256 of the **raw request body bytes**, exactly as sent. For a request with no body, hash the **empty string** (`e3b0c4…855`). |
Hash and sign the **same** path+query and body bytes you put on the wire. If you add a query
param or re-serialize the JSON after signing, the signature won't match and you'll get `401`.
The signature is `hex(HMAC-SHA512(signingSecret, canonicalString))`:
```ts
import { createHash, createHmac, randomBytes } from "node:crypto";
// `path` MUST include the query string; `body` is the exact string you send ("" if none).
function signedHeaders(method: string, path: string, body: string, signingSecret: string) {
const ts = String(Date.now());
const nonce = randomBytes(16).toString("hex");
const bodyHash = createHash("sha256").update(body).digest("hex");
const canonical = [method.toUpperCase(), path, ts, nonce, bodyHash].join("\n");
const signature = createHmac("sha512", signingSecret).update(canonical).digest("hex");
return { "X-AbsolutePay-Timestamp": ts, "X-AbsolutePay-Nonce": nonce, "X-AbsolutePay-Signature": signature };
}
```
```python
import hashlib, hmac, time, uuid
# `path` MUST include the query string; `body` is the exact string you send ("" if none).
def signed_headers(method, path, body, signing_secret):
ts = str(int(time.time() * 1000))
nonce = uuid.uuid4().hex
body_hash = hashlib.sha256(body.encode()).hexdigest()
canonical = "\n".join([method.upper(), path, ts, nonce, body_hash])
signature = hmac.new(signing_secret.encode(), canonical.encode(), hashlib.sha512).hexdigest()
return {
"X-AbsolutePay-Timestamp": ts,
"X-AbsolutePay-Nonce": nonce,
"X-AbsolutePay-Signature": signature,
}
```
```go
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"strconv"
"strings"
"time"
)
// path MUST include the query string; body is the exact string you send ("" if none).
func signedHeaders(method, path, body, signingSecret string) map[string]string {
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
nb := make([]byte, 16)
rand.Read(nb)
nonce := hex.EncodeToString(nb)
bodyHash := sha256.Sum256([]byte(body))
canonical := strings.Join([]string{strings.ToUpper(method), path, ts, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
mac := hmac.New(sha512.New, []byte(signingSecret))
mac.Write([]byte(canonical))
return map[string]string{
"X-AbsolutePay-Timestamp": ts,
"X-AbsolutePay-Nonce": nonce,
"X-AbsolutePay-Signature": hex.EncodeToString(mac.Sum(nil)),
}
}
```
```bash
# Emits the three signing headers as curl -H flags (bash/zsh + openssl).
# This is the sign() helper every curl sample in these docs assumes.
# usage: curl … -H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/balances "")
sign() { # sign METHOD PATH BODY — PATH includes the query string; BODY is "" if none
local ts=$(( $(date +%s) * 1000 ))
local nonce=$(openssl rand -hex 16)
local body_hash=$(printf '%s' "$3" | openssl dgst -sha256 | awk '{print $NF}')
local canonical="$(printf '%s\n%s\n%s\n%s\n%s' "$1" "$2" "$ts" "$nonce" "$body_hash")"
local sig=$(printf '%s' "$canonical" | openssl dgst -sha512 -hmac "$SIGNING_SECRET" | awk '{print $NF}')
printf -- '-H X-AbsolutePay-Timestamp:%s -H X-AbsolutePay-Nonce:%s -H X-AbsolutePay-Signature:%s' \
"$ts" "$nonce" "$sig"
}
```
Every endpoint in the [API reference](/api) ships a ready-to-run **signed** curl plus Node,
Python, and Go SDK samples.
### Try it — in-browser signer
Paste your signing secret and a request to get the exact headers + a ready curl. It runs entirely
in your browser (Web Crypto); your secret never leaves the page. Also at [`/signer`](/signer).
## Scopes
Keys carry **scopes** that limit what they can do. A call needing a scope the key lacks is rejected
with `403`. Grant only what each integration needs, and issue a separate app per integration.
Each resource group has a `:read` scope for its reads and a `:write` scope for its writes — a read
never requires a `:write` scope.
| Scope | Grants |
| --- | --- |
| `checkouts:write` | Create, update, and void checkouts (`POST`/`PATCH`/`DELETE` on `/v1/checkouts`) |
| `checkouts:read` | List and read checkouts |
| `invoices:write` | Create, update, and void invoices (`POST`/`PATCH`/`DELETE` on `/v1/invoices`) |
| `invoices:read` | List and read invoices |
| `payments:write` | Create a refund (`POST /v1/refunds`); issue gift cards |
| `payments:read` | Read refunds (`GET /v1/refunds`, `GET /v1/refunds/{id}`) and gift cards |
| `convert:write` | Quote and execute conversions |
| `convert:read` | Read settled conversion history (`GET /v1/conversions`) |
| `balances:read` | Read balances, deposits (history, addresses, networks), and the fee preview |
| `ledger:read` | Read settled history: reconciliation (`payments`/`withdrawals`) and the unified transactions ledger |
| `payouts:write` | Create payouts, and off-ramp: quote, withdraw, register/remove a bank |
| `payouts:read` | Read payout options + a payout, and off-ramp countries/banks/orders |
| `subscriptions:read` / `subscriptions:write` | Read / manage subscription plans and subscriptions |
The exact scope each endpoint needs is shown on that endpoint in the [API reference](/api). Checkouts
and invoices carry separate scope groups (`checkouts:*` vs `invoices:*`), reads use `:read` and writes
use `:write`, and `payments:write` covers refunds and gift-card issuance (a settled payment is money
**out**). The `account:admin` and app-management scopes are **not** grantable to tenant keys.
## IP allowlist (optional)
An app can restrict which source IPs may use it (CIDRs, set on the app). Requests from other IPs
are rejected with `403`. Leave it empty for no restriction.
## Test vs live
The token prefix selects the mode — **one base URL** for both:
| Prefix | Mode | Funds |
| --- | --- | --- |
| `ap_test_…` | Test | Sandbox only |
| `ap_live_…` | Live | Real |
Build against `ap_test_` (signing + review apply identically); switch to `ap_live_` for real funds.
A key only ever acts on its own workspace.
Prefer a clean, isolated environment? Use the **dedicated sandbox** at
`https://sandbox-api.absolutepay.io` (sign up at [sandbox.absolutepay.io](https://sandbox.absolutepay.io)),
where apps are auto-approved so you can start without waiting on review.
## Errors
- `401 unauthorized` — missing/unknown key, or a missing/invalid/stale signature.
- `403 forbidden` — valid key but missing scope, or a disallowed source IP.
See [Errors](/errors) for the full shape.
## Next
- [Quickstart](/quickstart) — first signed call to a paid test order.
- [SDK setup](/sdk) — let the client do all of the above for you.
- [Webhooks](/webhooks) — the *other* signature: verifying what we send **you**.
- [API reference](/api) — every endpoint with a ready-signed curl.
---
# Conversions
URL: /conversions
Convert one asset in your balance to another (e.g. USDT → BTC). It's a two-step flow: **quote** a
rate, then **execute** that quote before it expires. Use it to consolidate settled funds into one
asset, or to fund a payout in a currency you don't hold.
**Before you start:** the key needs the `convert:write` scope, and you need enough `available`
balance in the sell currency ([Balances](/balances-reconciliation)). Amounts are decimal strings.
## 1. Quote
Specify **exactly one** of sell amount ("I want to spend this much") or buy amount ("I want to
receive this much") — the other side is computed:
```ts
const quote = await ap.conversions.quote({
sellCurrency: "USDT",
buyCurrency: "BTC",
sellAmount: "100", // provide sellAmount OR buyAmount (exactly one)
});
console.log(quote.quoteId, quote.rate, quote.buyAmount);
```
```python
quote = ap.conversions.quote(
sell_currency="USDT",
buy_currency="BTC",
sell_amount="100", # provide sell_amount OR buy_amount (exactly one)
)
print(quote["quoteId"], quote["rate"], quote["buyAmount"])
```
```go
quote, err := ap.Conversions.Quote(ctx, absolutepay.QuoteParams{
SellCurrency: "USDT",
BuyCurrency: "BTC",
SellAmount: "100", // provide SellAmount OR BuyAmount (exactly one)
})
if err != nil {
log.Fatal(err)
}
fmt.Println(quote.QuoteID, quote.Rate, quote.BuyAmount)
```
```bash
BODY='{"sellCurrency":"USDT","buyCurrency":"BTC","sellAmount":"100"}'
curl https://api.absolutepay.io/v1/conversions/quote \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/conversions/quote "$BODY") -d "$BODY"
```
The quote locks a `rate` and both **exact** legs (`sellAmount`/`sellCurrency`,
`buyAmount`/`buyCurrency`) under a `quoteId` with a short validity window. If it expires, request a
new one — nothing has moved yet.
## 2. Execute
Pass the `quoteId` back with the quote's exact legs. This is the step that moves funds:
```ts
const order = await ap.conversions.execute({
quoteId: quote.quoteId,
sell: { amount: quote.sellAmount, currency: quote.sellCurrency },
buy: { amount: quote.buyAmount, currency: quote.buyCurrency },
});
console.log(order.orderId, order.status); // "SUCCESS" | "FAILED" | "PENDING"
// Or do both in one call:
const order2 = await ap.conversions.convert({ sellCurrency: "USDT", buyCurrency: "BTC", sellAmount: "100" });
```
```python
order = ap.conversions.execute(
quote_id=quote["quoteId"],
sell={"amount": quote["sellAmount"], "currency": quote["sellCurrency"]},
buy={"amount": quote["buyAmount"], "currency": quote["buyCurrency"]},
)
print(order["orderId"], order["status"]) # "SUCCESS" | "FAILED" | "PENDING"
# Or do both in one call:
order2 = ap.conversions.convert(sell_currency="USDT", buy_currency="BTC", sell_amount="100")
```
```go
order, err := ap.Conversions.Execute(ctx, absolutepay.ConvertExecuteParams{
QuoteID: quote.QuoteID,
Sell: absolutepay.Money{Amount: quote.SellAmount, Currency: quote.SellCurrency},
Buy: absolutepay.Money{Amount: quote.BuyAmount, Currency: quote.BuyCurrency},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(order["orderId"], order["status"]) // "SUCCESS" | "FAILED" | "PENDING"
// Or do both in one call:
_, err = ap.Conversions.Convert(ctx, absolutepay.QuoteParams{SellCurrency: "USDT", BuyCurrency: "BTC", SellAmount: "100"})
if err != nil {
log.Fatal(err)
}
```
```bash
BODY='{"quoteId":"quote_123","sell":{"amount":"100","currency":"USDT"},"buy":{"amount":"0.0015","currency":"BTC"}}'
curl https://api.absolutepay.io/v1/conversions \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/conversions "$BODY") -d "$BODY"
```
`convert()` is the two calls back-to-back using the quote's exact amounts — convenient for
machine-to-machine flows. The rate can move between its two internal steps, so if you need to show
a user the rate **before** committing, use `quote` then `execute` yourself.
## Confirm
The result is synchronous — `status` in the execute response is `SUCCESS`, `FAILED`, or `PENDING`
(no webhook is emitted for conversions). The bought asset appears in your workspace balance once the
order completes; verify with a [balance read](/balances-reconciliation).
## Gotchas
- **Conversions are irreversible** once executed — there's no undo; you'd have to convert back at the
then-current rate.
- **Exactly one amount** per quote: `sellAmount` XOR `buyAmount`. Sending both (or neither) is a `400`.
- **Echo the quote exactly.** `execute` must carry the quote's exact `sell` and `buy` legs; a
mismatched or expired quote is rejected — get a fresh quote and retry.
- **No `Idempotency-Key` on this endpoint.** A `quoteId` is single-purpose and short-lived, which
bounds double-spend — but if an execute call times out, check [transactions](/balances-reconciliation)
before retrying rather than blind-resubmitting.
- **Amounts are decimal strings** (e.g. `"100"`, `"0.0015"`) — never floats.
Full request/response fields are on the conversion endpoints in the [API reference](/api).
## Next
- [Balances & reconciliation](/balances-reconciliation) — see the converted balance land.
- [Send payouts](/payouts) — pay out the asset you just converted into.
- [Off-ramp](/offramp) — convert crypto to fiat and withdraw to a bank.
- [API reference](/api) · [Authentication](/authentication)
---
# Refunds
URL: /refunds
Refund a **settled** order back to the payer. Crypto has no chargeback mechanism — a refund is a
**new outbound transaction** you initiate, paid from your workspace balance back to the payer. You
create it, it processes on-chain, then a `charge.refunded` [webhook](/webhooks) fires. The accrued
fees on the original payment are reversed.
**Before you start:** refunds need the `payments:write` scope ([Authentication](/authentication)),
and the original order must have reached `PAID` (see
[Confirm the payment](/accept-payments#confirm-the-payment)).
You can refund an order that reached `PAID`. The refund amount can be the full amount or less
(partial) — over-refunding beyond the collected total is rejected with a `422`. Refunds are keyed
to the original `merchantTradeNo`.
## Create a refund
```ts
const refund = await ap.refunds.create({
merchantTradeNo: "order-1024", // the settled order to refund
amount: { amount: "49.99", currency: "USDT" }, // ≤ the original amount (partial allowed)
reason: "customer request",
});
console.log(refund.refundRequestId, refund.status); // e.g. "rf_…", "PENDING"
```
```python
refund = ap.refunds.create(
merchant_trade_no="order-1024", # the settled order to refund
amount={"amount": "49.99", "currency": "USDT"}, # ≤ the original amount (partial allowed)
reason="customer request",
)
print(refund["refundRequestId"], refund["status"]) # e.g. "rf_…", "PENDING"
```
```go
refund, err := ap.Refunds.Create(ctx, absolutepay.RefundParams{
MerchantTradeNo: "order-1024", // the settled order to refund
Amount: absolutepay.Money{Amount: "49.99", Currency: "USDT"}, // ≤ the original amount (partial allowed)
Reason: "customer request",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(refund["refundRequestId"], refund["status"]) // e.g. "rf_…", "PENDING"
```
```bash
BODY='{"merchantTradeNo":"order-1024","amount":{"amount":"49.99","currency":"USDT"},"reason":"customer request"}'
curl https://api.absolutepay.io/v1/refunds \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
$(sign POST /v1/refunds "$BODY") -d "$BODY"
```
## Track it
```ts
const status = await ap.refunds.get(refund.refundRequestId);
```
```python
status = ap.refunds.get(refund["refundRequestId"])
```
```go
status, err := ap.Refunds.Get(ctx, refund["refundRequestId"].(string))
if err != nil {
log.Fatal(err)
}
fmt.Println(status)
```
```bash
curl "https://api.absolutepay.io/v1/refunds/rf_…" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/refunds/rf_… "")
```
A refund starts `PENDING` and reaches `REFUND_SUCCESS` (or `REFUND_REJECTED`) when it settles — at
which point the `charge.refunded` webhook fires. Treat that webhook as the source of truth and act
idempotently on the refund id. Full fields are on `POST /v1/refunds` in the [API reference](/api).
## Gotchas
- **A settled refund is final.** It's an on-chain transfer — once `REFUND_SUCCESS`, it cannot be
reversed or recalled. Double-check the amount before creating one.
- **Retries:** `POST /v1/refunds` is a money-moving call — it accepts an `Idempotency-Key` header
(shown in the curl tab); reuse the same key when retrying so the refund is applied at most once.
After an ambiguous failure (timeout), check the refund's state before creating it again.
- **Amounts are decimal strings** (`"49.99"`), positive, ≤ 6 fraction digits, in the original
payment's currency. Partial refunds are allowed up to the collected total across all refunds on
the order.
- **In the sandbox**, refunds are mock-backed — no real on-chain transfer happens, and the same
`charge.refunded` webhook fires on completion, so you can test your handler end to end.
## Next
- [Accept payments](/accept-payments) — the checkout flow that produces refundable orders.
- [Webhooks](/webhooks) — handle `charge.refunded`.
- [Balances & reconciliation](/balances-reconciliation) — see the fee reversal in your ledger.
- [API reference](/api) — full fields on `POST /v1/refunds`.
---
# Accept payments
URL: /accept-payments
To collect a payment you create a **checkout link**. AbsolutePay returns a hosted checkout URL, your
customer opens it and pays in crypto (they pick the asset and network on the page), and the funds
settle to your workspace. You learn it settled via a [webhook](/webhooks) (and can poll as a fallback).
The flow: **create checkout → send customer to `checkoutUrl` → they pay → `payment.succeeded` webhook → you fulfill.**
**Before you start:** you need an API key with the `invoices:write` scope
([Authentication](/authentication)). Build against an `ap_test_` key or the
[sandbox](https://sandbox.absolutepay.io) first — same request shapes, no real funds. To show the
fee before you create the order, use the [fee preview](/fees).
## Create a checkout
Returns a `token` and a hosted `checkoutUrl` to send the customer to; they pick the asset on the page.
Your `reference` is your own order id — keep it unique per order; it's how you correlate the payment later.
```ts
const checkout = await ap.checkouts.create({
reference: "order-1024", // your order id (keep it unique)
amount: { amount: "49.99", currency: "USDT" }, // decimal string, ≤ 6 dp
});
console.log(checkout.token, checkout.checkoutUrl);
```
```python
checkout = ap.checkouts.create(
reference="order-1024", # your order id (keep it unique)
amount={"amount": "49.99", "currency": "USDT"}, # decimal string, ≤ 6 dp
)
print(checkout["token"], checkout["checkoutUrl"])
```
```go
checkout, err := ap.Checkouts.Create(ctx, absolutepay.CheckoutParams{
Reference: "order-1024", // your order id (keep it unique)
Amount: absolutepay.Money{Amount: "49.99", Currency: "USDT"}, // decimal string, ≤ 6 dp
})
if err != nil {
log.Fatal(err)
}
fmt.Println(checkout.Token, checkout.CheckoutURL)
```
```bash
BODY='{"reference":"order-1024","amount":{"amount":"49.99","currency":"USDT"}}'
curl https://api.absolutepay.io/v1/checkouts \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/checkouts "$BODY") -d "$BODY"
```
```json
{ "token": "chk_…", "kind": "checkout", "status": "OPEN", "checkoutUrl": "https://pay.absolutepay.io/pay/chk_…" }
```
Send the customer to `checkoutUrl` and keep the `token` — it's how you poll status later. Amounts are
decimal strings (`"49.99"`), positive, ≤ 6 fraction digits. The full parameter and response tables live
on **`POST /v1/checkouts`** in the [API reference](/api).
## Confirm the payment
Don't treat an order as paid until it reaches a terminal **paid** state. Use both signals:
1. **Webhooks (primary)** — a `payment.succeeded` event fires on settlement. See [Webhooks](/webhooks).
2. **Polling (fallback)** — read the checkout back by its token if you miss a webhook:
```ts
const checkout = await ap.checkouts.get("chk_…");
if (checkout.status === "PAID") { /* fulfill the order */ }
```
```python
checkout = ap.checkouts.get("chk_…")
if checkout["status"] == "PAID":
pass # fulfill the order
```
```go
checkout, err := ap.Checkouts.Get(ctx, "chk_…")
if err != nil {
log.Fatal(err)
}
if checkout.Status == "PAID" {
// fulfill the order
}
```
```bash
curl "https://api.absolutepay.io/v1/checkouts/chk_…" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/checkouts/chk_…" "")
```
`status` is the settlement state: `OPEN` → `PAID`. Unpaid links expire on their own; you don't need
to close them. Always fulfill on the **webhook**, then treat `ap.checkouts.get` as backup.
## Idempotency
The `reference` is your idempotency/reconciliation handle: generate it once per order and reuse it on
retry so you correlate the settlement back to the right order.
## Gotchas
- **Amounts are decimal strings** (`"49.99"`), positive, ≤ 6 fraction digits — never floats.
- **Settlement is final.** Once a payment settles there's no chargeback path in crypto; returning
funds means creating a [refund](/refunds), which is a new outbound transaction.
- **Sandbox links don't settle by themselves** — you trigger settlement ("Simulate payment" in the
dashboard), which fires the same `payment.succeeded` webhook as production.
## Next
- [Payment links & invoices](/payment-links) — shareable links + billing with a deposit address.
- [Refunds](/refunds) — return a settled payment.
- [Webhooks](/webhooks) — receive `payment.succeeded` reliably.
- [API reference](/api) — full fields on `POST /v1/checkouts` · [Authentication](/authentication) — keys, scopes, signing.
---
# Going live
URL: /going-live
Before real money moves, prove the whole flow in a mock environment, then walk the checklist.
Crypto settlement is **final** — there are no chargebacks — so anything you'd normally catch "in
dispute" you must catch before cutover.
## Test without real funds
Two ways to test, both mock-backed and both fire the same webhooks as production:
| | Test mode on production | Dedicated sandbox |
| --- | --- | --- |
| Host | `api.absolutepay.io` | `sandbox-api.absolutepay.io` |
| Key | `ap_test_…` | `ap_test_…` (sandbox app) |
| App approval | requires admin review | **auto-approved** |
| Settlement | mock | mock (you can trigger it) |
Use the **dedicated sandbox** ([sandbox.absolutepay.io](https://sandbox.absolutepay.io)) while
building — apps are auto-approved so there's no review wait. Point the SDK at it with `sandbox: true`
(Node), `sandbox=True` (Python), or `absolutepay.WithSandbox(true)` (Go).
### Test the full loop
1. Create a checkout → in the sandbox it auto-settles shortly after.
2. Expose a local webhook receiver with a tunnel (`ngrok http 4455`) and set that URL on your sandbox app.
3. Confirm the `payment.succeeded` webhook arrives and **verifies** on the raw body ([Webhooks](/webhooks)).
4. Create a payout with an `Idempotency-Key`; retry the same call and confirm it doesn't double-pay.
5. Pull [reconciliation](/balances-reconciliation) and match against your own records.
6. Force failures (bad amount, unsupported chain, missing scope) and confirm your error handling ([Errors](/errors)).
## Approval — configure the live app before it's reviewed
A live app needs a **verified workspace** (KYC/KYB in the dashboard) and must be **approved** by
review before it can authenticate or receive webhooks.
Editing any app detail — name, homepage, **webhook URL**, IP allowlist — resubmits the app for
review, and it can't authenticate until re-approved. So set the **production webhook URL and IP
allowlist in one edit, before approval** — don't plan to "flip the URL later" on a live app.
Rotating the app's token does **not** trigger re-review.
## Go-live checklist
**Account & compliance**
- [ ] Workspace KYC/KYB verified; live app **approved** (out of pending review).
- [ ] If your compliance requires blocking specific countries from your checkout, enforce the
geoblock at your edge/CDN — a payment accepted from a blocked region can't be reversed.
**Code**
- [ ] Secrets (`apiKey`, `signingSecret`, `webhookSecret`) loaded from env — never hard-coded or in a browser.
- [ ] Requests signed (or using the SDK, which signs for you) — see [Authentication](/authentication).
- [ ] Webhook receiver deployed, verifying signatures on the **raw body**, idempotent on `event.id`.
- [ ] `Idempotency-Key` on every money-moving write; balance checked before payouts.
- [ ] Amounts handled as decimal strings end to end — no floats anywhere on the money path.
- [ ] Errors handled with exponential backoff on `429`/`5xx` ([Errors](/errors)).
**Configuration**
- [ ] Production webhook URL set on the live app (before approval — see above); `whsec_` secret stored securely.
- [ ] Per-app **IP allowlist** (CIDRs) configured if you restrict where API calls originate.
**Operations & monitoring**
- [ ] Error logging captures `code` + `x-request-id` (quote both in support tickets).
- [ ] Webhook delivery monitored (Developers → Apps → Deliveries) with an alert on failed deliveries.
- [ ] Reconciliation job scheduled (hourly/daily) and diffing against your own ledger.
- [ ] Alerting on error-rate spikes and on payouts stuck out of a terminal state.
## Cut over
Swap the `ap_test_` app for the approved `ap_live_` one and update your env vars — **same base URL,
same request shapes**, only the credentials change. Then smoke-test the live credentials with a
read-only call before sending traffic:
```ts
const balances = await ap.balances.list(); // proves key, signature, and scope
console.log(balances);
```
```python
balances = ap.balances.list() # proves key, signature, and scope
print(balances)
```
```go
balances, err := ap.Balances.List(ctx) // proves key, signature, and scope
if err != nil {
log.Fatal(err)
}
fmt.Println(balances)
```
```bash
curl https://api.absolutepay.io/v1/balances \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/balances "")
```
A `401`/`403` here means a key, signature, or scope problem — fix it before any money moves
([Errors](/errors)). Watch the first 24h closely: error rates, webhook delivery, and the first
scheduled reconciliation run.
## Next
- [Authentication](/authentication) — keys, scopes, and request signing.
- [Webhooks](/webhooks) — verification and idempotent handling, end to end.
- [Balances & reconciliation](/balances-reconciliation) — the settled ledger your job should diff.
- [Errors](/errors) and the [API reference](/api) — retry strategy and every endpoint.
---
# Gift cards
URL: /giftcards
Issue prepaid gift cards funded from your workspace balance: pick a **template** (the card design),
create a card for an amount, and hand the returned card details to the recipient. Reading templates
and cards needs the `balances:read` [scope](/authentication); issuing needs `payments:write`.
## 1. Browse templates
```ts
const templates = await ap.giftcards.templates();
```
```python
templates = ap.giftcards.templates()
```
```go
templates, err := ap.GiftCards.Templates(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(templates)
```
```bash
curl "https://api.absolutepay.io/v1/giftcards/templates" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/giftcards/templates "")
```
```json
[{ "id": "tmpl-1", "titleEn": "Happy Birthday", "imageUrl": "https://…", "coverType": "birthday" }]
```
Pass a template's `id` as `templateId` when issuing.
## 2. Issue a card
The face value is debited from your workspace balance immediately (insufficient balance fails the
request), so check [balances](/balances-reconciliation) — and [preview the fee](/fees) with
`paymentType: "GIFTCARD"` — first.
```ts
const card = await ap.giftcards.create({
title: "Birthday gift", // shown on the card
templateId: "tmpl-1", // from templates()
amount: { amount: "50.00", currency: "USDT" }, // decimal string, ≤ 6 dp
});
console.log(card.cardNum, card.cardKey);
```
```python
card = ap.giftcards.create(
title="Birthday gift", # shown on the card
template_id="tmpl-1", # from templates()
amount={"amount": "50.00", "currency": "USDT"}, # decimal string, ≤ 6 dp
)
print(card["cardNum"], card["cardKey"])
```
```go
card, err := ap.GiftCards.Create(ctx, absolutepay.GiftCardParams{
Title: "Birthday gift", // shown on the card
TemplateID: "tmpl-1", // from Templates()
Amount: absolutepay.Money{Amount: "50.00", Currency: "USDT"}, // decimal string, ≤ 6 dp
})
if err != nil {
log.Fatal(err)
}
fmt.Println(card["cardNum"], card["cardKey"])
```
```bash
BODY='{"title":"Birthday gift","templateId":"tmpl-1","amount":{"amount":"50.00","currency":"USDT"}}'
curl https://api.absolutepay.io/v1/giftcards \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
$(sign POST /v1/giftcards "$BODY") -d "$BODY"
```
```json
{ "cardNum": "gc_9f2…", "cardKey": "…", "title": "Birthday gift", "templateId": "tmpl-1", "amount": { "amount": "50.00", "currency": "USDT" }, "status": "ACTIVE" }
```
Whoever holds `cardKey` can redeem the card — treat it like cash. Store it securely, deliver it only
to the recipient, and never log it. Issued value is crypto: there are no chargebacks.
## 3. List & look up
```ts
const cards = await ap.giftcards.list({ status: "ACTIVE" }); // keyset-paginated
const one = await ap.giftcards.get("gc_9f2…"); // by cardNum
```
```python
cards = ap.giftcards.list(status="ACTIVE") # keyset-paginated
one = ap.giftcards.get("gc_9f2…") # by cardNum
```
```go
cards, err := ap.GiftCards.List(ctx, absolutepay.PageQuery{Status: "ACTIVE"}) // keyset-paginated
if err != nil {
log.Fatal(err)
}
one, err := ap.GiftCards.Get(ctx, "gc_9f2…") // by cardNum
if err != nil {
log.Fatal(err)
}
fmt.Println(cards, one)
```
```bash
curl "https://api.absolutepay.io/v1/giftcards?status=ACTIVE" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/giftcards?status=ACTIVE" "")
curl "https://api.absolutepay.io/v1/giftcards/gc_9f2…" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/giftcards/gc_9f2…" "")
```
Lists are keyset-paginated (`limit`, `before` — see [Pagination](/pagination)). Card `status` is one of
`PENDING` / `REVIEW` (not yet active), `ACTIVE`, `REDEEMED`, `FROZEN`, `FAILED`. Gift cards don't emit
[webhook](/webhooks) events today — poll `get(cardNum)` to track redemption.
## Next
- [Fees](/fees) — preview the issuance cost with `paymentType: "GIFTCARD"`.
- [Balances & reconciliation](/balances-reconciliation) — the balance the card is funded from.
- [API reference](/api) — full parameter and response tables for the gift-card endpoints.
---
# Errors & idempotency
URL: /errors
Every non-`2xx` response has one small, predictable shape, and every SDK raises one typed error for
it — so error handling is one `catch`, everywhere.
## Error shape
Errors return a non-`2xx` status and a small JSON body:
```json
{ "code": "rate_limited", "title": "too many requests", "detail": "…" }
```
- `code` — a stable, machine-readable string. Branch on this.
- `title` — a human-readable summary. Show or log this; don't parse it.
- `detail` — optional extra context.
Every response carries an `x-request-id`; include it when you report a problem.
## Handle errors in the SDK
Non-2xx responses raise `AbsolutePayError` (Node/Python) / return `*absolutepay.Error` (Go), carrying
`status`, `code`, `detail`, and the request id, plus `isAuth` / `isRateLimited` helpers for the two
branches you'll actually write:
```ts
import { AbsolutePayError } from "absolutepay";
try {
await ap.payouts.create({ items: [/* … */] });
} catch (e) {
if (e instanceof AbsolutePayError) {
console.error(e.status, e.code, e.message, e.detail, e.requestId); // message = the title
if (e.isAuth) { /* 401/403 — check key, scope, or signature */ }
if (e.isRateLimited) { /* 429 — back off and retry */ }
}
throw e;
}
```
```python
from absolutepay import AbsolutePayError
try:
ap.payouts.create([...])
except AbsolutePayError as e:
print(e.status, e.code, e.detail, e.request_id) # str(e) is the human-readable title
if e.is_auth: # 401/403 — check key, scope, or signature
...
if e.is_rate_limited: # 429 — back off and retry
...
raise
```
```go
_, err := ap.Payouts.Create(ctx, items)
var apErr *absolutepay.Error
if errors.As(err, &apErr) {
fmt.Println(apErr.Status, apErr.Code, apErr.Title, apErr.Detail, apErr.RequestID)
if apErr.IsAuth() { /* 401/403 — check key, scope, or signature */ }
if apErr.IsRateLimited() { /* 429 — back off and retry */ }
}
```
In Python and Go, `status == 0` means the request failed at the network layer before any HTTP
response arrived — treat it like a `5xx` (safe to retry reads; retry writes only with the same
idempotency key). Inbound webhook verification has its own error
(`WebhookSignatureError` / `ErrInvalidSignature`) — see [Webhooks](/webhooks).
## Common statuses
| Status | Meaning | What to do |
| --- | --- | --- |
| `400` | Bad request — a field is missing or malformed. | Fix the request. |
| `401` | Unauthorized — missing/invalid key or signature. | Check key + signing. |
| `403` | Forbidden — the key lacks the required scope (or a disallowed IP). | Grant the scope. |
| `404` | Not found — the resource doesn't exist, or isn't yours. | Check the id. |
| `409` | Conflict — `idempotency_conflict` (same key, different body) or `idempotency_in_progress` (a request with that key is still running). | Use a fresh key, or retry once the in-flight request settles. |
| `429` | Rate limited. | Back off; honor `Retry-After`. |
| `5xx` | Server-side. | Retry idempotent calls with backoff. |
The specific `code` each endpoint can return is listed on that endpoint in the [API reference](/api).
## Idempotency
Money-moving POSTs — [payouts](/payouts), [refunds](/refunds), [conversions](/conversions),
[off-ramp](/offramp) withdrawals, gift cards, and subscription/plan creation — accept a unique
`Idempotency-Key` header. Generate the key once per operation (e.g. a UUID stored with your order row)
and reuse it across retries. The SDKs pass it for you via an `idempotencyKey` option on `create`
(`idempotency_key=` in Python, an `IdempotencyKey` field in Go).
The middleware keys on `Idempotency-Key` **plus** the request body:
| Case | Result |
| --- | --- |
| Same key **+ same body** (a retry) | **Replays** the original response — the operation runs at most once. The replay carries an `Idempotent-Replayed: true` header. |
| Same key **+ different body** | `409 idempotency_conflict` — you reused a key for a different request. Use a fresh key. |
| Same key **still in flight** (concurrent duplicate) | `409 idempotency_in_progress` — a request with that key is already running. Retry after it settles. |
Only successful terminal responses are cached: a `5xx` or `429` is **not** stored, so retrying with the
same key genuinely re-runs the operation (it doesn't replay the error). Crypto settlement is final — a
duplicate payout can't be clawed back — so an idempotency key is not optional on the payout path.
## Rate limits & retrying safely
Requests are rate limited **per API key** (per-IP for unauthenticated traffic), with separate buckets
for reads and writes:
| Bucket | Limit | Methods |
| --- | --- | --- |
| Read | **100 requests / second** | `GET`, `HEAD` |
| Write | **100 requests / second** | `POST`, `PUT`, `PATCH`, `DELETE` |
Every response carries an `X-RateLimit-Limit` header with the ceiling for that bucket. Exceed it and the
next request gets a `429` with a `Retry-After` header (whole seconds) — wait that long before retrying.
These are the current ceilings and may change, so treat `429` + `Retry-After` as the source of truth
rather than hard-coding a fixed send rate.
- **Reads and writes are independent buckets** — a burst of reads won't throttle your writes.
- **The bucket is per key.** Workers that share one key share one bucket; for more headroom, spread load
across keys or cap your concurrency so the steady rate stays under the ceiling.
- **The same limits apply in sandbox**, so you can validate your backoff and queueing there before going
live. (Sandbox only relaxes the sign-up / verification throttles, never the API rate limit.)
Retry `429` and `5xx` with **exponential backoff + jitter**:
```ts
async function withRetry(fn: () => Promise, tries = 5): Promise {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e) {
const retryable = e instanceof AbsolutePayError && (e.isRateLimited || e.status >= 500);
if (!retryable || i >= tries - 1) throw e;
const delay = Math.min(1000 * 2 ** i, 30_000) + Math.random() * 250; // jitter
await new Promise((r) => setTimeout(r, delay));
}
}
}
```
```python
import random
import time
from absolutepay import AbsolutePayError
def with_retry(fn, tries=5):
for i in range(tries):
try:
return fn()
except AbsolutePayError as e:
if not (e.is_rate_limited or e.status >= 500) or i >= tries - 1:
raise
time.sleep(min(2 ** i, 30) + random.random() * 0.25) # seconds: exponential backoff + jitter
```
```go
func withRetry[T any](fn func() (T, error), tries int) (T, error) {
var v T
var err error
for i := 0; i < tries; i++ {
if v, err = fn(); err == nil {
return v, nil
}
var apErr *absolutepay.Error
if !errors.As(err, &apErr) || !(apErr.IsRateLimited() || apErr.Status >= 500) || i == tries-1 {
return v, err
}
delay := time.Duration(min(1<
Reads and idempotent writes are safe to retry. For non-idempotent writes, retry **only** with the same
`Idempotency-Key` so you never duplicate the effect.
## Next
- [Webhooks](/webhooks) — the delivery-side twin: verify, de-dupe on `event.id`, respond fast.
- [Payouts](/payouts) — where `Idempotency-Key` matters most.
- [Going live](/going-live) — force failures in the sandbox and prove your handling before launch.
- [API reference](/api) — the exact `code` values each endpoint can return.
---
# Best practices
URL: /best-practices
The reference guides show you *how* to call each endpoint. This one shows you how to build an
integration that holds up in production — one that never loses or double-counts a payment, scales to
thousands of users without tripping the rate limit, and keeps your secrets safe.
Read it once before you point real traffic at AbsolutePay. Each section links to the reference page with
the full code.
## The mental model: three nets
A payment is real money crossing a network you don't control. Never rely on a single signal. Build on
three overlapping nets, weakest failure mode first:
1. **Webhooks (fast path).** Fulfill the moment a [`payment.succeeded`](/webhooks) event arrives.
2. **Polling (fallback).** Read the order back by its token when a webhook is missed — a receiver was
down, a URL was wrong, the app wasn't approved yet.
3. **Reconciliation (backstop).** A scheduled job that matches the settled feed against your own ledger,
catching anything both a missed webhook *and* a missed poll let slip.
`POST /v1/checkouts` returns `status: "OPEN"`, not a settlement. And never fulfill on the browser
`redirectUrl` params either — they're untrusted display hints. Only a terminal **paid** state,
confirmed server-side, means the money arrived.
## Poll at scale without hitting the rate limit
This is the section most integrations get wrong. The read limit is **100 requests / second per API
key** ([Errors → Rate limits](/errors#rate-limits--retrying-safely)), a bucket separate from writes.
A naive "poll every open order every 5 seconds" design blows through it the instant you have a few
thousand open orders — and because the bucket is per key, every worker sharing that key shares the one
ceiling. Exceed it and you get `429`s that stall *all* your reads, including the polls you actually need.
Four rules keep polling cheap:
### 1. Centralize polling in one worker with a shared limiter
Poll from a single background worker (or a coordinated pool) that runs every request through **one
shared rate limiter sized below the ceiling** — not from web request handlers, and not from a cron
fan-out that ignores the global budget. Adding more workers on the same key does **not** buy more
budget; it's one bucket.
### 2. Back off in tiers, and jitter
An order barely needs polling after the first few seconds. Poll frequently right after creation, then
progressively slower, and **stop** at a terminal state or expiry:
```
5s → 10s → 30s → 1m → 3m → 5m → 10m → 30m → stop
```
That's ~8 reads over an order's lifetime, almost all in the slow tiers. **Add jitter** (± a random
fraction of each interval) so a burst of orders created together doesn't fire their polls in lockstep
and spike one tier.
### 3. Poll only the un-settled set, bounded by expiry
Poll **only** orders still `OPEN`/pending that you haven't already settled via webhook. The instant an
order reaches a terminal state — or passes its expiry — drop it from the poll set. Orders expire on
their own (hosted checkouts and links don't need an explicit close), so the set you poll is naturally
bounded, and in practice it's just the small tail the webhook missed.
### 4. Honor `Retry-After`, never hard-code a rate
On a `429`, wait the `Retry-After` seconds (whole seconds) and resume. Treat that header as the source
of truth — the ceiling may change, so a hard-coded "we can do N/s" silently breaks.
Yes, comfortably. The read ceiling is 100 rps = 6,000 reads/minute. With tiered backoff, an order
costs ~8 reads total and spends only its first ~15 seconds in the fast tiers. Even at 10,000
orders/day with peaks of ~5 new orders/sec, the fast tier holds ~25 orders (~5 rps) and the aged
backlog adds well under 20 rps — **under 30 rps total.** Naive flat polling of the same book would be
10×+ over. The biggest lever is that webhooks fulfill first, so you only ever poll the stragglers.
A poller loop, in shape:
```ts
// One shared limiter for the whole poller — sized UNDER the 100 req/s read ceiling.
const limiter = new TokenBucket({ ratePerSec: 80 });
const TIERS_MS = [5_000, 10_000, 30_000, 60_000, 180_000, 300_000, 600_000, 1_800_000];
async function pollOnce(order) {
await limiter.acquire(); // stay under the read bucket
let c;
try {
c = await ap.checkouts.get(order.token); // a READ — counts against the read bucket
} catch (e) {
if (e.isRateLimited) return backoff(order, e.retryAfterSeconds); // honor Retry-After
throw e;
}
if (c.status === "PAID") return fulfillOnce(order); // idempotent with the webhook path
if (Date.now() > order.expiresAt) return drop(order); // expired → terminal, stop polling
const next = TIERS_MS[Math.min(order.tier++, TIERS_MS.length - 1)];
scheduleAt(order, Date.now() + next + jitter(next)); // back off + jitter to de-sync bursts
}
```
The helpers (`TokenBucket`, `jitter`, `scheduleAt`, `fulfillOnce`, `drop`, `backoff`) are your own —
the shape is what matters. `fulfillOnce` shares the idempotent path with your webhook handler.
For very high volume, split load across multiple API keys — each key is its own read/write bucket.
Pair this with one app per integration surface (see [Least-privilege scopes](#least-privilege-scopes))
so a poller, a checkout server, and a payout job never contend for the same budget.
## Make every handler idempotent
Deliveries are **at-least-once**, and your webhook and your poll are two code paths that converge on the
same settlement. Without act-once semantics you double-fulfill.
- **De-dupe on `event.id`** with a durable unique index (`INSERT … ON CONFLICT DO NOTHING`), not an
in-memory set. Key on the top-level `event.id`, not on your order reference — many events share one
reference.
- **Make the state transition itself idempotent** as a second line of defense: flip `OPEN → PAID` and
no-op if it's already paid. A poll and a webhook are different code paths; don't rely on the de-dupe
table alone.
- **Return non-2xx on transient failures.** A `5xx` or timeout is retried with backoff, so you'll get
the event again — that's the safety net. Never swallow an error to force a 2xx.
See [Webhooks](/webhooks) for the full handler and [Errors](/errors) for retry semantics.
## Crypto settlement is final — idempotency is not optional
On-chain settlement is irreversible: no chargeback, no recall. A duplicate payout is lost funds. So
every money-moving `POST` (payouts, refunds, conversions, off-ramp, gift cards, plan/subscription
create) must carry an [`Idempotency-Key`](/errors#idempotency).
- Generate **one key per operation**, store it on your order row *before* the request, and reuse it on
every retry. The SDKs pass it via `idempotencyKey` / `idempotency_key=` / `IdempotencyKey`.
- The server de-dupes on the key **plus the request body**: same key + same body replays the original
response; same key + different body → `409 idempotency_conflict`; same key still running →
`409 idempotency_in_progress`.
- Only definitive responses (`2xx`, or a non-`429` `4xx`) are cached — a transient `5xx` or `429`
**releases** the claim, so a same-key retry genuinely re-runs (it won't replay a stale error). Never
generate a fresh key on retry; that defeats the mechanism.
## Handle errors and rate limits like a good citizen
Retry `429` and `5xx` (and network-level failures) with **exponential backoff + jitter**, capped. Reads
and idempotent writes retry freely; non-idempotent writes retry **only with the same idempotency key**.
The full retry helper in three languages is in [Errors](/errors#rate-limits--retrying-safely).
- **Cap concurrency under the per-key bucket.** Steady-state throughput = your concurrency ÷ round-trip
time; keep it under 100 rps per key. For more headroom, spread across keys.
- **Reads and writes are independent buckets** — a read burst won't throttle your writes, so you don't
need to co-throttle them.
- **A limiter outage fails open.** The absence of `429`s during an incident is not a green light to
hammer — you can mask a real overload.
- **Sandbox still enforces the API rate limit.** It only relaxes the sign-up / verification throttles,
so it's the right place to validate your backoff and queueing before going live.
## Integrate correctly
### Use the official SDK
The [Node / Python / Go SDKs](/sdk) compute the timestamp, nonce, and request signature on every call,
raise one typed error, and pass idempotency keys. Hand-rolled signing is the single most common
integration footgun — the SDK removes the entire "sign exactly what you send" failure class. SDKs are
**server-side only**.
If you must sign raw HTTP ([Authentication](/authentication)):
- Keep the host clock **NTP-synced** — the signature timestamp must be within **±5 minutes** of ours,
in **milliseconds**, reused in both the header and the signed string.
- Generate a **fresh random nonce per request** (single-use). A retry is a new request → new nonce and
timestamp; idempotency is carried by the `Idempotency-Key`, never the nonce.
- Sign the **exact bytes** you transmit — the canonical string binds method + full path & query +
body hash. Re-serializing the JSON or appending a query param after signing → `401`.
### Least-privilege scopes
Issue **one app per integration surface** (checkout server, reconciliation reader, payout job), each
with the minimum scopes. A `:read` never needs a `:write`. `account:admin` and app-management scopes
are **not grantable** to a tenant key by design, so a leaked app key can only ever act within its own
workspace and its granted scopes — minimize both. One-app-per-surface also gives each surface its own
rate-limit bucket. See [Authentication → Scopes](/authentication).
### Choose the right flow, and reuse deposit addresses
| Flow | Use it when |
| --- | --- |
| **Checkout** (`POST /v1/checkouts`) | Hosted link, the payer picks asset + network on the page. |
| **Invoice** (`POST /v1/invoices`, `chain` required) | You fix the amount and network and want the deposit address up front. |
| **Deposit address** (`POST /v1/deposits/address`) | A permanent top-up address for **your own** workspace balance — not a per-order collection address. |
Deposit addresses are **permanent per (workspace, chain)**: `POST /v1/deposits/address` is
get-or-create — it returns the stored address if one exists and mints only on first ask. Call it once
and cache the result; don't mint a new address per top-up. See [Accept payments](/accept-payments),
[Payment links](/payment-links), and [Deposits](/deposits).
The app that **creates** a checkout or invoice is the app its `payment.succeeded` webhook routes to.
If you create the order with app A but listen on app B's webhook endpoint, you'll never get the event.
Create and listen on the same app.
### Paginate with cursors, resume from the same cursor
Every list returns `{ items, nextCursor }`. Loop, echoing `nextCursor` back as `before`, until it's
`null`. The cursor is **opaque** — never build or parse it. On a long scan, a `429` mid-way → back off
and resume from the **same** cursor (don't restart from page one). See [Pagination](/pagination).
## Guard your secrets
You hold three secrets per app, each with a different job. All three are shown **once**, live
server-side only, and should be loaded from a secret manager — never a browser bundle, never git, never
a log line.
| Secret | Prefix | Job |
| --- | --- | --- |
| API token | `ap_live_` / `ap_test_` | `Authorization: Bearer` — identifies the app |
| Signing secret | `apisign_` | HMAC over each outbound request (integrity + anti-replay) |
| Webhook secret | `whsec_` | verifies the webhooks we send you |
- **Rotate** periodically. Rotating the token keeps the app approved; losing a secret means rotate to
get fresh ones.
- **Separate `ap_test_` and `ap_live_`** keys per environment, and one app per integration so a leak is
contained.
- **Verify every webhook** before trusting it, in this order: recompute the HMAC on the **raw bytes**,
compare **timing-safe**, reject stale timestamps (replay), de-dupe on `event.id`, **durably record
the event, then return 2xx** — never ack before you've persisted it. The SDK's
`constructEvent` does verify + freshness + parse in one call.
- **Pin your origin** with the optional per-app **IP allowlist** (CIDRs), and point us at a real public
HTTPS endpoint — deliveries are SSRF-guarded, so private/loopback/metadata targets are rejected.
Editing a live app re-triggers review, and it can't authenticate until re-approved (rotating the
token does **not** re-trigger review). Configure the production webhook URL and IP allowlist in one
edit **before** you go live, so a cutover doesn't lock you out mid-flight.
## Activation is asynchronous — handle "setting up"
A workspace can only move money once it's **active**, which needs **both** KYC/KYB cleared **and** its
payment account provisioned. Provisioning happens **asynchronously after** KYC clears, so activation is
not instant. The lifecycle is:
```
draft → verifying → provisioning → active (frozen = held)
```
Before a workspace is `active`, money and balance calls return **`403`** (e.g. "not active yet" while
provisioning, or "finish verification" before that). Don't hard-code an assumption that a freshly
created workspace can accept payments.
- **Treat a `403` during onboarding as "not ready yet"** — poll the workspace status and surface a
"setting up" state to your own users rather than erroring out.
- Complete KYC/KYB and wait for `active` before pointing production traffic at a workspace.
- Know that an abnormal payout burst can **freeze** the account (`429 velocity_frozen`) — design your
payout flow around the webhook + poll, not the create response.
## Money amounts
- **Decimal strings end to end.** Represent every amount as a decimal string (≤ 6 fraction digits),
from your database through the request to display. Never rehydrate it as a float — no `parseFloat`,
no JS `Number`, no naive `toFixed` on the money path. The API rejects floats, scientific notation,
and negatives.
- **Match currency *and* chain exactly** against the supported options before submitting — an
unsupported pair fails the order, and funds sent on the wrong network are unrecoverable.
- **Know gross vs net.** The fee is a disclosed markup (`fee = network fee + margin`), so the settled
amount differs from the order amount. Call [`fees.preview`](/fees) right before creating an order to
show the exact split — it's an indicative quote, not a locked rate.
- **Delivered can differ from requested on a `PARTIAL` payout batch** — reconcile per suborder, not just
per batch. See [Payouts](/payouts).
## Before you go live
Everything above rolls up into the [Going live](/going-live) checklist — verified workspace, approved
app, secrets from env, request signing, raw-body webhook verification + idempotency, `Idempotency-Key`
on money moves, decimal-string amounts, backoff on `429`/`5xx`, webhook URL + IP allowlist set before
approval, and scheduled reconciliation with monitoring. Run through it before flipping to live keys.
## Next
- [Webhooks](/webhooks) — verify and handle deliveries reliably.
- [Errors](/errors) — retry, idempotency, and rate-limit semantics.
- [Balances & reconciliation](/balances-reconciliation) — the scheduled backstop.
- [Going live](/going-live) — the pre-launch checklist.
---
# Pagination
URL: /pagination
Every list endpoint uses the **same** model. There is one envelope and one way to page — no `offset`,
no `page` numbers anywhere:
```json
{
"items": [ /* … */ ],
"nextCursor": "eyJpZCI6IjAxSlgwUTdBQiIsInRzIjoxNzE5NzkyMDAwMDAwfQ"
}
```
- **`items`** — this page's rows.
- **`nextCursor`** (response) — an **opaque cursor the API returns**. Send it back as `before` to get
the next page. When it comes back **`null`**, you've reached the last page.
- **`limit`** (query) — caps how many items a page returns.
- **`before`** (query) — the cursor for the next page. Omit it for the first page.
- **`order`** (query) — `asc` \| `desc` sort direction.
Some settled-history lists (`reconciliation`, `refunds`, `conversions`) additionally return a `total`
alongside `items`/`nextCursor`. Small **reference** lists (networks, templates, options, plans,
balances) return everything in one page as `{ items, nextCursor: null }`.
`nextCursor` is opaque — treat it as a black box. Just echo the exact value back as `before`.
It stays stable even as new items arrive, so you won't skip or double-read rows mid-scan.
## Page through everything
```ts
let before: string | undefined;
do {
const page = await ap.checkouts.list({ limit: 50, before, order: "asc" });
for (const item of page.items) handle(item);
before = page.nextCursor ?? undefined; // null → stop
} while (before);
```
```python
before = None
while True:
page = ap.checkouts.list(limit=50, before=before, order="asc")
for item in page["items"]:
handle(item)
before = page.get("nextCursor")
if not before: # null → stop
break
```
```go
q := absolutepay.PageQuery{Limit: 50, Order: "asc"}
for {
page, err := ap.Checkouts.List(ctx, q)
if err != nil {
log.Fatal(err)
}
for _, item := range page.Items {
_ = item
}
if page.NextCursor == nil {
break
}
q.Before = *page.NextCursor
}
```
```bash
# First page:
curl "https://api.absolutepay.io/v1/checkouts?limit=50" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/checkouts?limit=50" "")
# Next page: pass the previous response's nextCursor as ?before=
curl "https://api.absolutepay.io/v1/checkouts?limit=50&before=eyJpZCI6IjAxSlgwUTdBQi…" \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET "/v1/checkouts?limit=50&before=eyJpZCI6IjAxSlgwUTdBQi…" "")
```
## Paginated endpoints
Every list below returns `{ items, nextCursor }` and pages with `limit` + `before`:
- `GET /v1/checkouts` · `ap.checkouts.list()`
- `GET /v1/invoices` · `ap.invoices.list()`
- `GET /v1/deposits` and `/v1/deposits/addresses` · `ap.deposits.list()` / `ap.deposits.addresses()`
- `GET /v1/refunds` · `ap.refunds.list()` — carries `total`
- `GET /v1/conversions` · `ap.conversions.list()` — carries `total`
- `GET /v1/reconciliation/payments` and `/v1/reconciliation/withdrawals` — carry `total`
- `GET /v1/subscriptions` · `ap.subscriptions.list()`
- `GET /v1/giftcards` · `ap.giftcards.list()`
- `GET /v1/offramp/orders` · `ap.offramp.orders()`
Reference lists — `GET /v1/balances`, `/v1/deposits/chains`, `/v1/payouts/options`,
`/v1/subscription-plans`, `/v1/giftcards/templates`, `/v1/offramp/countries`, `/v1/offramp/banks` —
use the same shape but return one page with `nextCursor: null`.
## Next
- [Balances & reconciliation](/balances-reconciliation) — the settled history lists.
- [Payment links](/payment-links) — `ap.checkouts.list()`, the list you'll page most often.
- [Errors](/errors) — a long paged scan should back off on `429` and resume from the same cursor.
- [API reference](/api) — each list endpoint's exact params and the `nextCursor` field.
---
# SDK setup
URL: /sdk
The official SDKs are the fastest way to integrate: they sign every request for you (HMAC over the
canonical string — see [Authentication](/authentication)), raise typed, retry-friendly errors, and
track the API contract. Prefer raw HTTPS? Every guide also shows the equivalent signed `curl`.
All SDKs are **server-side only** — your API key and signing secret must never reach a browser.
## Choose your language
Full, per-endpoint API reference for each SDK:
Don't see your language or framework? Request an SDK → — Node, Python, and Go ship today; tell us what to build next.
## Install
```bash
npm install absolutepay
```
Requires **Node 18+** (uses the global `fetch` and `node:crypto`).
```bash
pip install absolutepay
```
Requires **Python 3.9+**. Zero runtime dependencies — standard library only.
```bash
go get github.com/AbsolutePay/absolutepay-go
```
Requires **Go 1.18+**.
## Configure
```ts
import { AbsolutePay } from "absolutepay";
const ap = new AbsolutePay({
apiKey: process.env.ABSOLUTEPAY_API_KEY!, // ap_live_… / ap_test_…
signingSecret: process.env.ABSOLUTEPAY_SIGNING_SECRET!, // apisign_… — required for app keys
// sandbox: true, // → https://sandbox-api.absolutepay.io (default: production)
// baseUrl: "https://…", // optional, overrides `sandbox` entirely
// timeoutMs: 30000, // per-request timeout (default 30s)
});
```
```python
import os
from absolutepay import AbsolutePay
ap = AbsolutePay(
api_key=os.environ["ABSOLUTEPAY_API_KEY"], # ap_live_… / ap_test_…
signing_secret=os.environ["ABSOLUTEPAY_SIGNING_SECRET"], # apisign_… — required for app keys
# sandbox=True, # → https://sandbox-api.absolutepay.io (default: production)
# base_url="https://…", # optional, overrides `sandbox` entirely
# timeout=30.0, # per-request timeout in seconds (default 30)
)
```
```go
import (
"os"
"github.com/AbsolutePay/absolutepay-go"
)
ap, err := absolutepay.New(
os.Getenv("ABSOLUTEPAY_API_KEY"), // ap_live_… / ap_test_…
absolutepay.WithSigningSecret(os.Getenv("ABSOLUTEPAY_SIGNING_SECRET")), // apisign_…
// absolutepay.WithSandbox(true), // → https://sandbox-api.absolutepay.io
)
```
| Option | Required | Default | Notes |
| --- | --- | --- | --- |
| `apiKey` / `api_key` | yes | — | The app's bearer token (`ap_live_` or `ap_test_`). |
| `signingSecret` / `signing_secret` | yes for app keys | — | The `apisign_` secret. The SDK signs every request with it. |
| `sandbox` | no | `false` | `true` → the dedicated sandbox host. Ignored if a base URL is set. |
| `baseUrl` / `base_url` | no | production | Override the API origin entirely. |
| `timeoutMs` / `timeout` | no | 30s | Aborts a request after this long (ms in Node, seconds in Python). |
Never hard-code the API key or signing secret. Load them from the environment (or a secrets
manager) so they never land in your repo or a browser bundle.
## First call
Read your balances — the simplest call to confirm your key + signing work:
```ts
const balances = await ap.balances.list();
console.log(balances); // [{ currency: "USDT", available: "1000.000000", locked: "0.000000" }]
```
```python
balances = ap.balances.list()
print(balances) # [{"currency": "USDT", "available": "1000.000000", "locked": "0.000000"}]
```
```go
balances, err := ap.Balances.List(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(balances) // []absolutepay.Balance{{USDT 1000.000000 0.000000}}
```
```bash
# sign() emits the three signing headers — see the Authentication guide for the helper.
curl https://api.absolutepay.io/v1/balances \
-H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/balances "")
```
A test workspace starts with sandbox funds, so you'll see a balance right away.
## Handle errors
Non-2xx responses raise `AbsolutePayError` with the platform's problem fields:
```ts
import { AbsolutePay, AbsolutePayError } from "absolutepay";
try {
await ap.payouts.create({ items: [/* … */] });
} catch (e) {
if (e instanceof AbsolutePayError) {
console.error(e.status, e.code, e.title, e.detail, e.requestId);
if (e.isRateLimited) { /* back off and retry */ }
if (e.isAuth) { /* check key / scopes / signature */ }
}
}
```
```python
from absolutepay import AbsolutePayError
try:
ap.payouts.create([...])
except AbsolutePayError as e:
print(e.status, e.code, e.detail, e.request_id)
if e.is_rate_limited: # back off and retry
...
if e.is_auth: # check key / scopes / signature
...
```
```go
_, err := ap.Payouts.Create(ctx, []absolutepay.PayoutItem{/* … */})
var apErr *absolutepay.Error
if errors.As(err, &apErr) {
fmt.Println(apErr.Status, apErr.Code, apErr.Detail, apErr.RequestID)
if apErr.IsRateLimited() { /* back off and retry */ }
if apErr.IsAuth() { /* check key / scopes / signature */ }
}
```
See [Error handling](/errors) for the retry strategy and the full error shape.
## Low-level escape hatch
Every resource method wraps a low-level `request(...)` that signs and sends for you. Use it directly
for any endpoint without a dedicated helper:
```ts
const data = await ap.request("GET", "/v1/reconciliation/payments?limit=50");
```
```python
data = ap.request("GET", "/v1/reconciliation/payments?limit=50")
```
```go
// The Go client is fully typed — use the resource method, e.g.
page, err := ap.Reconciliation.Payments(ctx, absolutepay.ReconciliationQuery{Limit: 50})
if err != nil {
log.Fatal(err)
}
fmt.Println(page)
```
## Or: install the agent skill
If you build with an AI coding agent (Claude Code, Cursor, or anything that supports skills),
you can skip the manual wiring entirely — install the official
[AbsolutePay skill](https://github.com/AbsolutePay/absolutepay-skill) and let your agent do
the integration:
```bash
npx skills add AbsolutePay/absolutepay-skill
```
The skill teaches your agent to integrate AbsolutePay via these SDKs (or raw REST) with the
guardrails built in — request signing, webhook verification on the raw body, and idempotent
event handling. To also pull the bundled reference files, run
`npx github:AbsolutePay/absolutepay-skill`.
## Next
- [Quickstart](/quickstart) — create your first payment end to end.
- [Authentication](/authentication) — keys, signing, and scopes (what the SDK does for you).
- [API reference](/api) — every endpoint, parameters, and responses. Also the generated SDK references: [Node.js](https://absolutepay.github.io/absolutepay-js/) · [Python](https://absolutepay.github.io/absolutepay-python/) · [Go](https://pkg.go.dev/github.com/AbsolutePay/absolutepay-go).