# 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).