# Perly Pay docs > Accept bitcoin, USDC and USDT straight into your own wallet: payment links, a checkout, invoices or the API. 2.5¢ a payment, nothing taken from it. Perly Pay is a hosted, non-custodial payment service. Merchants accept bitcoin (on-chain), USDC on Base, Arbitrum, Optimism and Ethereum, USDT on Ethereum and TRON, and USDT0 (Tether's omnichain USDT) on Arbitrum and Optimism. There is no USDT on Base. They connect public keys only (Ledger, Trezor, a file, or pasted); every invoice gets an address derived from them, so money lands straight in the merchant's wallet. Perly Pay never holds funds and never asks for a seed or private key. Every page below is also its own Markdown file ([quick-start.md](https://pay.perly.io/docs/quick-start.md)); the summary with links is [llms.txt](https://pay.perly.io/llms.txt). Machine-readable API: [openapi.json](https://pay.perly.io/api/v1/openapi.json). - [Perly Pay for developers who build with AI](https://pay.perly.io/developers): a prompt to paste into Claude Code, Cursor or Codex, the ways to install Perly Pay where you write code, and five recipes. - Every docs page is also Markdown: add `.md` to its address ([webhooks.md](https://pay.perly.io/docs/webhooks.md)), or ask for it with `Accept: text/markdown`. The docs home: [docs.md](https://pay.perly.io/docs.md). - [Agent Skills](https://pay.perly.io/.well-known/agent-skills/index.json): one SKILL.md per integration (perly-pay-quick-start, perly-pay-checkout, perly-pay-payment-links, perly-pay-subscriptions, perly-pay-webhooks, perly-pay-sdks, perly-pay-woocommerce, perly-pay-whmcs, perly-pay-make, perly-pay-add-crypto-checkout, perly-pay-sell-credits, perly-pay-sell-subscription, perly-pay-sell-license-keys, perly-pay-sell-bot-access, perly-pay-paid-mcp-tools, perly-pay-test-and-go-live). - [Claude Code plugin](https://pay.perly.io/docs/recipes): Eight skills for Claude Code (also as Cursor rules and an AGENTS.md block): a crypto checkout, credits per request, subscriptions renewed by invoice, license keys, paid bots, paid MCP tools, webhooks, and going live. `claude plugin marketplace add volodymyrslipak/perly-pay-ai && claude plugin install perly-pay@perly` - [perly-pay-mcp](https://pay.perly.io/docs/mcp): A local MCP server (stdio) for Claude Code, Cursor and Codex: an assistant makes products, prices, payment links and invoices, pays them in test mode and reads payments, with your key on your machine; with a live key it only reads. `claude mcp add perly-pay --env PERLY_PAY_SECRET_KEY=sk_test_… -- npx -y perly-pay-mcp` - [npx perly-pay init](https://pay.perly.io/docs/quick-start-npx): Sets up Perly Pay in a Next.js, Express, Hono, Laravel or Node project in test mode: a checkout route, a webhook route that checks signatures, a Pay button, and the keys in .env (kept out of git). `--yes` asks nothing, so an assistant can run it. `npx perly-pay init --yes` --- # Quick start > Your first payment in five minutes — first without code, then with two calls and a webhook. Everything here works in **test mode**: test networks, test coins, nothing real moves. Switch to live when it feels right. > **Note:** Rather see it? [The same, step by step in pictures](https://pay.perly.io/integrate). ## Without code: a payment link 1. **Sign up** — [Start free](https://pay.perly.io/login) with your email. No password — we send a code. 2. **Connect a wallet** — Dashboard → Wallets. Add the public key from your Ledger or Trezor. See [Wallets](https://pay.perly.io/docs/wallets.md). 3. **Make a product** — Dashboard → Products. A name and a price is enough. 4. **Make a link** — Dashboard → Links → New. Copy the `/l/…` address. 5. **Pay it** — Open the link, pick a coin, and press **Simulate payment**. The invoice turns **Paid** and a receipt is sent. > **Note:** That's a real checkout. Share the same kind of link in live mode and buyers pay straight to your wallet. ## With code: two calls Get a test key in Dashboard → Developers. It starts with `sk_test_`. Keep it on your server. **Node** ```bash npm install perly-pay ``` **PHP** ```bash composer require perly/perly-pay ``` **Node** ```js import { PerlyPay } from "perly-pay"; const pay = new PerlyPay(process.env.PERLY_PAY_SECRET_KEY); // sk_test_… // 1. Make an invoice (amounts in cents) const invoice = await pay.invoices.create({ amount: 2400, currency: "USD", description: "1,000 API credits", customer: { email: "ada@example.com" }, metadata: { orderId: "1042" }, }); // 2. Send the buyer to pay it redirect(invoice.url); // https://pay.perly.io/i/ ``` **PHP** ```php $perly = new \Perly\Pay\PerlyPay(getenv('PERLY_PAY_SECRET_KEY')); // sk_test_… // 1. Make an invoice (amounts in cents) $invoice = $perly->invoices->create([ 'amount' => 2400, 'currency' => 'USD', 'description' => '1,000 API credits', 'customer' => ['email' => 'ada@example.com'], 'metadata' => ['orderId' => '1042'], ]); // 2. Send the buyer to pay it header('Location: ' . $invoice['url'], true, 303); ``` **curl** ```bash curl https://pay.perly.io/api/v1/invoices \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"amount":2400,"currency":"USD","description":"1,000 API credits"}' ``` The buyer picks bitcoin or a stablecoin, scans, pays. The money goes to an address made from **your** key. ## Know when it's paid: a webhook Add an endpoint in Dashboard → Developers → Webhooks and copy its `whsec_…` secret. Then check every delivery's signature before you trust it. **Node** ```js app.post("/webhooks/perly", express.raw({ type: "application/json" }), (req, res) => { const event = pay.webhooks.verify( req.body, // the raw bytes, not parsed JSON req.header("Perly-Signature"), process.env.PERLY_PAY_WEBHOOK_SECRET, ); // throws if the signature is wrong or old if (event.type === "invoice.fulfilled") { ship(event.data.object.metadata.orderId); } res.sendStatus(200); }); ``` **PHP** ```php $event = \Perly\Pay\Webhook::verify( file_get_contents('php://input'), // the raw body, not json_decode'd $_SERVER['HTTP_PERLY_SIGNATURE'] ?? null, getenv('PERLY_PAY_WEBHOOK_SECRET'), ); // throws if the signature is wrong or old if ($event['type'] === 'invoice.fulfilled') { ship($event['data']['object']['metadata']['orderId']); } http_response_code(200); ``` Both SDKs, what they cover and how they retry: [SDKs](https://pay.perly.io/docs/sdks.md). > **Note:** Ship on `invoice.fulfilled`, not on `invoice.paid`: fulfilled comes once, after screening. See [Webhooks](https://pay.perly.io/docs/webhooks.md). ## Go live - Connect your **live** wallet keys (mainnet). - Make a live key (`sk_live_…`) and a live webhook endpoint. - Test mode stays as it is, beside live. The two never mix. --- # Quick start with npx > One command sets Perly Pay up in your project: a checkout route, a webhook route that checks signatures, a Pay button and your test key. `npx perly-pay init` reads your project, writes the code for its framework, and leaves you one press from a test payment. It sets up **test mode** only; you go live in the dashboard. ## Run it ```bash npx perly-pay init ``` It finds the framework and the package manager, opens Dashboard → Developers and asks for a test key (`sk_test_…`). A live key is refused. ## What it writes | Framework | Found by | Files | | --- | --- | --- | | Next.js (App Router) | `next` | `lib/perly-pay.ts`, `app/api/perly-pay/checkout/route.ts`, `app/api/perly-pay/webhook/route.ts`, `components/PayButton.tsx`, `app/perly-pay/page.tsx` | | Express | `express` | `lib/perly-pay.js`, `routes/perly-pay.js` | | Hono | `hono` | `lib/perly-pay.ts`, `routes/perly-pay.ts` | | Laravel | `artisan`, `laravel/framework` | `app/Http/Controllers/PerlyPayController.php`, `routes/perly-pay.php`, `config/perly-pay.php`, `resources/views/perly-pay/button.blade.php` | | Node.js | any other `package.json` | `lib/perly-pay.js`, `perly-pay.js` (`node:http`) | TypeScript or JavaScript, ES modules or CommonJS, as your project is, and under `src/` when your code lives there. npm, pnpm, yarn, bun and Composer. - **Checkout.** The button posts to `/perly-pay/checkout` (`/api/perly-pay/checkout` in Next.js). It opens an invoice and sends the buyer to pay it. - **Webhook.** `/perly-pay/webhook` checks `Perly-Signature` on the raw body. On `invoice.fulfilled` it calls `grantAccess()`: the one TODO you fill in. - **Plans.** One-off sales and credit packs, in `PLANS` at the top of the core module. The plan, its credits and your user id travel in the invoice's metadata and come back in the webhook. - **Keys.** `PERLY_PAY_SECRET_KEY` and `PERLY_PAY_WEBHOOK_SECRET` in `.env.local` (Next.js) or `.env`, and that file in `.gitignore`. If git already tracks that file, init writes no key into it and says how to untrack it. > **Note:** init never replaces a file or a value that is already there. `--force` does. ## Connect the webhook Perly Pay has to reach the webhook URL. Add it in Dashboard → Developers → Webhooks for `invoice.fulfilled` and paste its `whsec_…` secret into `PERLY_PAY_WEBHOOK_SECRET`. Or let init do both: ```bash npx perly-pay init --webhook-url https://your-app.example/perly-pay/webhook ``` On your laptop, a tunnel gives a public URL: `cloudflared tunnel --url http://localhost:3000`. More in [Webhooks](https://pay.perly.io/docs/webhooks.md). ## Pay once, in test mode 1. **Start your app** — Express and Node read `.env` with `node --env-file=.env`, or with dotenv. 2. **Open /perly-pay** — Press **Pay with crypto**. You land on the checkout. 3. **Simulate payment** — The invoice turns paid, then fulfilled. See [Test mode](https://pay.perly.io/docs/test-mode.md). 4. **Watch grantAccess run** — Your webhook gets `invoice.fulfilled`. Now write what it grants. ## With an AI assistant `--yes` asks nothing, so Claude Code, Cursor or Codex can run it for you. Give it the key and what you sell: ```bash npx perly-pay init --yes --key sk_test_… --product "Logo review" --amount 4900 ``` `--json` prints the result for the assistant to read. ## Options | Flag | What it does | | --- | --- | | `--yes`, `-y` | No questions. For AI assistants and scripts. | | `--framework ` | `next`, `express`, `hono`, `laravel` or `node`, when the guess is wrong. | | `--key ` | Your test key. Default: `PERLY_PAY_SECRET_KEY`, or it asks. | | `--webhook-url ` | Registers the URL for `invoice.fulfilled` and saves its secret. | | `--product `, `--amount `, `--currency ` | What you sell, in USD or EUR. `--amount 4900` is $49.00. | | `--install` | Installs the SDK too. Otherwise the command is printed. | | `--force` | Replaces files and values that are already there. | | `--plugin`, `--mcp` | Prints how to add the Claude Code plugin and the `perly-pay-mcp` server. | | `--dry-run` | Says what would happen, writes nothing. | | `--json` | Prints the result as JSON. | | `--api `, `--dir ` | Another Perly Pay (a local one), another folder. | ## Go live - Connect your **live** wallet keys in Dashboard → Wallets. - Make a live key (`sk_live_…`) and a live webhook endpoint. - Set them where your app runs. The code stays as it is. [SDKs](https://pay.perly.io/docs/sdks.md) has the rest of the API. --- # Payment links > One address that sells one thing. Share it anywhere, no code. A payment link is `pay.perly.io/l/your-slug`. Anyone who opens it gets a fresh invoice and a fresh address — two buyers never share one. ## Make one 1. **Pick a price** — Dashboard → Links → New, then choose a product's price — any of the [six price models](https://pay.perly.io/docs/price-models.md). 2. **Set the options** — Quantity, coupons, what to ask the buyer (email, name), where to send them after paying. 3. **Share it** — Copy the link or its QR code. Put it in a bio, an email, a menu, a PDF. ## What the buyer sees 1. Your name, the item and its price. 2. A coin and a network — bitcoin first. 3. A QR code, an exact amount and a 30-minute timer. 4. Arriving, then Paid. A receipt by email. > **Note:** The price is locked for 30 minutes. A late payment still lands in your wallet; we value it at that day's rate and mark it for review. ## Turn one off Archive a link and it stops opening new invoices. Invoices it already opened keep watching for their money. --- # Hosted checkout and pay.js > Send buyers to our checkout, or open it over your page with one script. Every invoice has a page: `pay.perly.io/i/{id}`. It is phone-first, works without an account and speaks the buyer's language. You never build a payment form. ## Redirect Create an invoice on your server and send the buyer to its `url`. Pass `return_url` to bring them back when it's paid. ```js const invoice = await pay.invoices.create({ amount: 4800, currency: "USD", description: "Pro, one month", return_url: "https://example.com/thanks?order=1042", }); redirect(invoice.url); ``` ## Overlay with pay.js First allow your site: **Settings → Checkout → Sites that may embed the checkout**, one origin per line (`https://yourshop.com`). The checkout opens over the sites listed there only; anywhere else the button opens it in a new tab. Then add the script once. Any element with `data-perly-pay` opens that payment link over your page. **index.html** ```html ``` The value is a payment link's slug. The overlay closes itself when the invoice is paid; your webhook still decides what to ship. > **Note:** pay.js never sees keys or money. It only opens our checkout in a frame. ## Which to use | You want | Use | | --- | --- | | No code at all | [A payment link](https://pay.perly.io/docs/payment-links.md) | | A button on your site | pay.js | | A cart with its own total | An invoice from the API, then redirect | --- # WooCommerce > A payment method for WooCommerce: bitcoin and USDC/USDT straight to your wallet, orders marked paid by a signed webhook. The Perly Pay plugin adds crypto to the classic checkout and the Checkout block. Each order becomes an invoice in Perly Pay; the buyer pays it over your store or on its own page; a signed webhook marks the order paid. WordPress 6.5+, WooCommerce 8.6+, PHP 8.1+. Nothing to install on the server: the [PHP SDK](https://pay.perly.io/docs/sdks.md) is inside. > **Note:** Invoices are in USD or EUR. In a store with another currency the payment method stays hidden, and its settings say why. ## Install 1. **Get the zip** — The plugin is on its way to WordPress.org. Until it is listed, [write to us](https://pay.perly.io/contact) for `perly-pay-woocommerce.zip`. 2. **Install** — In WordPress: Plugins → Add New → Upload Plugin, choose the zip, activate. WooCommerce must be active. 3. **Open the settings** — WooCommerce → Settings → Payments → **Perly Pay**. ## Settings | Setting | What to put | | --- | --- | | Secret key | From Dashboard → Developers → API keys. `sk_test_…` for [test mode](https://pay.perly.io/docs/test-mode.md), `sk_live_…` for real money. The settings say which mode you are in. | | Webhook signing secret | `whsec_…`. **Set up webhook** fills it in for you. | | Payment page | **Overlay** opens the checkout over your store (pay.js). **Redirect** sends the buyer to it. Phones always get the page itself. | | Status when paid | WooCommerce's own (Processing; Completed for downloads), always Processing, or always Completed. | | Title, Description | What buyers see at checkout. | | Debug log | Events and errors in WooCommerce → Status → Logs. Never keys, secrets or buyers' details. | 1. **Paste the key** — Then press **Test connection**: it says whether the key works, its mode, and whether this store's webhook is in Perly Pay. 2. **Set up the webhook** — Press **Set up webhook**. It adds the store's address (shown above the buttons, `…/wp-json/perly-pay/v1/webhook`) in Perly Pay and saves its secret. Or add it yourself in Developers → Webhooks and paste the secret. 3. **Allow the overlay** — In Dashboard → Settings, add the store's address to **Sites that may embed the checkout**. Without it the overlay offers to open the checkout in a new tab. 4. **Enable** — Tick **Take crypto with Perly Pay** and save. ## Test With a `sk_test_…` key, place an order in your store and choose Perly Pay. The order waits as **Pending payment**. Pay the invoice with test coins, or from the API — its id is in the order's notes: ```bash curl -X POST https://pay.perly.io/api/v1/invoices/inv_…/simulate \ -H "Authorization: Bearer $PERLY_PAY_SECRET_KEY" -H "Content-Type: application/json" -d '{}' ``` Within seconds the order is **Processing**, with a note naming the network, the amount and a link to the transaction. `{"outcome":"short"}` and `{"outcome":"over"}` try the other cases once a coin is chosen on the invoice page. ## How orders follow payments | In Perly Pay | The order | | --- | --- | | Paid and the sender checked (`invoice.fulfilled`) | Paid: your chosen status, stock reduced, the buyer's email. A note with each transfer and its transaction. | | Seen, part paid, paid but still screening | A note. Still pending. | | Held for [review](https://pay.perly.io/docs/screening.md) | On hold, with the reason. Decide in Perly Pay. | | Expired or canceled, nothing paid | Cancelled. | | Expired with part paid | On hold: refund it or accept it in Perly Pay. | | Refunded | Refunded in full, or a note for part. | Each event is applied once, to the order it was made for, and a late "expired" never undoes a payment. When the buyer comes back to the thank-you page before the webhook, the plugin asks the API and updates the order then. ## Refunds Refund from the order screen as usual: the plugin opens a [refund](https://pay.perly.io/docs/refunds.md) in Perly Pay, in the coin that was paid (part of it for a partial refund). Perly Pay emails the buyer for an address and checks it; the order's notes then say what to send where. You send from your wallet and paste the transaction id in Perly Pay — Perly Pay never holds the money. ## Go live - Turn on live mode in Perly Pay and connect your wallet's public keys. - Paste a `sk_live_…` key. Live keys and webhooks are separate from test ones: press **Set up webhook** again. - Add your live store's https address to **Sites that may embed the checkout** in live mode. - Place one small real order and refund it. --- # PrestaShop > A payment module for PrestaShop: buyers pay in bitcoin or USDC/USDT straight to your wallet, and a signed webhook makes the order. The Perly Pay module adds **Pay with crypto** to your checkout. The cart becomes an invoice in Perly Pay; the buyer pays it on its own page or over your shop; the order is made once money is on its way. PrestaShop 8.0 to 9.x, PHP 8.1+ with curl. Nothing to install with Composer: the [PHP SDK](https://pay.perly.io/docs/sdks.md) is inside. > **Note:** Perly Pay bills in USD or EUR. A cart in another currency does not see the payment option, and the settings page names those currencies. Payment → Preferences narrows the currencies, countries and groups as for any payment module. ## Install 1. **Get the module** — Download `perlypay.zip` from the latest release on [GitHub](https://github.com/volodymyrslipak/perly-pay-prestashop/releases/latest). 2. **Upload** — In the back office: Modules → Module Manager → **Upload a module**, and drop `perlypay.zip`. 3. **Configure** — Press **Configure** on Perly Pay. ## Settings | Setting | What to put | | --- | --- | | Secret key | From Dashboard → Developers → API keys. `sk_test_…` for [test mode](https://pay.perly.io/docs/test-mode.md), `sk_live_…` for real money. Full access, or Restricted with **Webhook endpoints** ticked. | | Webhook signing secret | `whsec_…`. **Set up webhook** fills it in for you. | | Payment page | **Redirect** (the default) sends the buyer to the payment page on pay.perly.io. **Overlay** opens it over the shop. Phones always get the page itself. | | Open refunds in Perly Pay | On by default: a credit slip on an order Perly Pay paid opens a refund there. | | Debug log | Ids and outcomes to Advanced Parameters → Logs. Never keys, secrets or customers' details. | 1. **Paste the key** — Save, then press **Test connection**: it says whether the key works, its mode, and whether this shop's webhook is in Perly Pay. 2. **Set up the webhook** — Press **Set up webhook**. It adds the shop's address, `https://your-shop/module/perlypay/webhook`, in Perly Pay with the invoice and refund events and saves its secret. Or add it yourself in Developers → Webhooks and paste the secret. 3. **Allow the overlay** — For **Overlay**, add the shop's address in Dashboard → Settings → Checkout → **Sites that may embed the checkout**. ## What buyers pay with The buyer picks a coin and network on the payment page and pays from any wallet. The money goes straight to yours. | Coin | Networks | | --- | --- | | BTC | Bitcoin | | USDC | Base, Arbitrum, Optimism and Ethereum | | USDT0 | Arbitrum and Optimism | | USDT | Ethereum and TRON | > **Note:** The module is free and open source (MIT). Perly Pay costs 2.5¢ a payment, never more than 0.5% of it, billed after the month. A month of up to 21 payments costs $0. Nothing is taken from the payment: see [Pricing](https://pay.perly.io/pricing). ## Test With a `sk_test_…` key, buy something with **Pay with crypto**. Pay with test coins, or from the API — the invoice id `inv_…` is in the payment page's address: ```bash curl -X POST https://pay.perly.io/api/v1/invoices/inv_…/simulate \ -H "Authorization: Bearer $PERLY_PAY_SECRET_KEY" -H "Content-Type: application/json" -d '{}' ``` Within seconds the order is **Payment accepted** and the customer has the confirmation email. ## How orders follow payments No order is made when the buyer leaves for the payment page, so abandoned crypto checkouts do not fill the back office. The first webhook that says money is on its way makes the order, for the amount Perly Pay billed. | In Perly Pay | The order | | --- | --- | | Seen, not yet confirmed | **Awaiting crypto confirmation**, one of the module's two order states. | | Paid and the sender checked (`invoice.fulfilled`) | **Payment accepted**. The invoice id is the payment's transaction id, the customer gets PrestaShop's emails, and a private message names the network, the coin amount and the transaction. | | Held for [review](https://pay.perly.io/docs/screening.md) | **Crypto payment held**. Decide in Perly Pay. | | Part paid, overpaid or paid late | A private message on the order. | | Expired with part paid | Held. | | Expired, nothing paid | No order. The cart stays as it was. | | A seen payment that never arrived | **Payment error**. | A webhook only names the invoice: the module asks Perly Pay's API for it with the shop's own key, and that answer decides the order. Each event is applied once, to the cart it was made for. When the buyer comes back before the webhook, the module asks the API and applies the same rules. ## Refunds Give a credit slip as usual (Partial refund, Standard refund, Return products): the module opens a [refund](https://pay.perly.io/docs/refunds.md) in Perly Pay for the slip's amount, in the coin that was paid. Perly Pay emails the customer for an address; you send from your wallet and paste the transaction id in Perly Pay. The order's messages say each step. A slip given as a voucher opens no refund. A full refund made in Perly Pay marks the order **Refunded**. ## Go live - Turn on live mode in Perly Pay and connect your wallet's public keys. - Paste a `sk_live_…` key and press **Set up webhook** again: a live key makes a live webhook. - For **Overlay**, add the shop's https address to **Sites that may embed the checkout** in live mode too: test and live keep separate lists. - Perly Pay sends webhooks only to `https://` addresses. - Sell one small order for real and refund it with a credit slip. --- # WHMCS > A payment gateway for WHMCS: clients pay invoices in bitcoin or USDC/USDT straight to your wallet, marked paid by a signed webhook. The Perly Pay module adds crypto to your WHMCS invoices. The client presses **Pay with crypto**; the invoice's balance becomes an invoice in Perly Pay, paid over the WHMCS page or on its own; a signed webhook records the payment in WHMCS. WHMCS 8, PHP 8.1+ with curl. Nothing to install with Composer: the [PHP SDK](https://pay.perly.io/docs/sdks.md) is inside. > **Note:** Perly Pay bills in USD or EUR. For clients in another currency, set the gateway's **Convert To For Processing** to USD or EUR; the payment is still recorded in the invoice's own currency. Without it, those clients see a note to choose another way to pay. ## Install 1. **Get the zip** — The module is on its way to the WHMCS Marketplace. Until it is listed, [write to us](https://pay.perly.io/contact) for `perly-pay-whmcs.zip`. 2. **Upload** — Unzip it over your WHMCS folder. It adds `modules/gateways/perlypay.php`, `modules/gateways/callback/perlypay.php` and `modules/gateways/perlypay/`. 3. **Activate** — Configuration → Apps & Integrations → Payment Gateways (or Setup → Payment Gateways), activate **Perly Pay**. ## Settings | Setting | What to put | | --- | --- | | Secret key | From Dashboard → Developers → API keys. `sk_test_…` for [test mode](https://pay.perly.io/docs/test-mode.md), `sk_live_…` for real money. | | Webhook signing secret | `whsec_…`, from the webhook you add for this WHMCS (below). | | Payment page | **Overlay** opens the checkout over the invoice (pay.js). **Redirect** sends the client to it. Phones always get the page itself. | | Currencies | USD and EUR, or only one of them. | 1. **Paste the key** — Save, then press **Test connection** in the key's field: it says whether the key works, its mode, and whether this WHMCS's webhook is in Perly Pay. 2. **Add the webhook** — In Dashboard → Developers → Webhooks, add the address the settings show, `https://your-whmcs/modules/gateways/callback/perlypay.php`, with the invoice and refund events. Paste its secret and save. 3. **Allow the overlay** — In Dashboard → Settings, add your WHMCS address to **Sites that may embed the checkout**. Without it the overlay offers to open the checkout in a new tab. ## Test With a `sk_test_…` key, open an unpaid invoice in the client area and press **Pay with crypto**. Pay it with test coins, or from the API — the invoice's id is on the checkout page: ```bash curl -X POST https://pay.perly.io/api/v1/invoices/inv_…/simulate \ -H "Authorization: Bearer $PERLY_PAY_SECRET_KEY" -H "Content-Type: application/json" -d '{}' ``` Within seconds the WHMCS invoice is **Paid**. Billing → Gateway Log shows the network, the amount and a link to the transaction. ## How invoices follow payments | In Perly Pay | In WHMCS | | --- | --- | | Paid and the sender checked (`invoice.fulfilled`) | A payment for the balance the client was billed, in the invoice's currency, under the Perly Pay payment id (`pay_…`). WHMCS marks the invoice Paid and does the rest as usual. | | Seen, part paid, paid but still screening | A Gateway Log line. No payment. | | Held for [review](https://pay.perly.io/docs/screening.md) | A Gateway Log line with the reason. Decide in Perly Pay. | | Expired or canceled | A Gateway Log line. Pressing the button again makes a new invoice. | | Refund opened, ready, sent, confirmed | A Gateway Log line, with what to send where. | Pressing the button again reuses the open invoice while the balance is the same. Each payment is recorded once, however often the webhook arrives, and only for invoices this WHMCS made. ## Refunds Refund the transaction in WHMCS as usual: the module opens a [refund](https://pay.perly.io/docs/refunds.md) in Perly Pay, in the coin that was paid (the same share of it for part). Perly Pay emails the client for an address and checks it; the Gateway Log then says what to send where. You send from your wallet and paste the transaction id in Perly Pay — Perly Pay never holds the money. ## Go live - Turn on live mode in Perly Pay and connect your wallet's public keys. - Paste a `sk_live_…` key. Live keys and webhooks are separate from test ones: add the callback address again in live mode and paste its secret. - Add your WHMCS's https address to **Sites that may embed the checkout** in live mode. - Pay one small real invoice and refund it. --- # Blesta > A payment gateway for Blesta: clients pay invoices in bitcoin or USDC/USDT straight to your wallet, recorded by a signed webhook. The Perly Pay gateway adds crypto to your Blesta invoices. The client chooses **Perly Pay** under Pay or in the order form; on "Review and confirm" the gateway makes an invoice in Perly Pay for exactly the amount Blesta worked out, and **Pay with crypto** opens it. A signed webhook records the payment in Blesta. Blesta 6, or Blesta 5 on PHP 8.1 or later, with PHP's `curl` and `json`. Nothing to install with Composer: the [PHP SDK](https://pay.perly.io/docs/sdks.md) is inside. > **Note:** Perly Pay bills in USD or EUR, and the gateway says so to Blesta, so Blesta offers it only on payments in those two currencies. ## Install 1. **Get the gateway** — Download `perly-pay-blesta.zip` from the latest release on [GitHub](https://github.com/volodymyrslipak/perly-pay-blesta/releases/latest). 2. **Upload** — Unzip it over your Blesta folder. It adds `components/gateways/nonmerchant/perly_pay/`. 3. **Install** — Settings → Company → Payment Gateways → Available, find **Perly Pay** and press **Install**. It makes one table, `perly_pay_invoices`. ## Settings | Setting | What to put | | --- | --- | | Secret key | From Dashboard → Developers → API keys. `sk_test_…` for [test mode](https://pay.perly.io/docs/test-mode.md), `sk_live_…` for real money. Full access, or Restricted with **Webhook endpoints** ticked: **Test connection** reads your webhooks. | | Webhook signing secret | `whsec_…`, from the webhook you add for this Blesta (below). Until it is saved, clients see no button. | | Payment page | **Over the Blesta page (overlay)** opens the checkout over Blesta's page. **On pay.perly.io (redirect)** sends the client to it. Phones always get the page itself. | 1. **Paste the key** — Save, then press **Test connection**: it says whether the key works, its mode, and whether this Blesta's webhook is in Perly Pay. 2. **Add the webhook** — In Dashboard → Developers → Webhooks, add the address the settings show, `https://your-blesta/callback/gw/1/perly_pay/` (`1` is the company), with the invoice and refund events. Paste its secret and save. 3. **Allow the overlay** — For the overlay, add your Blesta address in Dashboard → Settings → Checkout → **Sites that may embed the checkout**. Running several companies? Each has its own gateway, its own callback address (`/callback/gw//perly_pay/`) and its own webhook in Perly Pay. ## What clients pay with The client picks a coin and network on the payment page and pays from any wallet. The money goes straight to yours. | Coin | Networks | | --- | --- | | BTC | Bitcoin | | USDC | Base, Arbitrum, Optimism and Ethereum | | USDT0 | Arbitrum and Optimism | | USDT | Ethereum and TRON | > **Note:** The gateway is free and open source (MIT). Perly Pay costs 2.5¢ a payment, never more than 0.5% of it, billed after the month. A month of up to 21 payments costs $0. Nothing is taken from the payment: see [Pricing](https://pay.perly.io/pricing). ## Test With a `sk_test_…` key, pay an invoice in the client area with **Perly Pay**. Pay it with test coins, or from the API — the invoice id `inv_…` is on the checkout page: ```bash curl -X POST https://pay.perly.io/api/v1/invoices/inv_…/simulate \ -H "Authorization: Bearer $PERLY_PAY_SECRET_KEY" -H "Content-Type: application/json" -d '{}' ``` Within seconds the Blesta invoice is **Paid**. ## How invoices follow payments | In Perly Pay | In Blesta | | --- | --- | | Paid and the sender checked (`invoice.fulfilled`) | A transaction for the amount billed, applied to the invoices it pays, and the client is emailed, as for any payment. The transaction id is the Perly Pay invoice (`inv_…`), its reference the first transfer (`pay_…`), its note the coin and network. | | Part paid, expired, held for [review](https://pay.perly.io/docs/screening.md) | A line in the gateway log (Tools → Logs → Gateway). No payment. | | Refund events | A line in the gateway log, from the refund as Perly Pay's API returns it. | A payment Blesta already has is never handed over again, so a replayed webhook records nothing twice and sends no second email. When the client comes back, Blesta's "payment received" page shows the payment as approved or pending; the webhook records it. Crypto cannot be pulled from a wallet, so there are no automatic recurring charges: each invoice is paid when the client pays it. ## Refunds Refund the transaction in Blesta as usual: the gateway opens a [refund](https://pay.perly.io/docs/refunds.md) in Perly Pay, in the coin that was paid (the same share of it for part). Perly Pay emails the client for an address; you send from your wallet and paste the transaction id in Perly Pay. Blesta marks the transaction refunded once the refund is open. Blesta refunds a transaction once: refund the rest of the same payment from the Perly Pay dashboard. ## Go live - Turn on live mode in Perly Pay and connect your wallet's public keys. - Paste a `sk_live_…` key. Live keys and webhooks are separate from test ones: add the callback address again as a live webhook and paste its secret. - For the overlay, add your Blesta's https address to **Sites that may embed the checkout** in live mode too: test and live keep separate lists. - Pay one small real invoice and refund it. --- # FOSSBilling > A payment adapter for FOSSBilling: clients pay invoices in bitcoin or USDC/USDT straight to your wallet, paid by a signed webhook. The Perly Pay adapter adds crypto to your FOSSBilling invoices. The client opens an unpaid invoice, picks Perly Pay and presses **Pay Now**; the adapter makes an invoice in Perly Pay for the invoice's total with tax and opens it, over the page or on its own. A signed webhook pays the FOSSBilling invoice. FOSSBilling 0.8.4 or later, PHP 8.1+ with `curl` and `json`. Nothing to install with Composer: the [PHP SDK](https://pay.perly.io/docs/sdks.md) is inside. > **Note:** Perly Pay bills in USD or EUR. Set **Accepted Currencies** to those two; on an invoice in another currency the client is asked to choose another way to pay. ## Install 1. **Get the adapter** — Download `perly-pay-fossbilling.zip` from the latest release on [GitHub](https://github.com/volodymyrslipak/perly-pay-fossbilling/releases/latest). 2. **Upload** — Make the folder `library/Payment/Adapter/Perlypay/` in your FOSSBilling and unzip `perly-pay-fossbilling.zip` into it, so that `library/Payment/Adapter/Perlypay/Perlypay.php` exists. 3. **Install it** — System → Payment Gateways → **Add New Payment Gateway**: under **Install Locally**, install **Perlypay**. It shows as Perlypay until you give it a title in its settings. ## Settings | Setting | What to put | | --- | --- | | Test secret key | From Dashboard → Developers → API keys, in test mode: `sk_test_…`. Used while **Enable Test Mode** is on: test coins on test networks. | | Test webhook signing secret | The `whsec_…` secret of the test-mode webhook (below). | | Live secret key | The same page in live mode: `sk_live_…`. Takes real money. | | Live webhook signing secret | The `whsec_…` secret of the live-mode webhook. | | Payment page | **Over FOSSBilling (overlay)** opens the checkout over FOSSBilling's payment page. **On pay.perly.io (redirect)** sends the client there. Phones always get the page itself. | | Accepted Currencies | USD and EUR. | | Allow One Time Payments | On. Each invoice is paid on its own: there are no subscription payments. | 1. **Add the webhook** — In Dashboard → Developers → Webhooks, add the gateway's **Callback URL** (its settings page shows it: `https://your-fossbilling/ipn.php?gateway_id=…`) with the invoice and refund events. Do it once in test mode and once in live mode: each mode signs with its own secret. 2. **Paste the secrets** — Paste each webhook's secret in its field. **Send test** in Perly Pay puts a "Perly Pay test event" in FOSSBilling's Invoices → Transactions when the address and the secret are right. 3. **Allow the overlay** — For the overlay, add your FOSSBilling address in Dashboard → Settings → Checkout → **Sites that may embed the checkout**. FOSSBilling checks the settings when you enable the gateway: a missing key for the gateway's mode, or a live key in a test field, stops it there. ## What clients pay with The client picks a coin and network on the payment page and pays from any wallet. The money goes straight to yours. | Coin | Networks | | --- | --- | | BTC | Bitcoin | | USDC | Base, Arbitrum, Optimism and Ethereum | | USDT0 | Arbitrum and Optimism | | USDT | Ethereum and TRON | > **Note:** The adapter is free and open source (MIT). Perly Pay costs 2.5¢ a payment, never more than 0.5% of it, billed after the month. A month of up to 21 payments costs $0. Nothing is taken from the payment: see [Pricing](https://pay.perly.io/pricing). ## Test With **Enable Test Mode** on and the test key and secret saved, open an unpaid invoice as the client, choose Perly Pay and press **Pay Now**. Pay with test coins, or from the API — the invoice id `inv_…` is on the checkout page: ```bash curl -X POST https://pay.perly.io/api/v1/invoices/inv_…/simulate \ -H "Authorization: Bearer $PERLY_PAY_SECRET_KEY" -H "Content-Type: application/json" -d '{}' ``` Within seconds the FOSSBilling invoice is **Paid**. ## How invoices follow payments | In Perly Pay | In FOSSBilling | | --- | --- | | Paid and the sender checked (`invoice.fulfilled`) | The amount goes to the client's balance and pays the invoice. The transaction keeps the Perly Pay payment (`pay_…`) as its id, and a note names the network, the coin amount and the transaction. | | Part paid, expired with money received, held for [review](https://pay.perly.io/docs/screening.md) | A note on the transaction and on the invoice. No payment. | | Expired, nothing paid | A note on the transaction. Paying again makes a new Perly Pay invoice. | | Refund events | A note on the transaction, from the refund as Perly Pay's API returns it. | A replayed or doubled webhook records nothing twice. Events for invoices another shop, another FOSSBilling or another gateway made on the same Perly Pay account are dropped. If recording fails, the webhook is answered 503 and Perly Pay sends it again for up to 72 hours. ## Refunds FOSSBilling has no refund hook for payment gateways, so open the [refund](https://pay.perly.io/docs/refunds.md) in Perly Pay: Payments → the payment → Refund. Perly Pay emails the client for an address; you send from your wallet. The refund's steps are noted on FOSSBilling's transactions. ## Go live - Turn on live mode in Perly Pay and connect your wallet's public keys. - Paste the `sk_live_…` key and the live webhook's secret, and turn **Enable Test Mode** off. - For the overlay, add your FOSSBilling's https address to **Sites that may embed the checkout** in live mode too: test and live keep separate lists. - Pay one small real invoice and refund it in Perly Pay. --- # Make > Payments into any Make scenario: instant triggers for paid invoices, refunds, subscriptions and customers; modules to make invoices and payment links and to look them up. The Perly Pay app brings your payments to Make. A paid invoice can fill a sheet, update a CRM or give a Discord role; a form can create an invoice and send its payment page. Nothing to host and no code. > **Note:** A test key and a live key are two connections. A scenario on a test connection only ever sees test payments. ## Connect 1. **Get a key** — In Perly Pay, Dashboard → Developers → API keys. `sk_test_…` for [test mode](https://pay.perly.io/docs/test-mode.md), `sk_live_…` for real money. Choose **Full** access, or **Restricted** with **Webhook endpoints** ticked as well: the triggers add their own webhooks. 2. **Add Perly Pay to a scenario** — The app is on its way to Make's app list. Until it is listed, [write to us](https://pay.perly.io/contact) for its invite link; open it once, then search **Perly Pay** when you add a module. 3. **Connect** — **Add** a connection and paste the key. Name it so you can tell test from live. ## Instant triggers | Module | When | Event | | --- | --- | --- | | Watch paid invoices | Paid in full and the sender checked. The moment to give access or ship. | `invoice.fulfilled` | | Watch underpaid invoices | The buyer sent less than the invoice asks. | `invoice.underpaid` | | Watch opened refunds | You opened a [refund](https://pay.perly.io/docs/refunds.md). | `refund.requested` | | Watch confirmed refunds | The refund you sent is confirmed on chain. | `refund.confirmed` | | Watch started subscriptions | A [subscription](https://pay.perly.io/docs/subscriptions.md) starts. | `subscription.created` | | Watch renewed subscriptions | A subscriber paid for the next period. | `subscription.renewed` | | Watch ended subscriptions | It ended. The moment to take access away. | `subscription.ended` | | Watch new customers | A new buyer. | `customer.created` | Adding a trigger's webhook in Make adds one in Dashboard → Developers → Webhooks, named like **Make: Paid invoices**; deleting it in Make removes it. Make takes each delivery from Perly Pay with your key before the scenario runs, and Perly Pay hands each one out once: a request that did not come from Perly Pay, or one sent again, runs nothing. Each webhook is one of the 16 a mode may have. Each trigger gives the event and the object it is about — the invoice, refund, subscription or customer — as the [API](https://pay.perly.io/docs/api.md) has it. Fiat amounts are in cents (`total` 4900 is 49.00): divide by 100 in a mapping. Coin amounts are in the coin's smallest unit, with its `decimals`. ## Modules | Module | Give it | You get | | --- | --- | --- | | Create an invoice | Amount (like 49.00), description; optional currency, customer email, return URL, metadata | The invoice, with its payment page (**Payment page URL**) to send the buyer | | Create a payment link | A price, or a new product's name and amount | A page that sells it, to share anywhere | | Get an invoice | An invoice ID | Its status, what arrived, its payment page | | Search customers | An email, or nothing for all | Customers, newest first | | Make an API call | A path like `/invoices`, a method, and a JSON body if it needs one | The API's answer as it is, for anything the other modules do not do | ## Recipes ### Payment → row in Google Sheets 1. **Trigger** — Perly Pay → **Watch paid invoices**. Add the webhook; run the scenario once and pay a test invoice so Make learns the fields. 2. **Action** — Google Sheets → **Add a Row**, in a sheet with columns like Date, Email, Amount, Coin. 3. **Map** — Date → Invoice: Paid at, Email → Customer email, Amount → `Total (cents) / 100`, Coin → Payment: Coin. Turn it on. ### Payment → Discord role Discord needs to know who paid. Put the buyer's Discord user id on the invoice when you make it, and read it back when it is paid. 1. **Scenario 1: the invoice** — Trigger: your sign-up form asking for email and Discord user id. Perly Pay → **Create an invoice**, metadata key `discord_user_id`, value the form's answer. Then email the buyer the **Payment page URL**. 2. **Scenario 2: the role** — Perly Pay → **Watch paid invoices**. Discord → **Add a Role to a Member** (or **Make an API call** to Discord): your server, the role, and the member = Invoice: Metadata → `discord_user_id`. 3. **Take it back** — For a subscription, **Watch ended subscriptions** removes the role the same way. ## Test With a test connection, create an invoice (by the module, or in the dashboard) and pay it with test coins, or from the API with the invoice's id: ```bash curl -X POST https://pay.perly.io/api/v1/invoices/inv_…/simulate \ -H "Authorization: Bearer $PERLY_PAY_SECRET_KEY" -H "Content-Type: application/json" -d '{}' ``` Within seconds the scenario runs. Its History shows each run and what came in. ## Go live - Turn on live mode in Perly Pay and connect your wallet's public keys. - Add a connection with a `sk_live_…` key. In each scenario, add a new webhook on it for the trigger, and switch the other Perly Pay modules to it. - Pay one small real invoice and watch it arrive. --- # n8n > Crypto payments in any n8n workflow: a trigger for paid invoices, refunds and subscriptions, and a node that makes invoices and payment links. The Perly Pay nodes bring your payments to n8n. A paid invoice can fill a sheet, email a download link or grant access; a form or a CRM step can create an invoice and send its payment page. Two nodes: **Perly Pay**, which does things, and **Perly Pay Trigger**, which starts a workflow. > **Note:** The key decides the mode. A workflow built with a test key only ever sees test payments and can never move real money. ## Install 1. **Install the nodes** — Follow n8n's [guide to installing community nodes](https://docs.n8n.io/integrations/community-nodes/installation/) with the package name `n8n-nodes-perly-pay`. It is on [npm](https://www.npmjs.com/package/n8n-nodes-perly-pay), with its source on [GitHub](https://github.com/volodymyrslipak/n8n-nodes-perly-pay), and has no dependencies of its own. 2. **Get a key** — In Perly Pay, Dashboard → Developers → API keys. `sk_test_…` for [test mode](https://pay.perly.io/docs/test-mode.md), `sk_live_…` for real money. Choose **Full** access, or **Restricted** with **Webhook endpoints** ticked: the trigger adds its own webhook. 3. **Add the credential** — In n8n, add a **Perly Pay API** credential and paste the key. n8n tests it by listing your webhook endpoints. ## Perly Pay node | Resource | Operation | What it does | | --- | --- | --- | | Invoice | Create | An invoice for an amount in USD or EUR, with its payment page (`url`) where the buyer picks a coin and network. Optional: the buyer's email, a return URL, how long the buyer has to choose, your own metadata. | | Invoice | Get | An invoice by its ID: its status, what arrived and on which network. | | Invoice | Get Many | Invoices, newest first, by status or customer. | | Customer | Search | Customers by email, or all of them, newest first. | | Payment Link | Create | A shareable payment page for one of your prices, or for a new one-time price from a name and an amount. | **Simplify**, on by default, gives an invoice or a link as ten flat fields. Amounts come as integers (cents; coins in their smallest unit), each with a decimal string beside it: `total` is `4900` and `total_decimal` is `"49.00"`. ## Perly Pay Trigger Choose the events that start the workflow. Activating the workflow adds a webhook in your Perly Pay account for those events, at the workflow's production URL; deactivating it deletes the webhook. | Event | When | | --- | --- | | Invoice Paid (`invoice.fulfilled`) | Paid in full, confirmed on its network and screened: the moment to deliver | | Payment Underpaid (`invoice.underpaid`) | Less than the invoice arrived. The buyer can send the rest. | | Refund Opened (`refund.requested`) | A refund was opened. Perly Pay asks the buyer for an address. | | Refund Confirmed (`refund.confirmed`) | The refund you sent from your wallet is confirmed on chain | | Subscription Started (`subscription.created`) | A subscription started | | Subscription Renewed (`subscription.renewed`) | A subscriber paid for the next period | | Subscription Ended (`subscription.ended`) | A subscription ended: the moment to take access away | | New Customer (`customer.created`) | A new buyer was added | | Invoice Created (`invoice.created`) | An invoice was made, by you or by a checkout | | Invoice Expired (`invoice.expired`) | Nothing arrived in time | | Invoice Held for Review (`invoice.review`) | A payment waits for your review before it counts | | Payment Seen (`payment.received`) | A transfer was seen on chain, not yet confirmed | | Checkout Completed (`checkout.completed`) | A checkout session was paid | | Subscription Past Due (`subscription.past_due`) | A renewal was not paid on time | | Subscription Canceled (`subscription.canceled`) | A subscription was canceled; it may run to the end of its period | | Access Granted (`entitlement.granted`) | A buyer was given access to a product | | Access Revoked (`entitlement.revoked`) | A buyer's access to a product ended | | License Key Issued (`license.created`) | A license key was issued for a purchase | | Credits Low (`credits.low`) | A customer's credits fell to the low-balance threshold | Deliver on **Invoice Paid**. Put your own order or user ID in the invoice's metadata when you create it; it comes back on every event about that invoice. Each event has a unique `id`, and Perly Pay retries a delivery that is not answered for up to 72 hours, so a step that must happen once can skip an `id` it has already seen. > **Note:** Perly Pay sends events only to an address it can reach: with a `sk_live_…` key that is a public `https` address, so n8n has to be reachable from the internet. Every delivery's `Perly-Signature` is checked against the raw body before the workflow starts; a bad one is answered 401 and starts nothing. Every other delivery is claimed once from Perly Pay with your key, and Perly Pay's copy is what runs: the same delivery posted again starts nothing. The webhook's signing secret stays in the workflow's static data, never in a node's output. ## What buyers pay with The buyer picks a coin and network on the payment page and pays from any wallet. The money goes straight to yours. | Coin | Networks | | --- | --- | | BTC | Bitcoin | | USDC | Base, Arbitrum, Optimism and Ethereum | | USDT0 | Arbitrum and Optimism | | USDT | Ethereum and TRON | > **Note:** The package is free and open source (MIT). Perly Pay costs 2.5¢ a payment, never more than 0.5% of it, billed after the month. A month of up to 21 payments costs $0. Nothing is taken from the payment: see [Pricing](https://pay.perly.io/pricing). ## Test With a test key, create an invoice (with the node, or in the dashboard) and pay it with test coins, or from the API with the invoice's id — an HTTP Request node will do: ```bash curl -X POST https://pay.perly.io/api/v1/invoices/inv_…/simulate \ -H "Authorization: Bearer $PERLY_PAY_SECRET_KEY" -H "Content-Type: application/json" -d '{}' ``` The trigger then receives `invoice.fulfilled` as it would for a real payment. ## Go live - Turn on live mode in Perly Pay and connect your wallet's public keys. - Add a credential with a `sk_live_…` key and switch the workflow's Perly Pay nodes to it. Activating the workflow adds the live webhook. - Pay one small real invoice and watch it arrive. --- # AI assistants (MCP) > Sell from Claude Code, Cursor or Codex in one prompt: products, prices, payment links and test payments, through a local MCP server with your key. `perly-pay-mcp` lets your coding assistant set up Perly Pay for you: make a product and its price, share a payment link, pay it in [test mode](https://pay.perly.io/docs/test-mode.md), and read what came in. It runs on your machine and talks only to Perly Pay's API, with your key. Nothing of ours sits in between. ## Set up 1. **Get a test key** — Dashboard → Developers → API keys: `sk_test_…`. Build with it; nothing real moves. 2. **Add the server** — One line in Claude Code, or a few in Cursor's or Codex's config. Node 20 or newer. 3. **Ask** — "Sell a one-hour consultation for $49 with a payment link, then pay it in test mode and show me the payment." **Claude Code** ```bash claude mcp add perly-pay --env PERLY_PAY_SECRET_KEY=sk_test_… -- npx -y perly-pay-mcp ``` **Cursor** ```json // ~/.cursor/mcp.json, or .cursor/mcp.json in a project { "mcpServers": { "perly-pay": { "command": "npx", "args": ["-y", "perly-pay-mcp"], "env": { "PERLY_PAY_SECRET_KEY": "sk_test_…" } } } } ``` **Codex** ```toml # ~/.codex/config.toml [mcp_servers.perly-pay] command = "npx" args = ["-y", "perly-pay-mcp"] env = { PERLY_PAY_SECRET_KEY = "sk_test_…" } ``` > **Warning:** Keep the key out of git: a config file inside a project is often committed. | Variable | What | | --- | --- | | `PERLY_PAY_SECRET_KEY` | `sk_test_…` while you build. `sk_live_…` reads only (see below). | | `PERLY_PAY_API` | Default `https://pay.perly.io`. | | `PERLY_PAY_WEBHOOK_SECRET` | Optional `whsec_…`, the default for `verify_webhook_signature`. | ## Tools Generated from the [API](https://pay.perly.io/docs/api.md)'s own description, so every argument is the API's. Amounts are integers in cents: `4900` is $49.00. | Tools | For | | --- | --- | | `create_product`, `list_products`, `update_product`, `create_price`, `list_prices`, `update_price` | products and prices | | `create_payment_link`, `list_payment_links`, `update_payment_link` | payment links | | `create_invoice`, `list_invoices`, `retrieve_invoice`, `cancel_invoice`, `create_checkout_session`, `retrieve_checkout_session` | invoices and checkout | | `create_customer`, `list_customers`, `retrieve_customer` | customers | | `create_subscription`, `list_subscriptions`, `retrieve_subscription` | subscriptions | | `check_entitlement`, `list_entitlements`, `consume_entitlement_credits`, `list_credit_usage` | access and credits | | `list_payments`, `retrieve_payment`, `list_receipts`, `retrieve_receipt`, `list_events` | payments, receipts, events | | `create_refund`, `list_refunds` | refunds | | `create_webhook_endpoint`, `list_webhook_endpoints`, `delete_webhook_endpoint` | webhooks | | `simulate_payment` | test mode | | `verify_webhook_signature`, `pay_links` | on your machine | `simulate_payment` pays an invoice, or buys from a payment link and pays that, so fulfilment, receipts and [webhooks](https://pay.perly.io/docs/webhooks.md) fire as for a real buyer. `pay_links` gives the dashboard pages for what only you can do. ## What it will not do - **With a live key, it only reads.** Every tool that writes is refused — new invoices, checkout sessions, products, prices, customers, links and subscriptions, edits, cancels, refunds, credit spending and webhook endpoints. The tool says why; you do it in the dashboard or in your app's own code. Why: text a buyer typed can reach the assistant, and nothing the server receives can prove that you, not the model, approved a live change. - **Wallets, API keys, your bill, going live and two-factor sign-in** have no tool. You do them in the dashboard; `pay_links` gives the pages. - **What cannot be undone** — cancelling an invoice, opening a refund, deleting a webhook endpoint — waits for `confirm: true`, which the assistant sends only after asking you. - **Your secret key is never repeated** in an answer or a log. ## Go live 1. **Connect a wallet** — Its public key or address, in the dashboard. We never ask for a seed or a private key. See [Wallets](https://pay.perly.io/docs/wallets.md). 2. **Turn on live mode** — In the dashboard, once the wallet is connected. 3. **Swap the key** — Put `sk_live_…` where `sk_test_…` was, in your app's server. Your assistant can keep the test key. --- # Recipes for AI assistants > Sell an API, a SaaS plan, an app, a bot or MCP tools for crypto, in one prompt to your coding assistant. Ask your assistant to "let people buy API credits with USDC" and it should do it the right way: test mode first, a signed webhook, access only once a payment is fulfilled. These recipes teach it how. Each is a skill for Claude Code, a rule for Cursor and a block for `AGENTS.md`, from one source, plus a small app you can run. They cost nothing to run: they are files in your project, calling Perly Pay with your own key. | Recipe | Sells | Skill | Example | | --- | --- | --- | --- | | [API credits](https://pay.perly.io/docs/recipe-api-credits.md) | Prepaid credits, spent per request | `sell-credits` | `api-credits` (Hono) | | [SaaS subscription](https://pay.perly.io/docs/recipe-saas-subscription.md) | A monthly plan, renewed by invoice | `sell-subscription` | `saas-subscription` (Next.js) | | [License keys](https://pay.perly.io/docs/recipe-license-keys.md) | An app or plugin unlocked by a key | `sell-license-keys` | `license-keys` (Node CLI) | | [Telegram bot](https://pay.perly.io/docs/recipe-telegram-bot.md) | Paid bot commands, roles, groups | `sell-bot-access` | `telegram-bot` (Node) | | [Paid MCP server](https://pay.perly.io/docs/recipe-paid-mcp-server.md) | MCP tools, credits per call | `paid-mcp-tools` | `paid-mcp-server` (MCP SDK) | Three more skills come with them: `add-crypto-checkout` for any one-off sale, `verify-webhooks` for a correct webhook route in every framework, and `test-and-go-live` for simulated payments and the switch to live money. ## Claude Code ```bash claude plugin marketplace add volodymyrslipak/perly-pay-ai && claude plugin install perly-pay@perly ``` Then ask in plain words, or call a skill: `/perly-pay:sell-credits`. ## Cursor Copy the plugin's `cursor/rules/*.mdc` into your project's `.cursor/rules/`. `perly-pay.mdc` holds the rules; each recipe has its own file, attached when a chat is about it. ## AGENTS.md Paste the plugin's `AGENTS-snippet.md` into your `AGENTS.md` (Codex, Jules, Copilot, Zed, Cursor and others read it). It sits between `BEGIN perly-pay` and `END perly-pay`, so an update replaces it whole. ## Any assistant Skills for every assistant are listed at `/.well-known/skills/index.json`, and every docs page is also Markdown — add `.md` to its address, or read all of them in `/llms-full.txt`. ## What every recipe holds to - Test mode first: a `sk_test_…` key and `invoices.simulate`. Nothing real moves. - The secret key stays on your server, in `PERLY_PAY_SECRET_KEY`. Shipped apps check licenses with no key at all. - Access only on `invoice.fulfilled` or `entitlements.check` — never the redirect, never `invoice.paid`. - Webhooks verified on the raw body, each event handled once. - Idempotency keys on what may repeat, such as spending credits. - Amounts in cents, USD or EUR. Never floats. - No auto-debit, ever: renewals and top-ups are invoices the buyer pays from their wallet. --- # Recipe: API credits > Sell prepaid credits for an API or an AI feature and spend one per request. A short balance answers 402 with a top-up link. Buyers prepay a pack of credits; every request spends some. Perly Pay is the ledger: spending is one atomic call that refuses an overdraft, and every buyer has their own top-up page. Nothing is ever debited on its own — when credits run out, the buyer tops up. The whole credits feature is in [Sell credits](https://pay.perly.io/docs/credits.md); this is the recipe around it. > **Note:** With an assistant: the `sell-credits` skill of the Perly Pay plugin does this recipe in your project. The runnable example is `examples/api-credits` (MIT) in the plugin's repository. ## How it works 1. **A credit pack** — A product and a price with `model: "credit_pack"`, `unit_amount` in cents and `credits`. Made once per mode. 2. **Your users are customers** — Made once with `external_id` (your user id); from then on `credits.*` take that id. 3. **Top up** — `credits.topUpUrl(userId)` is the buyer's own page: they pick a pack and pay. Each paid pack adds credits. 4. **Spend per request** — `credits.consume(userId, cost, { idempotencyKey })` first, then the work. A retry with the same key returns the first spend, so your server makes the key for each request and never takes it from the caller. 5. **Short?** — `insufficient_credits` (409) spends nothing: answer `402` with the top-up page, not a 500. **Spend one request's credits** ```js try { await pay.credits.consume(userId, cost, { product: PRODUCT, action: "api", idempotencyKey: `req-${userId}-${requestId}` }); } catch (e) { if (e.code !== "insufficient_credits") throw e; return reply(402, { top_up: await pay.credits.topUpUrl(userId, { product: PRODUCT }) }); } // now do the work ``` ## Run the example 1. **Install** — `npm install` in `examples/api-credits` (Node 20.12+). 2. **Key** — Copy `.env.example` to `.env` and put a test key (`sk_test_…`) in `PERLY_PAY_SECRET_KEY`. 3. **Set up** — `npm run setup` makes the product and price with your key and prints their ids for `.env`. 4. **Run** — `npm start` (or `npm run dev`), as its README says. 5. **Pay without a wallet** — Call `POST /v1/shout` (402), open the page `POST /top-up` gives, pick the pack, press **Simulate payment** on the invoice, call again: 200 and `X-Credits-Remaining: 499`. ## Pitfalls - Spending without an idempotency key: a retried request is charged twice. - Keying a spend on an id the caller sends (an `Idempotency-Key` header): the same key returns the first spend as a success, so the caller gets the work again without paying. - Checking the balance, then spending, as two steps. `consume` alone refuses an overdraft; the balance is for display. - Doing the work before spending. Spend first. - Test ids with a live key. Products, prices and webhook endpoints belong to one mode: run the setup again with `sk_live_…` when you go live. --- # Recipe: SaaS subscription > A monthly or yearly plan paid in crypto. Every period is an invoice with reminders; your app asks Perly Pay who has access. Crypto cannot be pulled from a wallet, so a subscription renews by invoice: before each period ends the buyer gets the next one, then reminders until it is paid. There is no auto-debit. Your app never counts days: it asks `entitlements.check`. > **Note:** With an assistant: the `sell-subscription` skill of the Perly Pay plugin does this recipe in your project. The runnable example is `examples/saas-subscription` (MIT) in the plugin's repository. ## How it works 1. **A plan** — A product and a price with `model: "subscription"` and `recurring.interval` (`month`, `year`…), optional trial and grace days. 2. **Subscribe** — An invoice for that price with the user as customer opens the subscription and its first invoice. Paying it makes the plan active. 3. **Gate features** — `entitlements.check({ customer_external_id, product })` → `has_access` and `ends_at`, on the server, on every visit or with a short cache. 4. **Listen** — `invoice.fulfilled` for the first payment and each renewal; `subscription.past_due`, `subscription.unpaid` and `subscription.ended` to warn and lock. 5. **Let buyers manage it** — A customer portal link (`portalSessions.create`) to pay the open renewal, prepay or cancel. **Subscribe, then gate** ```js const invoice = await pay.invoices.create( { line_items: [{ price: PRO_MONTHLY }], customer: customer.id, metadata: { user: userId }, return_url }, { idempotencyKey: `subscribe-${userId}-${today}` }, ); redirect(invoice.url); // invoice.subscription is the new subscription const { has_access } = await pay.entitlements.check({ customer_external_id: userId, product: PRO }); ``` See [Subscriptions and reminders](https://pay.perly.io/docs/subscriptions.md) for grace days, plan changes and statuses. ## Run the example 1. **Install** — `npm install` in `examples/saas-subscription` (Node 20.12+). 2. **Key** — Copy `.env.example` to `.env` and put a test key (`sk_test_…`) in `PERLY_PAY_SECRET_KEY`. 3. **Set up** — `npm run setup` makes the product and price with your key and prints their ids for `.env`. 4. **Run** — `npm start` (or `npm run dev`), as its README says. 5. **Pay without a wallet** — Subscribe on the home page, press **Simulate payment** on the invoice, then open `/pro?user=you@example.com`: Pro is unlocked until the period's end. ## Pitfalls - Saying "we charge your wallet automatically". Nothing is pulled; say "renews by invoice". - A missing or fake customer email: renewal invoices and reminders go there. - Locking at the first late minute. Decide what `past_due` (inside grace days) means for access. - Granting access on the redirect back from checkout. Only a verified `invoice.fulfilled` webhook or `entitlements.check` from your server means paid. --- # Recipe: License keys > Sell a desktop app, CLI, plugin or template with license keys. The shipped app checks its key with no secret inside. Perly Pay issues a key when the payment is fulfilled, counts activations per device, and answers "is this key good for this product here?" through public endpoints. The software you ship uses `LicenseClient`, which needs no secret key. > **Note:** With an assistant: the `sell-license-keys` skill of the Perly Pay plugin does this recipe in your project. The runnable example is `examples/license-keys` (MIT) in the plugin's repository. ## How it works 1. **A product that issues keys** — `issues_license: true` and `license_activation_limit` (devices per key), with a one-time, lifetime, fixed-term or subscription price. 2. **Sell it** — A [payment link](https://pay.perly.io/docs/payment-links.md) on your site, or an invoice per buyer. 3. **Send the key** — On the `license.created` webhook (its object is the key), or `licenseKeys.list({ customer })` after `invoice.fulfilled`. The receipt does not carry it. 4. **Activate in the app** — `activate({ key, product, instance_name })` with a name that stays the same on this device; keep the activation id. 5. **Check on start** — `validate({ key, product, instance })`. A definite `valid: false` locks; no network keeps the last good answer for a few days. **Node** ```js import { LicenseClient } from "perly-pay"; const licenses = new LicenseClient(); // no secret key, ever const result = await licenses.activate({ key, product: "prod_…", instance_name: deviceName }); if (!result.valid) showWhy(result.reason); // limit_reached, revoked, expired, … ``` **PHP** ```php use Perly\Pay\LicenseClient; $result = (new LicenseClient())->activate(['key' => $key, 'product' => 'prod_…', 'instance_name' => home_url()]); if (!$result['valid']) echo $result['reason']; ``` ## Run the example 1. **Install** — `npm install` in `examples/license-keys` (Node 20.12+). 2. **Key** — Copy `.env.example` to `.env` and put a test key (`sk_test_…`) in `PERLY_PAY_SECRET_KEY`. 3. **Set up** — `npm run setup` makes the product and price with your key and prints their ids for `.env`. 4. **Run** — `npm start` (or `npm run dev`), as its README says. 5. **Pay without a wallet** — `npm run buy-test -- you@example.com` buys a copy in test mode and prints the key; then `npm run app -- activate ` and `npm run app -- pro hello`. ## Pitfalls - A `sk_…` key inside the app. Anyone can pull it out; shipped code uses `LicenseClient` only. - Leaving out `product`: the call is refused (`parameter_missing`). Pass it every time; a key for anything else answers `wrong_product`. - An `instance_name` that changes every run: each start eats an activation. --- # Recipe: Telegram bot > Sell bot commands, a role or a private group. Each user gets their own invoice; a signed webhook unlocks them. The bot never sees money. On `/buy` it sends the user their own invoice link; your server hears `invoice.fulfilled` and unlocks. Every paid command asks Perly Pay, so a restart loses nothing. Discord works the same way, with a role. > **Note:** With an assistant: the `sell-bot-access` skill of the Perly Pay plugin does this recipe in your project. The runnable example is `examples/telegram-bot` (MIT) in the plugin's repository. ## How it works 1. **A product and price** — One-time or lifetime for "forever", fixed term for passes, subscription for memberships renewed by invoice. 2. **A customer per chat user** — `external_id: "tg:"`. Ask for an email only for subscriptions: renewals go there. 3. **/buy** — An invoice with the chat id in `metadata`, sent as a button. One invoice per user per day, by idempotency key. 4. **Unlock** — On `invoice.fulfilled`, read the chat id from `metadata` and answer: a message, a one-time invite link, a role. 5. **Paid commands** — `entitlements.check({ customer_external_id: "tg:", product })`. **/buy** ```js const invoice = await pay.invoices.create( { line_items: [{ price: PRICE }], customer: customer.id, metadata: { chat_id: String(chatId) } }, { idempotencyKey: `buy-${userId}-${today}` }, ); await tg("sendMessage", { chat_id: chatId, text: "Pay:", reply_markup: { inline_keyboard: [[{ text: "Pay", url: invoice.url }]] } }); ``` ## Run the example 1. **Install** — `npm install` in `examples/telegram-bot` (Node 20.12+). 2. **Key** — Copy `.env.example` to `.env` and put a test key (`sk_test_…`) in `PERLY_PAY_SECRET_KEY`. 3. **Set up** — `npm run setup` makes the product and price with your key and prints their ids for `.env`. 4. **Run** — `npm start` (or `npm run dev`), as its README says. 5. **Pay without a wallet** — Message the bot `/buy`, open Pay and press **Simulate payment**: the bot says "Payment confirmed" and `/secret` answers. ## Pitfalls - One shared payment link for everyone: you cannot tell who paid. An invoice per user, with the chat id. - Unlocking when the user taps "I paid". Only the webhook or `entitlements.check` unlocks. - Access kept in the bot's memory. It is gone after a restart; ask Perly Pay. --- # Recipe: Paid MCP server > Charge for an MCP server's tools per call with credits. A person buys a pack once; each call spends, then runs. Agents should not pay on their own. A person buys a credit pack in their browser, then every tool call checks the balance, spends credits with an idempotency key, and runs. Out of credits, the tool answers with a top-up link the agent shows. No x402, no wallet inside the agent, nothing debited on its own. > **Note:** With an assistant: the `paid-mcp-tools` skill of the Perly Pay plugin does this recipe in your project. The runnable example is `examples/paid-mcp-server` (MIT) in the plugin's repository. ## How it works 1. **A credit pack** — As in [API credits](https://pay.perly.io/docs/recipe-api-credits.md). Give each tool a cost and put it in the tool's description. 2. **Who is calling** — A hosted server maps each caller's API key to a customer `external_id`. Only your server holds `PERLY_PAY_SECRET_KEY`. 3. **Charge, then run** — `credits.consume` with the tool's name as `action` and a fresh key your server makes for each call, then do the work. 4. **Short?** — Return a tool result with `isError: true` and the top-up link, not a protocol error: clients show it. 5. **Free helpers** — `balance` and `top_up` tools, so an agent can explain what is going on. **A paid tool** ```js server.registerTool("summarize", { description: "Costs 3 credits.", inputSchema: { text: z.string() } }, async ({ text }, extra) => { const refused = await charge(userId, 3, "summarize", extra.requestId); // credits.consume, or a top-up message if (refused) return { isError: true, content: [{ type: "text", text: refused }] }; return { content: [{ type: "text", text: summarize(text) }] }; }); ``` ## Run the example 1. **Install** — `npm install` in `examples/paid-mcp-server` (Node 20.12+). 2. **Key** — Copy `.env.example` to `.env` and put a test key (`sk_test_…`) in `PERLY_PAY_SECRET_KEY`. 3. **Set up** — `npm run setup` makes the product and price with your key and prints their ids for `.env`. 4. **Run** — `npm start` (or `npm run dev`), as its README says. 5. **Pay without a wallet** — Add it to Claude Code (`claude mcp add paid-tools -- npx tsx "$PWD/src/server.ts"`), run `word_stats`: it answers with a top-up page. Pick the pack, press **Simulate payment** and run it again. ## Pitfalls - Putting `PERLY_PAY_SECRET_KEY` in an MCP config other people copy. Host the server, or have it call your backend. - Charging after the work: a failed charge then means free work. - Keying the charge on the JSON-RPC id: the client chooses it, and the same id again returns the first charge, so the tool runs again for free. --- # Price models > One-time, pay what you want, fixed term, subscription, lifetime with cohorts, credit packs. A product has one or more prices. Each price follows one model. | Model | The buyer gets | Good for | | --- | --- | --- | | One-time | The item, once | Goods, downloads, tickets | | Pay what you want | Any amount above your minimum | Tips, donations, pre-sales | | Fixed term | Access for N days | Passes, courses, a season | | Subscription | A period, renewed by invoice | Software, memberships | | Lifetime | Access forever — optionally one of a few seats | Founding members, early access | | Credit pack | A number of credits | API calls, credits, prints | ## Lifetime and cohorts Cap a lifetime price at a number of seats, then split the seats into cohorts that cost more as they fill. | Cohort | Seats | Price | | --- | --- | --- | | Founding | 1–21 | $21 | | Early | 22–231 | $42 | | Open | 232–2,331 | $105 | | Club | 2,332–21,000 | $210 | That is Perly App's club: 21,000 seats in four cohorts, sold through Perly Pay. The checkout shows which cohort is open; a seat gets its number when it's paid. ## Extras on any price - **Coupons** — percent or amount off; once, for N periods, or forever. - **License keys** — issued on payment, with an activation limit. - **Refund terms** — allowed or not, and for how many days. The buyer ticks them at checkout. - **Currency** — USD or EUR. The buyer pays the equal amount in bitcoin or dollars. --- # Sell credits > Prepaid credits for an API or an AI service: buyers top up themselves, your server spends one call at a time. Nothing is taken from a top-up. Credits suit anything sold by use: tokens, images, minutes, searches. The buyer pays for a pack in crypto; you spend from it on every call; when it runs low they top up on their own page. Nothing is ever charged automatically — the buyer always pays themselves — and Perly takes nothing from a top-up. ## Set it up 1. **Make a product and its packs** — Dashboard → Products → New, then New price → **Credit pack**: e.g. 1,000 credits for $10, 5,000 for $40. Or `pay.prices.create({ product, model: "credit_pack", unit_amount: 1000, credits: 1000 })`. 2. **Choose when buyers hear they are low** — On the product's page, under **Credits**: the low-balance alert, the buyer's letter, and **Any amount** — N of one pack, so a buyer can top up $25 instead of a fixed pack. 3. **Give each buyer a customer** — Make one per user with your own id: `pay.customers.create({ email, external_id: user.id })`. From then on the SDK takes that id. ## Spend, check, top up **Node** ```js import { PerlyPay, InvalidRequestError } from "perly-pay"; const pay = new PerlyPay(process.env.PERLY_PAY_SECRET_KEY); // Before the work: enough left? const { balance, top_up_url } = await pay.credits.balance(user.id); // After it: spend. The same idempotencyKey never spends twice. try { await pay.credits.consume(user.id, 3, { action: "chat", idempotencyKey: request.id }); } catch (e) { if (e instanceof InvalidRequestError && e.code === "insufficient_credits") { return { error: "Out of credits", topUp: await pay.credits.topUpUrl(user.id) }; } throw e; } ``` **PHP** ```php $perly = new \Perly\Pay\PerlyPay(getenv('PERLY_PAY_SECRET_KEY')); $left = $perly->credits->balance($user->id)['balance']; try { $perly->credits->consume($user->id, 3, ['action' => 'chat', 'idempotency_key' => $requestId]); } catch (\Perly\Pay\Exception\InvalidRequestException $e) { if ($e->getErrorCode() === 'insufficient_credits') { header('Location: ' . $perly->credits->topUpUrl($user->id)); } } ``` **curl** ```bash curl https://pay.perly.io/api/v1/credits/consume \ -H "Authorization: Bearer sk_test_…" \ -H "Idempotency-Key: req_8f2c" \ -d '{"customer_external_id":"user_42","credits":3,"action":"chat"}' ``` - The customer is a `cus_…` id, an email, or your own id for them (`external_id`). Name a `product` when you sell credits for more than one. - A spend is atomic and never goes below zero: two at once never both pass on the same credits. Short is `insufficient_credits` (409), and nothing is spent. - Send your request's id as the idempotency key. A retry — after a timeout, a crash, even a day later — returns the first spend instead of spending again. - `action` is your label (`chat`, `image.hd`, `api/search`). The product's page sums spends by action and by customer. ## The top-up page `top_up_url` is the buyer's own page, the same every time: it shows what is left and your packs, in your checkout's look, and opens a normal invoice when they choose one. When it is paid, the credits land on their balance once. Put the link in your app, in an "out of credits" error, or send it — the buyer portal has a **Top up** button too. ## When credits run low | Event | When | | --- | --- | | `credits.low` | The balance fell to the product's alert. Once per crossing: it comes again only after a top-up lifts it above. | | `credits.depleted` | The balance reached zero. Once per crossing. | Both carry the customer as `data.object`, with `data.product` and `data.balance`. With the alert set and the letter on, the buyer also gets one email with their top-up link — at most one a day. In test mode letters go to your team only. > **Note:** Credits from many packs add up; a spend takes the oldest pack first. --- # Subscriptions and reminders > Every period is an invoice. We send it, remind the buyer, and tell you what happened. Crypto can't be pulled from a wallet. So a subscription renews the honest way: before each period ends we send the buyer an invoice, then reminders until it's paid. ## A period's life 1. **Invoice** — Before the period ends, the renewal invoice is emailed. `subscription.renewal_invoice_opened`. 2. **Reminders** — If it's still open, the buyer is reminded as the end comes closer. 3. **Paid** — The next period starts where the last one ended — no lost days. `subscription.renewed`. 4. **Not paid** — After your grace days it's **Past due**, then **Unpaid**. Access is yours to decide from the status. ## Settings | Setting | What it does | | --- | --- | | Interval | Day, week, month, quarter, year, or every N days | | Trial | Days before the first payment | | Grace days | How long a period may stay unpaid before it's past due | | Prepay | Let buyers pay several periods at once | ## Changing plans - A renewal keeps the price the buyer signed up at. - **Upgrades** are charged now, prorated for the days left. - **Downgrades** start with the next period. - Canceling stops the next invoice; the paid period runs to its end. ## Statuses | Status | Means | | --- | --- | | Trial | In the free days | | Active | Paid up | | Past due | The renewal is late, inside grace | | Unpaid | Grace is over | | Paused | Stopped by you, for now | | Canceled | Will end at the period's end | | Ended | Over | --- # Refunds > You send from your own wallet; the buyer gives an address through a secure link. We take nothing. We never hold money, so we can't send it back for you. A refund is you paying the buyer from your wallet — we make that safe and short. ## How it goes 1. **You open it** — On the payment, press **Refund**: all of it or part. `refund.requested`. 2. **The buyer gives an address** — They get a secure link by email and paste an address on the same network. We screen it. 3. **You send** — We show the exact amount and a QR code. Send from any of your wallets. 4. **You mark it sent** — Paste the transaction id. We check it pays that address that amount. `refund.sent`. 5. **We confirm** — Once that transaction is confirmed on-chain, the invoice turns **Refunded** and the buyer gets a refund receipt. `refund.confirmed`. > **Note:** Only the transaction you name counts. A buyer may give an exchange address that other people pay into too, so a transfer we merely see there is shown to you as a suggestion, never taken as your refund. > **Note:** Money from an address on a sanctions list, or one the coin's issuer froze, is never refunded: the dashboard refuses, and the API answers `sanctioned_sender`. Sending it back may itself be prohibited, so leave it where it is and talk to a lawyer. See [Screening](https://pay.perly.io/docs/screening.md). ## Who pays the network fee By default the **buyer** does: the fee comes out of the refund. A price can say you pay it instead. | Paid | Fee | You send | | --- | --- | --- | | 100 USDC on Base | ~0.01 | 99.99 USDC | | 100 USDT on Ethereum | ~2 | 98 USDT | | 0.001 BTC | ~0.000005 | 0.000995 BTC | When the price says **you** pay the fee, you send the full amount and the fee comes from your wallet. > **Note:** Fees here are examples; the refund screen shows today's. A fee bigger than the refund is refused rather than sending dust. The buyer agreed to the price's refund terms with a checkbox at checkout. Perly takes no part of a refund, and no fee for one. ## Refund window Each price says whether refunds are allowed and for how many days. Outside the window the Refund button is gone. --- # Screening > Every sender is checked before an invoice grants anything. A sanctions hit never buys and is never refunded. Before `invoice.fulfilled`, every address that sent the money is checked. On EVM networks that is the token transfer's sender and the transaction's own sender; on Bitcoin, every input. The full picture, for you and your lawyer: [Sanctions and risk](https://pay.perly.io/sanctions). ## What is checked | Check | Networks | On a hit | | --- | --- | --- | | OFAC's sanctioned addresses (a daily copy) | All | Red flag | | TRM Labs' free sanctions API (second opinion) | All | Red flag | | The stablecoin issuer's freeze, sender and receiving address | USDC, USDT, USDT0 | Red flag | | ScamSniffer's phishing and drainer list | EVM | Review, your call | A list missing or older than 7 days answers "error", and nothing is granted. A check that has not answered after 30 minutes sends the invoice to review. ## What you receive - `invoice.review` — held for you. The invoice's `review_reason` says why: `screening_hit`, `screening_unavailable`, and others. - A red flag never becomes `invoice.fulfilled`: neither **Accept** in the dashboard nor the API can grant it. The database refuses it. - A refund of red-flagged money is refused: the dashboard says why, and the API answers `sanctioned_sender`. - Money that pays no invoice is screened too. A red flag on it shows in the dashboard's review queue as Flagged. > **Warning:** Don't ship on `invoice.paid`: it can be followed by `invoice.review`. Ship on `invoice.fulfilled`, which comes only after screening is clear. ## Refused places From sanctioned countries and regions, sign-up, the dashboard and the hosted checkout are refused by the country of the connection. Pages you host yourself, with an address from the API, are yours to gate. --- # Receipts > Email, a public page and a PDF, numbered in your own series. Every paid invoice gets a receipt, once. So does every refund. | Where | What | | --- | --- | | Email | Sent to the buyer the moment it's paid | | Page | `pay.perly.io/r/{token}` — open without an account | | PDF | From the page, for the buyer's books and yours | ## Numbers Receipts are numbered per business, per year: `KS-2026-000142`. Choose your prefix (2–8 capitals) in Settings. Test mode counts its own: `TEST-KS-2026-000001`. ## What a receipt says - Your business and the buyer. - Each item, its quantity and price. - The amount paid on-chain, the network and the transaction. - The rate it was priced at. --- # Wallets > Connect a public key from Ledger, Trezor, a file, or paste it. What it can and can't do. Perly Pay makes a new address for every invoice from your wallet's **public** key. The money goes straight to that address — it's yours from the first second. ## What a public key can and can't do | It can | It can't | | --- | --- | | Make your receiving addresses | Spend, sign or move anything | | Let us watch them for payments | Be turned into your private key | | Show the payments it received | Change where money goes | > **Warning:** We never ask for a seed phrase or a private key — no screen in Perly Pay has a field for one. Anyone who asks is not us. A public key does show every address it makes and what they received. That's why we keep it sealed (AES-256) and never show it back in full. ## Five ways to connect | From | How | Keys | | --- | --- | --- | | Ledger | Plug in, open the Bitcoin app (2.1.0 or newer — update it in Ledger Live) or the Ethereum app, press **Read from Ledger**. The browser reads the public key; you confirm on the device. | BTC and EVM | | Trezor | Same, through Trezor's own connect window. | BTC and EVM | | File | Export watch-only from Sparrow, Electrum or your wallet and drop the file. | BTC | | Paste | Paste a `zpub`/`xpub`, or a list of addresses you own. | BTC and EVM | | Multisig | Paste the wallet's output descriptor, or drop its file from Sparrow, Electrum, Specter or Coldcard (or a BSMS record). Up to 15 keys. | BTC | ## Which keys | Family | Path | Looks like | | --- | --- | --- | | Bitcoin | `m/84'/0'/0'` (native SegWit) | `zpub6r…` or `xpub6C…` | | Bitcoin multisig | Each key's own, usually `m/48'/0'/0'/2'` | `wsh(sortedmulti(2,[fingerprint/48h/0h/0h/2h]xpub…/0/*,…))` | | USDC, USDT, USDT0 (every EVM network) | `m/44'/60'/0'/0` | `xpub6E…` | | USDT on TRON | `m/44'/195'/0'/0` | `xpub6E…` or `T…` addresses | TRON is a family of its own: its key is coin 195's, never the EVM one. A Ledger reads it in its **Tron** app; a Trezor (Safe or Model T) gives 50 TRON addresses at a time instead of a key. One EVM key serves Base, Arbitrum, Optimism and Ethereum: an address is the same on all four. ## Check the first address After connecting, we show your first address. Find the same one in your wallet (Ledger Live, Trezor Suite, Sparrow). If they match, confirm — that proves the key is yours and the path is right. ## Use a fresh account Old payments to an address never pay a new invoice — only money that arrives after the invoice opens counts. Still, a fresh account keeps your books clean and your history private. ## Seeing every payment in your wallet Each invoice gets the next address of your key. Wallets list those addresses in their own ways. ### Bitcoin Invoices take receive addresses 0, 1, 2… of the native SegWit account `m/84'/0'/0'`, and the dashboard names them that way. Ledger Live and Trezor Suite show it as a plain **Native SegWit** account — nothing to set up. For more control, open the same account in Sparrow: connect the device, pick Native SegWit, `m/84'/0'/0'`. ### Bitcoin multisig A multisig wallet (M of N keys) gives P2WSH addresses (`bc1q…`, longer than single-key ones) or P2SH-P2WSH (`3…`). Only `sortedmulti` is taken — the default in Sparrow, Specter and Coldcard — and every key with its fingerprint and path. Compare the first three addresses with Sparrow or your coordinator — or on a Trezor or Ledger that holds one of its keys: a Trezor shows the first address; a Ledger first shows the whole wallet (M of N and every key) to approve, which is the check of every key, then the address. The Ledger's approval is kept in that browser, so the next check asks only for the address. Invoices take receive addresses 0, 1, 2… of every key at once. Collect money gives one PSBT that names each key, for each device to sign. ### USDC and USDT Invoices take `m/44'/60'/0'/0/0`, `m/44'/60'/0'/0/1`, `m/44'/60'/0'/0/2`…: the **BIP44 standard** path, with the number at the end. The dashboard calls them account 1, 2, 3. Trezor uses this path itself, so Trezor Suite, Rabby and MetaMask list them the same way. > **Warning:** **Ledger Live shows only the first one.** Ledger Live numbers its accounts in the third place instead: `m/44'/60'/0'/0/0`, `m/44'/60'/1'/0/0`… Account 1 is its first Ethereum account too. From account 2 on, it does not list them on Base, Arbitrum or Optimism, and on Ethereum it stops at the first address that never received anything. The money is there and your Ledger signs for it — open it in Rabby or MetaMask. | Wallet | To see accounts 2, 3, … from a Ledger | | --- | --- | | Rabby | Add the Ledger, then **Advanced Settings** → **Select HD path** → **BIP44**. Not Ledger Live, not Legacy. | | MetaMask | Add a hardware wallet → Ledger, then **Select HD path** → **BIP44 Standard (e.g. MetaMask, Trezor)**. Not Ledger Live, not Legacy (MEW / MyCrypto). | Both then list the addresses in order: account 2 in your dashboard is the second one there. ### USDT on TRON Invoices take `m/44'/195'/0'/0/0`, `/1`, `/2`…, the path TronLink and Trust Wallet use; the dashboard calls them address 1, 2, 3. Ledger Live shows only address 1 (it numbers TRON accounts in the third place). > **Warning:** **Moving USDT out costs TRX.** Every USDT transfer on TRON burns energy, paid in TRX unless the address has its own: about 6.5 TRX (~$2) to an address that already holds USDT, about 13 TRX (~$4) to one that never did — per address, whatever the amount. Collect from many addresses at once, not after every payment; rented or staked energy costs less. ### Unpaid invoices and the gap Wallets look 20 empty addresses ahead. If many invoices go unpaid in a row, payments can sit past that gap. The dashboard tells you when; raise the gap limit in Sparrow or Electrum, or add the next accounts in Rabby or MetaMask. --- # Test mode > Test networks, test keys, a button that pays. Free and unlimited. Test mode is a second set of books next to live. Its own keys, invoices, customers and webhooks. Nothing in it touches live. | | Test | Live | | --- | --- | --- | | Keys | `sk_test_…` | `sk_live_…` | | Bitcoin | testnet4 | mainnet | | USDC, USDT | USDC on Base Sepolia, Arbitrum Sepolia, OP Sepolia and Ethereum Sepolia, and USDT on TRON Nile | Base (USDC only), Arbitrum, Optimism, Ethereum (USDT on Arbitrum and Optimism is USDT0), and USDT on TRON | | Receipt numbers | `TEST-KS-2026-000001` | `KS-2026-000001` | | Emails | Your team only | Your buyers | | Counts on your bill | Never | Paid payments | > **Note:** In test mode, letters go only to your team members' addresses. A receipt or a refund link for anyone else is held, not sent. To see what a buyer gets, pay a test invoice with your own email. ## Simulate a payment Once a coin is chosen, every test invoice page has a big **Simulate payment** button. One press pays the invoice in full. **Short or over**, under it, opens the other two results. Either way the invoice moves exactly as it would on-chain: Arriving, then Paid (or Underpaid, Overpaid), then a receipt and your webhooks. - Full — the invoice is paid and fulfilled. - Short — it waits as Underpaid until the rest arrives or it expires; the button then says **Simulate the rest**. - Over — it's paid, and the extra is shown for you to refund. From code, the same button is an API call — find `simulate` in the [API reference](https://pay.perly.io/docs/api.md). ## Real test coins You can also pay a test invoice for real with testnet coins from any public faucet. The watcher reads test networks the same way it reads mainnet. > **Warning:** Never send mainnet money to a test invoice. Test addresses are made for test networks. --- # Webhooks > Signed POSTs to your server when something happens. Verify, then act. Add an endpoint in Dashboard → Developers → Webhooks, pick the events, copy the `whsec_…` secret. Test and live have their own endpoints. ## A delivery **POST https://example.com/webhooks/perly** ```json { "id": "evt_8c1f…", "type": "invoice.fulfilled", "mode": "live", "created": "2026-10-08T09:41:07Z", "data": { "object": { "id": "inv_3k9…", "status": "paid", "amount": 2400, "currency": "USD", "metadata": { "orderId": "1042" } } } } ``` Answer with any `2xx` within 10 seconds. Anything else is retried with backoff for three days. A delivery can arrive twice — use the event `id` to do things once. ## Verify the signature Every delivery has a `Perly-Signature` header: a timestamp and an HMAC-SHA256 of `{timestamp}.{raw body}` under your secret. ```bash Perly-Signature: t=1791446400,v1=5f2c8a… ``` Use the raw body exactly as it arrived — parsed and re-serialised JSON won't match. Refuse timestamps more than five minutes away. **Node** ```js import crypto from "node:crypto"; export function verify(rawBody, header, secret, tolerance = 300) { if (!secret) return false; // an empty key is one anybody can sign with const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > tolerance) return false; const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 ?? ""); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` **Python** ```python import hashlib, hmac, time def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: if not secret: # an empty key is one anybody can sign with return False parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts.get("t", "0")) if abs(time.time() - t) > tolerance: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` **PHP** ```php function perly_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { if ($secret === '') return false; // an empty key is one anybody can sign with $parts = []; foreach (explode(',', $header) as $pair) { [$k, $v] = array_pad(explode('=', $pair, 2), 2, ''); $parts[$k] = $v; } $t = (int) ($parts['t'] ?? 0); if (abs(time() - $t) > $tolerance) return false; $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1'] ?? ''); } // $ok = perly_verify(file_get_contents('php://input'), $_SERVER['HTTP_PERLY_SIGNATURE'], $secret); ``` **Node SDK** ```js const event = pay.webhooks.verify(rawBody, req.header("Perly-Signature"), process.env.PERLY_PAY_WEBHOOK_SECRET); ``` **PHP SDK** ```php $event = \Perly\Pay\Webhook::verify(file_get_contents('php://input'), $_SERVER['HTTP_PERLY_SIGNATURE'] ?? null, getenv('PERLY_PAY_WEBHOOK_SECRET')); ``` ## Events | Event | When | | --- | --- | | `invoice.created` | An invoice was made. | | `invoice.rail_chosen` | The buyer picked a coin and network; the address and amount are set. | | `invoice.open` | Back to waiting for money. | | `invoice.seen` | Money is on its way, not yet confirmed. | | `invoice.underpaid` | Less than the amount arrived. | | `invoice.paid` | The full amount arrived and is confirmed. | | `invoice.overpaid` | More than the amount arrived. | | `invoice.review` | Held for you to look at — a screening hit or late money. | | `invoice.expired` | Nothing arrived in time. | | `invoice.canceled` | Canceled before money arrived. | | `invoice.fulfilled` | Granted, once: ship now. | | `invoice.refunded` | Refunded, in full or in part. | | `invoice.sent` | A B2B invoice was emailed. | | `invoice.found_on_other_network` | Money reached the invoice's address on another network. It is in your wallet but counts only once you count it as paid. | | `payment.received` | A transfer to an invoice's address was seen. Again, with `reorg: true`, if the chain took back its block. | | `payment.confirmed` | That transfer is deep enough to trust. Bitcoin: 1 block, 2 from $1,000, 3 from $10,000. Dollar tokens from $10,000: the invoice is paid only once the chain has also finalized that block (minutes later; about 15 on Ethereum, 20 on the rollups). | | `payment.dropped` | That transfer left the chain. | | `receipt.created` | A receipt was numbered and issued. | | `refund.requested` | You opened a refund. | | `refund.address_received` | The buyer gave an address. | | `refund.sent` | You marked the refund sent with its transaction id. | | `refund.confirmed` | That transaction is confirmed on-chain. | | `refund.canceled` | The refund was called off. | | `subscription.created` | A subscription started. | | `subscription.activated` | Its first payment arrived; it's active. | | `subscription.trial_ending` | The trial ends in three days. | | `subscription.renewal_invoice_opened` | The next period's invoice was opened. | | `subscription.renewed` | The next period was paid. | | `subscription.past_due` | A renewal is late. | | `subscription.unpaid` | Grace days are over. | | `subscription.paused` | Paused: nothing renews or reminds. | | `subscription.resumed` | Running again. | | `subscription.plan_changed` | Moved to another price, now or at the period's end. | | `subscription.canceled` | Canceled, now or at the period's end. | | `subscription.ended` | It's over. | | `entitlement.granted` | A customer got access, credits or a seat. | | `entitlement.revoked` | That access was taken away. | | `credits.low` | A customer's credits for a product fell to the product's low-balance threshold. Once per crossing; `data.balance` is what is left. | | `credits.depleted` | A customer's credits for a product reached zero. Once per crossing. | | `checkout.completed` | A checkout session opened its invoice. Nothing is paid yet: ship on `invoice.fulfilled`. | | `license.created` | A license key was issued. | | `license.revoked` | A license key was turned off. | | `customer.created` | A new buyer. | | `webhook_endpoint.disabled` | An endpoint failed for 72 hours and was turned off. | | `webhook.test` | A test you sent from the dashboard; only that endpoint gets it. | | `plan.changed` | Your Perly Pay plan changed. | > **Note:** Ship on `invoice.fulfilled`. It comes once per invoice, after the payment is confirmed and screened. `invoice.paid` can be followed by `invoice.review` if a sender is on a sanctions list. --- # SDKs > Node and PHP: the whole API, with idempotency keys, retries, typed errors and webhook checks built in. Two server SDKs speak the whole API. Both send an `Idempotency-Key` with every POST and keep it across retries, retry `429` and `5xx` with backoff, raise typed errors, and check webhook signatures. Neither needs anything else installed. | SDK | Package | Runs on | | --- | --- | --- | | Node / TypeScript | `perly-pay` on npm | Node 18.17+, Deno, Bun, workers and edge. Zero dependencies. | | PHP | `perly/perly-pay` on Composer | PHP 8.1+ with `ext-curl` and `ext-json`. Zero dependencies. | | WooCommerce | [Plugin](https://pay.perly.io/docs/woocommerce.md) | A payment method built on the PHP SDK. | | WHMCS | [Module](https://pay.perly.io/docs/whmcs.md) | A payment gateway built on the PHP SDK. | | Make | [App](https://pay.perly.io/docs/make.md) | Instant triggers and modules for scenarios, without code. | | AI assistants | [MCP server](https://pay.perly.io/docs/mcp.md) | `perly-pay-mcp`: Claude Code, Cursor or Codex set up and test payments for you. | ## Install **Node** ```bash npm install perly-pay ``` **PHP** ```bash composer require perly/perly-pay ``` Make a client with your secret key from Dashboard → Developers. Keep it on your server. A `sk_test_…` key works in [test mode](https://pay.perly.io/docs/test-mode.md). **Node** ```js import { PerlyPay } from "perly-pay"; const pay = new PerlyPay(process.env.PERLY_PAY_SECRET_KEY); ``` **PHP** ```php use Perly\Pay\PerlyPay; $perly = new PerlyPay(getenv('PERLY_PAY_SECRET_KEY')); ``` ## Take a payment Make a checkout session and send the buyer to its `url`. The money goes to an address made from **your** key. **Node** ```js const session = await pay.checkoutSessions.create({ line_items: [{ price: "price_…", quantity: 1 }], return_url: "https://shop.example/thanks", metadata: { order_id: "1042" }, }); redirect(session.url); ``` **PHP** ```php $session = $perly->checkoutSessions->create([ 'line_items' => [['price' => 'price_…', 'quantity' => 1]], 'return_url' => 'https://shop.example/thanks', 'metadata' => ['order_id' => '1042'], ]); header('Location: ' . $session['url'], true, 303); ``` Node returns typed objects; PHP returns the same JSON as associative arrays. Every call in the [API reference](https://pay.perly.io/docs/api.md) shows both. ## Verify a webhook Check the `Perly-Signature` header on the **raw** body before you trust a delivery. Both SDKs refuse a changed body, a wrong secret and a signature older than five minutes. See [Webhooks](https://pay.perly.io/docs/webhooks.md). **Node** ```js export async function POST(request) { const event = pay.webhooks.verify( await request.text(), // the raw body request.headers.get("perly-signature"), process.env.PERLY_PAY_WEBHOOK_SECRET, ); // throws SignatureVerificationError if (event.type === "invoice.fulfilled") await ship(event.data.object.metadata.order_id); return new Response(null, { status: 204 }); } ``` **PHP** ```php use Perly\Pay\Exception\SignatureVerificationException; use Perly\Pay\Webhook; try { $event = Webhook::verify( file_get_contents('php://input'), // the raw body $_SERVER['HTTP_PERLY_SIGNATURE'] ?? null, getenv('PERLY_PAY_WEBHOOK_SECRET'), ); } catch (SignatureVerificationException $e) { http_response_code(400); exit; } if ($event['type'] === 'invoice.fulfilled') { ship($event['data']['object']['metadata']['order_id']); } http_response_code(204); ``` ## Retries and idempotency A POST that times out may still have happened. The SDKs give every POST a fresh key and send the same key on each retry, so a retry never makes a second invoice. Pass your own key to tie a call to your order: **Node** ```js await pay.invoices.create(params, { idempotencyKey: `order-${order.id}` }); ``` **PHP** ```php $perly->invoices->create($params, ['idempotency_key' => 'order-' . $order->id]); ``` Both retry twice by default on `429`, `5xx` and no answer, waiting longer each time and as long as `Retry-After` asks. Change it per client or per call (`maxRetries` / `max_retries`, `timeoutMs` / `timeout`). ## Errors Every failure carries the API's `type`, `code`, `message` and `param`, the HTTP status, and the request id to quote to support. | When | Node | PHP | | --- | --- | --- | | A field is wrong | `InvalidRequestError` | `InvalidRequestException` | | The key is missing or wrong | `AuthenticationError` | `AuthenticationException` | | The key may not do this | `PermissionError` | `PermissionException` | | Too many requests | `RateLimitError` | `RateLimitException` | | Same key, different request | `IdempotencyError` | `IdempotencyException` | | No answer at all | `ConnectionError` | `ConnectionException` | | A webhook doesn't check out | `SignatureVerificationError` | `SignatureVerificationException` | | Anything else | `APIError` | `ApiException` | All of them extend `PerlyPayError` (Node) or `PerlyPayException` (PHP). A call from a place Perly Pay can't be used ([Sanctions and risk](https://pay.perly.io/sanctions)) is refused as a `PermissionError` (`PermissionException`) with code `region_blocked` (451), whatever the key, and is not retried. The place is your server's own connection: run it somewhere else. A key made by a team member stops working (`AuthenticationError`, `api_key_invalid`) when they leave the team or move to a role that can't manage keys. Every owner gets a letter naming the keys. Make the key your server runs on as an owner, or make a new one then. In live mode a new invoice or checkout session is refused as a `PermissionError` (`PermissionException`) with code `bill_overdue` (403) while a Perly Pay bill is unpaid past its 14 days, until you pay it in Dashboard → Billing. Pay as you go has no limit on sales. A business still on a plan paid ahead gets the same refusal with code `plan_limit_reached` (403) past that plan's limit and its 10% buffer, until the plan's period ends. Invoices already made still get paid; test mode is never limited. ## License keys from your app Software you ship never holds a secret key. `LicenseClient` (in both SDKs) validates, activates and deactivates a buyer's license key with that key and your `product`, which is required: without it the call is refused. **Node** ```js import { LicenseClient } from "perly-pay"; const result = await new LicenseClient().activate({ key, product: "prod_…", instance_name: os.hostname() }); ``` **PHP** ```php use Perly\Pay\LicenseClient; $result = (new LicenseClient())->activate(['key' => $key, 'product' => 'prod_…', 'instance_name' => gethostname()]); ``` ## WooCommerce and WHMCS The [WooCommerce plugin](https://pay.perly.io/docs/woocommerce.md) and the [WHMCS module](https://pay.perly.io/docs/whmcs.md) are built on the PHP SDK, on their way to WordPress.org and the WHMCS Marketplace. [Write to us](https://pay.perly.io/contact) to try either first. ## Without an SDK Any language with HTTPS works: the API is JSON over HTTPS, described by an [OpenAPI document](https://pay.perly.io/api/v1/openapi.json) you can generate a client from. The [Webhooks](https://pay.perly.io/docs/webhooks.md) page has signature checks in Node, Python and PHP without an SDK. --- # API reference > Every endpoint and object, with each call in curl and Node. **Base URL:** `https://pay.perly.io/api/v1` (v1.0.0) Authenticate with your secret key as a bearer token: `sk_test_…` works in test mode, `sk_live_…` in live. The key decides the mode. A key stops working when the team member who made it leaves or may no longer manage keys: make your server's key as an owner, or make a new one then. Send an `Idempotency-Key` with every POST: a retry with the same key returns the first answer for 24 hours. The SDKs do it for you. An error answers `{ "error": { "type", "code", "message", "param" } }` with a 4xx or 5xx. A call from a place Perly Pay can't be used, your own server's included, gets `451` with code `region_blocked` ([Sanctions and risk](https://pay.perly.io/sanctions)). Amounts in fiat are integers in cents; on-chain amounts are integer strings in the asset's smallest unit. **curl** ```bash curl https://pay.perly.io/api/v1/invoices \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js // npm install perly-pay import { PerlyPay } from "perly-pay"; const pay = new PerlyPay(process.env.PERLY_PAY_SECRET_KEY); ``` **PHP** ```php // composer require perly/perly-pay use Perly\Pay\PerlyPay; $perly = new PerlyPay(getenv('PERLY_PAY_SECRET_KEY')); ``` The calls below use these clients: `pay` (Node) and `$perly` (PHP). See [SDKs](https://pay.perly.io/docs/sdks.md). ## Invoices ### List invoices `GET /invoices` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `status (query)` | `string` | | | `customer (query)` | `string` | A customer id, `cus_…`. | | `subscription (query)` | `string` | A subscription id, `sub_…`. | **Returns:** a page of `Invoice` **curl** ```bash curl "https://pay.perly.io/api/v1/invoices?status=paid&limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const invoices = await pay.invoices.list({ status: "paid", limit: 10, }); ``` **PHP** ```php $invoices = $perly->invoices->list([ 'status' => 'paid', 'limit' => 10, ]); ``` ### Create an invoice `POST /invoices` Lines are prices (`{ price, quantity }`) or your own (`{ description, amount, quantity }`), in one currency. For a single amount, `amount` + `description` is short for one line. `customer` is an id or `{ email }`. A price line may carry its own `description` ("Seat #12"), and `discount` takes an amount off this invoice alone. The buyer pays at `url`, choosing bitcoin or a stablecoin there. Live mode: a new invoice is refused with 403 `bill_overdue` while a Perly Pay bill is unpaid past its 14 days, until it is paid in the dashboard's Billing; invoices already made are still paid. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `line_items` | `object[]` | | | `amount` | `integer` | One amount instead of line_items in minor units (cents), an integer. | | `description` | `string` | With `amount`: what it is for. | | `currency` | `"USD" \| "EUR"` | For lines without a price. Default: your account's currency. | | `customer` | `string \| object` | A customer id, or `{ email }` to find or make one. | | `customer_email` | `string` | Without `customer`: finds or makes the customer by email. | | `coupon` | `string` | | | `discount` | `integer` | A one-off amount off this invoice (a credit you owe the buyer; not with `coupon`) in minor units (cents), an integer. | | `subscription` | `string` | A subscription id, `sub_…`. | | `return_url` | `string` | Where the buyer's page sends them back. | | `expires_in_minutes` | `integer` | Until when a rail may be chosen. | | `locale` | `string` | | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `Invoice` **curl** ```bash curl https://pay.perly.io/api/v1/invoices \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "line_items": [ { "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "quantity": 1 } ], "customer_email": "ada@example.com", "metadata": { "order_id": "1042" } }' ``` **Node** ```js const invoice = await pay.invoices.create({ line_items: [ { price: "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", quantity: 1, }, ], customer_email: "ada@example.com", metadata: { order_id: "1042" }, }); ``` **PHP** ```php $invoice = $perly->invoices->create([ 'line_items' => [ [ 'price' => 'price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'quantity' => 1, ], ], 'customer_email' => 'ada@example.com', 'metadata' => ['order_id' => '1042'], ]); ``` ### Retrieve an invoice `GET /invoices/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Invoice` **curl** ```bash curl https://pay.perly.io/api/v1/invoices/inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const invoice = await pay.invoices.retrieve("inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $invoice = $perly->invoices->retrieve('inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Cancel an invoice `POST /invoices/{id}/cancel` Only while nothing has arrived. Money that arrives later is still seen (the address stays watched) and lands in review. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Returns:** `Invoice` **curl** ```bash curl https://pay.perly.io/api/v1/invoices/inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/cancel \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{}' ``` **Node** ```js const invoice = await pay.invoices.cancel("inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $invoice = $perly->invoices->cancel('inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ## Test mode ### Simulate a payment (test mode only) `POST /invoices/{id}/simulate` Test keys only. `full` pays the amount due: the invoice turns paid and is fulfilled — entitlements, license keys, a receipt, and the events your webhooks get. `short` sends half (underpaid), `over` a tenth more (overpaid); those two need the buyer's chain and asset chosen first. For `full`, an invoice with no chain yet gets one: `chain` and `asset` if given, else the first stablecoin offered (refund terms count as accepted). **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `outcome` | `"full" \| "short" \| "over"` | Default full. | | `chain` | `string` | | | `asset` | `"BTC" \| "USDC" \| "USDT"` | | **Returns:** `Invoice` **curl** ```bash curl https://pay.perly.io/api/v1/invoices/inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/simulate \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"outcome":"full"}' ``` **Node** ```js const invoice = await pay.invoices.simulate("inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { outcome: "full", }); ``` **PHP** ```php $invoice = $perly->invoices->simulate('inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'outcome' => 'full', ]); ``` ## Checkout sessions ### List checkout sessions `GET /checkout_sessions` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `status (query)` | `"open" \| "complete" \| "expired"` | | | `customer (query)` | `string` | A customer id, `cus_…`. | **Returns:** a page of `CheckoutSession` **curl** ```bash curl "https://pay.perly.io/api/v1/checkout_sessions?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const checkoutSessions = await pay.checkoutSessions.list({ limit: 10, }); ``` **PHP** ```php $checkoutSessions = $perly->checkoutSessions->list([ 'limit' => 10, ]); ``` ### Create a checkout session `POST /checkout_sessions` A cart of 1–20 prices in one currency. Send the buyer to `url`, or open it in an overlay with pay.js (`data-session="cs_…"`). The buyer comes back to `return_url` after paying, `cancel_url` if they leave. Live mode: a new session is refused with 403 `bill_overdue` while a Perly Pay bill is unpaid past its 14 days. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `line_items` — required | `object[]` | | | `return_url` | `string` | An http(s) URL. | | `cancel_url` | `string` | An http(s) URL. | | `customer` | `string` | A customer id, `cus_…`. | | `customer_email` | `string` | | | `coupon` | `string` | A coupon code. | | `expires_in_minutes` | `integer` | Default 1440 (24 hours). | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `CheckoutSession` **curl** ```bash curl https://pay.perly.io/api/v1/checkout_sessions \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "line_items": [ { "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "quantity": 1 } ], "return_url": "https://shop.example/thanks", "cancel_url": "https://shop.example/cart", "metadata": { "order_id": "1042" } }' ``` **Node** ```js const session = await pay.checkoutSessions.create({ line_items: [ { price: "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", quantity: 1, }, ], return_url: "https://shop.example/thanks", cancel_url: "https://shop.example/cart", metadata: { order_id: "1042" }, }); ``` **PHP** ```php $session = $perly->checkoutSessions->create([ 'line_items' => [ [ 'price' => 'price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'quantity' => 1, ], ], 'return_url' => 'https://shop.example/thanks', 'cancel_url' => 'https://shop.example/cart', 'metadata' => ['order_id' => '1042'], ]); ``` ### Retrieve a checkout session `GET /checkout_sessions/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `CheckoutSession` **curl** ```bash curl https://pay.perly.io/api/v1/checkout_sessions/cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const session = await pay.checkoutSessions.retrieve("cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $session = $perly->checkoutSessions->retrieve('cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Expire a checkout session `POST /checkout_sessions/{id}/expire` The buyer can no longer pay it. Only an open session. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Returns:** `CheckoutSession` **curl** ```bash curl https://pay.perly.io/api/v1/checkout_sessions/cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/expire \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{}' ``` **Node** ```js const session = await pay.checkoutSessions.expire("cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $session = $perly->checkoutSessions->expire('cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Open a checkout session's page `GET /embed/checkout_sessions/{id}` — no key needed Public. Redirects (302) to the session's hosted checkout. pay.js uses it; `origin` (your page's origin) marks it as embedded so the page can talk back to pay.js. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `origin (query)` | `string` | | **Errors** - `302` — A redirect (Location) to the page. **curl** ```bash curl https://pay.perly.io/api/v1/embed/checkout_sessions/cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 ``` ## Products ### List products `GET /products` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `active (query)` | `"true" \| "false"` | | **Returns:** a page of `Product` **curl** ```bash curl "https://pay.perly.io/api/v1/products?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const products = await pay.products.list({ limit: 10, }); ``` **PHP** ```php $products = $perly->products->list([ 'limit' => 10, ]); ``` ### Create a product `POST /products` **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `name` — required | `string` | | | `description` | `string` | | | `image_url` | `string` | | | `issues_license` | `boolean` | Issue a license key with every purchase. | | `license_activation_limit` | `integer` | Instances a key may be active on. | | `active` | `boolean` | | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `Product` **curl** ```bash curl https://pay.perly.io/api/v1/products \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "name": "Pro plan", "description": "Everything in Pro, for a year." }' ``` **Node** ```js const product = await pay.products.create({ name: "Pro plan", description: "Everything in Pro, for a year.", }); ``` **PHP** ```php $product = $perly->products->create([ 'name' => 'Pro plan', 'description' => 'Everything in Pro, for a year.', ]); ``` ### Retrieve a product `GET /products/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Product` **curl** ```bash curl https://pay.perly.io/api/v1/products/prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const product = await pay.products.retrieve("prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $product = $perly->products->retrieve('prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Update a product `POST /products/{id}` Set `active: false` to stop selling it; products are never deleted (invoices point at them). **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `name` | `string` | | | `description` | `string \| null` | | | `image_url` | `string \| null` | | | `issues_license` | `boolean` | Issue a license key with every purchase. | | `license_activation_limit` | `integer \| null` | Instances a key may be active on. | | `active` | `boolean` | | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `Product` **curl** ```bash curl https://pay.perly.io/api/v1/products/prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"description":"Everything in Pro, for a year, with support."}' ``` **Node** ```js const product = await pay.products.update("prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { description: "Everything in Pro, for a year, with support.", }); ``` **PHP** ```php $product = $perly->products->update('prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'description' => 'Everything in Pro, for a year, with support.', ]); ``` ## Prices ### List prices `GET /prices` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `product (query)` | `string` | A product id, `prod_…`. | | `model (query)` | `"one_time" \| "pay_what_you_want" \| "fixed_term" \| "subscription" \| "lifetime" \| "credit_pack"` | | | `active (query)` | `"true" \| "false"` | | **Returns:** a page of `Price` **curl** ```bash curl "https://pay.perly.io/api/v1/prices?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const prices = await pay.prices.list({ limit: 10, }); ``` **PHP** ```php $prices = $perly->prices->list([ 'limit' => 10, ]); ``` ### Create a price `POST /prices` Every model: one_time, pay_what_you_want (with a minimum), fixed_term (N days of access), subscription (interval, trial, grace, prepay), lifetime (optional cap and cohort tiers), credit_pack. Amounts are immutable; make a new price to change one. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `product` — required | `string` | A product id, `prod_…`. | | `currency` | `"USD" \| "EUR"` | USD or EUR. Default: your account's currency. | | `nickname` | `string` | | | `refunds` | `object` | Refund terms the buyer accepts at checkout. | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | | `model` — required | `"one_time"` | | | `unit_amount` — required | `integer` | Amount in minor units (cents), an integer. | **Returns:** `Price` **curl** ```bash curl https://pay.perly.io/api/v1/prices \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "model": "one_time", "unit_amount": 4900, "currency": "USD" }' ``` **Node** ```js const price = await pay.prices.create({ product: "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", model: "one_time", unit_amount: 4900, currency: "USD", }); ``` **PHP** ```php $price = $perly->prices->create([ 'product' => 'prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'model' => 'one_time', 'unit_amount' => 4900, 'currency' => 'USD', ]); ``` ### Retrieve a price `GET /prices/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Price` **curl** ```bash curl https://pay.perly.io/api/v1/prices/price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const price = await pay.prices.retrieve("price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $price = $perly->prices->retrieve('price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Update a price `POST /prices/{id}` Nickname, active, refund terms (`refunds` replaces them whole; what you leave out takes its default) and metadata. Amounts and the model never change. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `nickname` | `string \| null` | | | `active` | `boolean` | | | `refunds` | `object` | Refund terms the buyer accepts at checkout. | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `Price` **curl** ```bash curl https://pay.perly.io/api/v1/prices/price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"nickname":"Launch price"}' ``` **Node** ```js const price = await pay.prices.update("price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { nickname: "Launch price", }); ``` **PHP** ```php $price = $perly->prices->update('price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'nickname' => 'Launch price', ]); ``` ## Customers ### List customers `GET /customers` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `email (query)` | `string` | | | `external_id (query)` | `string` | | **Returns:** a page of `Customer` **curl** ```bash curl "https://pay.perly.io/api/v1/customers?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const customers = await pay.customers.list({ limit: 10, }); ``` **PHP** ```php $customers = $perly->customers->list([ 'limit' => 10, ]); ``` ### Create a customer `POST /customers` **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `email` | `string` | | | `name` | `string` | | | `external_id` | `string` | Your own id for this buyer (unique). | | `country` | `string` | ISO 3166-1 alpha-2. | | `locale` | `string` | | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `Customer` **curl** ```bash curl https://pay.perly.io/api/v1/customers \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "name": "Ada Lovelace", "external_id": "user_42" }' ``` **Node** ```js const customer = await pay.customers.create({ email: "ada@example.com", name: "Ada Lovelace", external_id: "user_42", }); ``` **PHP** ```php $customer = $perly->customers->create([ 'email' => 'ada@example.com', 'name' => 'Ada Lovelace', 'external_id' => 'user_42', ]); ``` ### Retrieve a customer `GET /customers/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Customer` **curl** ```bash curl https://pay.perly.io/api/v1/customers/cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const customer = await pay.customers.retrieve("cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $customer = $perly->customers->retrieve('cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Update a customer `POST /customers/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `email` | `string \| null` | | | `name` | `string \| null` | | | `external_id` | `string` | Your own id for this buyer (unique). | | `country` | `string` | ISO 3166-1 alpha-2. | | `locale` | `string` | | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `Customer` **curl** ```bash curl https://pay.perly.io/api/v1/customers/cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"name":"Ada King"}' ``` **Node** ```js const customer = await pay.customers.update("cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { name: "Ada King", }); ``` **PHP** ```php $customer = $perly->customers->update('cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'name' => 'Ada King', ]); ``` ## Subscriptions ### List subscriptions `GET /subscriptions` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `status (query)` | `string` | | | `customer (query)` | `string` | A customer id, `cus_…`. | | `price (query)` | `string` | A price id, `price_…`. | **Returns:** a page of `Subscription` **curl** ```bash curl "https://pay.perly.io/api/v1/subscriptions?status=active&limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const subscriptions = await pay.subscriptions.list({ status: "active", limit: 10, }); ``` **PHP** ```php $subscriptions = $perly->subscriptions->list([ 'status' => 'active', 'limit' => 10, ]); ``` ### Create a subscription `POST /subscriptions` Renewed by invoice: the customer pays each period themselves. Without a trial the first invoice opens now (`latest_invoice`); the subscription turns active when it is paid. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `customer` — required | `string` | A customer id, `cus_…`. | | `price` — required | `string` | A subscription price. | | `quantity` | `integer` | | | `coupon` | `string` | | | `trial_days` | `integer` | Default: the price's. | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `Subscription` **curl** ```bash curl https://pay.perly.io/api/v1/subscriptions \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50" }' ``` **Node** ```js const subscription = await pay.subscriptions.create({ customer: "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", price: "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", }); ``` **PHP** ```php $subscription = $perly->subscriptions->create([ 'customer' => 'cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'price' => 'price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', ]); ``` ### Retrieve a subscription `GET /subscriptions/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Subscription` **curl** ```bash curl https://pay.perly.io/api/v1/subscriptions/sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const subscription = await pay.subscriptions.retrieve("sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $subscription = $perly->subscriptions->retrieve('sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Update a subscription `POST /subscriptions/{id}` `price`: change plan — an upgrade applies now (prorated), a downgrade at the period end (`pending_price`). `cancel_at_period_end`: stop renewing, or take that back. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `price` | `string` | A price id, `price_…`. | | `cancel_at_period_end` | `boolean` | | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `Subscription` **curl** ```bash curl https://pay.perly.io/api/v1/subscriptions/sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"cancel_at_period_end":true}' ``` **Node** ```js const subscription = await pay.subscriptions.update("sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { cancel_at_period_end: true, }); ``` **PHP** ```php $subscription = $perly->subscriptions->update('sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'cancel_at_period_end' => true, ]); ``` ### Cancel a subscription `POST /subscriptions/{id}/cancel` Now, or with `at_period_end: true` when the paid period runs out. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `at_period_end` | `boolean` | | **Returns:** `Subscription` **curl** ```bash curl https://pay.perly.io/api/v1/subscriptions/sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/cancel \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"at_period_end":true}' ``` **Node** ```js const subscription = await pay.subscriptions.cancel("sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { at_period_end: true, }); ``` **PHP** ```php $subscription = $perly->subscriptions->cancel('sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'at_period_end' => true, ]); ``` ### Pause a subscription `POST /subscriptions/{id}/pause` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Returns:** `Subscription` **curl** ```bash curl https://pay.perly.io/api/v1/subscriptions/sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/pause \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{}' ``` **Node** ```js const subscription = await pay.subscriptions.pause("sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $subscription = $perly->subscriptions->pause('sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Resume a paused subscription `POST /subscriptions/{id}/resume` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Returns:** `Subscription` **curl** ```bash curl https://pay.perly.io/api/v1/subscriptions/sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/resume \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{}' ``` **Node** ```js const subscription = await pay.subscriptions.resume("sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $subscription = $perly->subscriptions->resume('sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Invoice several periods ahead `POST /subscriptions/{id}/prepay` Opens one invoice for `periods` periods (up to the price's `prepay_max_periods`); paying it moves the period on. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `periods` — required | `integer` | | **Returns:** `Invoice` **curl** ```bash curl https://pay.perly.io/api/v1/subscriptions/sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/prepay \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"periods":3}' ``` **Node** ```js const invoice = await pay.subscriptions.prepay("sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { periods: 3, }); ``` **PHP** ```php $invoice = $perly->subscriptions->prepay('sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'periods' => 3, ]); ``` ## Payment links ### List payment links `GET /payment_links` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `active (query)` | `"true" \| "false"` | | | `price (query)` | `string` | A price id, `price_…`. | **Returns:** a page of `PaymentLink` **curl** ```bash curl "https://pay.perly.io/api/v1/payment_links?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const paymentLinks = await pay.paymentLinks.list({ limit: 10, }); ``` **PHP** ```php $paymentLinks = $perly->paymentLinks->list([ 'limit' => 10, ]); ``` ### Create a payment link `POST /payment_links` A page at /l/\ that sells one price. Share it anywhere, or open it with pay.js. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `price` — required | `string` | A price id, `price_…`. | | `quantity` | `integer` | | | `adjustable_quantity` | `boolean` | | | `allow_coupons` | `boolean` | | | `collect_name` | `boolean` | | | `success_url` | `string` | Where the buyer goes after paying. | | `slug` | `string` | Default: random. | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `PaymentLink` **curl** ```bash curl https://pay.perly.io/api/v1/payment_links \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "adjustable_quantity": true }' ``` **Node** ```js const paymentLink = await pay.paymentLinks.create({ price: "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", adjustable_quantity: true, }); ``` **PHP** ```php $paymentLink = $perly->paymentLinks->create([ 'price' => 'price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'adjustable_quantity' => true, ]); ``` ### Retrieve a payment link `GET /payment_links/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `PaymentLink` **curl** ```bash curl https://pay.perly.io/api/v1/payment_links/plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const paymentLink = await pay.paymentLinks.retrieve("plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $paymentLink = $perly->paymentLinks->retrieve('plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Update a payment link `POST /payment_links/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `active` | `boolean` | | | `quantity` | `integer` | | | `adjustable_quantity` | `boolean` | | | `allow_coupons` | `boolean` | | | `collect_name` | `boolean` | | | `success_url` | `string \| null` | An http(s) URL. | | `metadata` | `map` | Your own key–value pairs (≤ 50 keys, values ≤ 500 characters). On an update they are merged in; an empty value removes its key. | **Returns:** `PaymentLink` **curl** ```bash curl https://pay.perly.io/api/v1/payment_links/plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"active":false}' ``` **Node** ```js const paymentLink = await pay.paymentLinks.update("plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { active: false, }); ``` **PHP** ```php $paymentLink = $perly->paymentLinks->update('plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'active' => false, ]); ``` ## Coupons ### List coupons `GET /coupons` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `active (query)` | `"true" \| "false"` | | | `code (query)` | `string` | | **Returns:** a page of `Coupon` **curl** ```bash curl "https://pay.perly.io/api/v1/coupons?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const coupons = await pay.coupons.list({ limit: 10, }); ``` **PHP** ```php $coupons = $perly->coupons->list([ 'limit' => 10, ]); ``` ### Create a coupon `POST /coupons` Give `percent_off`, or `amount_off` with its `currency`. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `code` — required | `string` | What the buyer types. Case does not matter. | | `percent_off` | `integer` | | | `amount_off` | `integer` | Amount off in minor units (cents), an integer. | | `currency` | `"USD" \| "EUR"` | USD or EUR. | | `duration` | `"once" \| "repeating" \| "forever"` | For subscriptions: once (default), repeating, forever. | | `duration_periods` | `integer` | With duration `repeating`. | | `max_redemptions` | `integer` | | | `products` | `string[]` | Only these products. Empty: every product. | | `expires_at` | `string` | | **Returns:** `Coupon` **curl** ```bash curl https://pay.perly.io/api/v1/coupons \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"code":"WELCOME10","percent_off":10}' ``` **Node** ```js const coupon = await pay.coupons.create({ code: "WELCOME10", percent_off: 10, }); ``` **PHP** ```php $coupon = $perly->coupons->create([ 'code' => 'WELCOME10', 'percent_off' => 10, ]); ``` ### Retrieve a coupon `GET /coupons/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Coupon` **curl** ```bash curl https://pay.perly.io/api/v1/coupons/cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const coupon = await pay.coupons.retrieve("cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $coupon = $perly->coupons->retrieve('cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Update a coupon `POST /coupons/{id}` Turn it off, move its end, or change how many may use it. What it takes off never changes. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `active` | `boolean` | | | `expires_at` | `string \| null` | | | `max_redemptions` | `integer \| null` | | **Returns:** `Coupon` **curl** ```bash curl https://pay.perly.io/api/v1/coupons/cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"max_redemptions":100}' ``` **Node** ```js const coupon = await pay.coupons.update("cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { max_redemptions: 100, }); ``` **PHP** ```php $coupon = $perly->coupons->update('cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'max_redemptions' => 100, ]); ``` ## Entitlements ### Check access `GET /entitlements/check` One call: may this customer use this product now? Name the customer by `customer`, `customer_email` or `customer_external_id`, and the product by `product` (or `price`). **Parameters** | Name | Type | Description | | --- | --- | --- | | `customer (query)` | `string` | A customer id, `cus_…`. | | `customer_email (query)` | `string` | | | `customer_external_id (query)` | `string` | | | `product (query)` | `string` | A product id, `prod_…`. | | `price (query)` | `string` | A price id, `price_…`. | **Returns:** `EntitlementCheck` **curl** ```bash curl "https://pay.perly.io/api/v1/entitlements/check?customer_email=ada%40example.com&product=prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const entitlementCheck = await pay.entitlements.check({ customer_email: "ada@example.com", product: "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", }); ``` **PHP** ```php $entitlementCheck = $perly->entitlements->check([ 'customer_email' => 'ada@example.com', 'product' => 'prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', ]); ``` ### List entitlements `GET /entitlements` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `customer (query)` | `string` | A customer id, `cus_…`. | | `product (query)` | `string` | A product id, `prod_…`. | | `subscription (query)` | `string` | A subscription id, `sub_…`. | **Returns:** a page of `Entitlement` **curl** ```bash curl "https://pay.perly.io/api/v1/entitlements?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const entitlements = await pay.entitlements.list({ limit: 10, }); ``` **PHP** ```php $entitlements = $perly->entitlements->list([ 'limit' => 10, ]); ``` ### Retrieve an entitlement `GET /entitlements/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Entitlement` **curl** ```bash curl https://pay.perly.io/api/v1/entitlements/ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const entitlement = await pay.entitlements.retrieve("ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $entitlement = $perly->entitlements->retrieve('ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Spend credits `POST /entitlements/{id}/consume` Spends credits from one credit pack, atomically. Refused when fewer are left. `action` labels the spend for your reports; with an `Idempotency-Key` a retry never spends twice. To spend from a customer's packs together, use POST /credits/consume. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `credits` — required | `integer` | | | `action` | `string` | Your label for what the credits paid for. | **Returns:** `Entitlement` **curl** ```bash curl https://pay.perly.io/api/v1/entitlements/ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/consume \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"credits":1,"action":"chat"}' ``` **Node** ```js const entitlement = await pay.entitlements.consume("ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { credits: 1, action: "chat", }); ``` **PHP** ```php $entitlement = $perly->entitlements->consume('ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'credits' => 1, 'action' => 'chat', ]); ``` ## Credits ### Retrieve a credit balance `GET /credits/balance` What a customer has left to spend — on one product's credit packs (`product`), or on all of them — with the buyer's own `top_up_url`: a stable page where they choose a pack and pay. Show it, or send it when `low`. **Parameters** | Name | Type | Description | | --- | --- | --- | | `customer (query)` | `string` | A customer id, `cus_…`. | | `customer_email (query)` | `string` | | | `customer_external_id (query)` | `string` | | | `product (query)` | `string` | A product id, `prod_…`. | **Returns:** `CreditBalance` **curl** ```bash curl "https://pay.perly.io/api/v1/credits/balance?customer_external_id=user_42&product=prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const creditBalance = await pay.credits.balance({ customer_external_id: "user_42", product: "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", }); ``` **PHP** ```php $creditBalance = $perly->credits->balance([ 'customer_external_id' => 'user_42', 'product' => 'prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', ]); ``` ### Spend credits `POST /credits/consume` Spends `credits` from the customer's packs — of `product`, or of any — oldest first, atomically, never below zero. Refused with `insufficient_credits` (409) when fewer are left; nothing is spent then. Send an `Idempotency-Key`: a retry with it returns the first spend and never spends twice, however late it comes. Crossing the product's low-balance threshold sends `credits.low`; reaching zero, `credits.depleted`. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `customer` | `string` | A customer id, `cus_…`. | | `customer_email` | `string` | | | `customer_external_id` | `string` | | | `credits` — required | `integer` | | | `product` | `string` | A product id, `prod_…`. | | `action` | `string` | Your label for what the credits paid for (`chat`, `image.hd`, `api/search`). Shows in your dashboard by action. | **Returns:** `CreditUsage` **curl** ```bash curl https://pay.perly.io/api/v1/credits/consume \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"customer_external_id":"user_42","credits":1,"action":"chat"}' ``` **Node** ```js const creditUsage = await pay.credits.consume({ customer_external_id: "user_42", credits: 1, action: "chat", }); ``` **PHP** ```php $creditUsage = $perly->credits->consume([ 'customer_external_id' => 'user_42', 'credits' => 1, 'action' => 'chat', ]); ``` ### List credit spends `GET /credits/usage` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `customer (query)` | `string` | A customer id, `cus_…`. | | `product (query)` | `string` | A product id, `prod_…`. | | `action (query)` | `string` | Your label for what the credits paid for (`chat`, `image.hd`, `api/search`). Shows in your dashboard by action. | **Returns:** a page of `CreditUsage` **curl** ```bash curl "https://pay.perly.io/api/v1/credits/usage?customer=cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50&action=chat&limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const credits = await pay.credits.usage({ customer: "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", action: "chat", limit: 10, }); ``` **PHP** ```php $credits = $perly->credits->usage([ 'customer' => 'cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'action' => 'chat', 'limit' => 10, ]); ``` ## License keys ### Validate a license key `POST /license_keys/validate` — no key needed Public: call it from your app with the license key and your `product`, no secret key. `product` is required (`parameter_missing` without it): a key of any other product or account answers `wrong_product`. With `instance` (an activation id) or `instance_name`, also checks that this instance is activated. **Body** | Name | Type | Description | | --- | --- | --- | | `key` — required | `string` | XXXXX-XXXXX-XXXXX-XXXXX | | `product` | `string` | Your product, `prod_…`. Required unless you call with your secret key: a key for any other product, or from another account, is refused. | | `merchant` | `string` | Your account's slug. Only needed if two accounts ever share a key (practically never). | | `livemode` | `boolean` | Optional: `true` accepts only a live key, `false` only a test-mode one. Your product's own mode either way. | | `instance` | `string` | An activation id, `act_…`. | | `instance_name` | `string` | Your name for the machine or site, e.g. a hostname. | **Returns:** `LicenseCheck` **curl** ```bash curl https://pay.perly.io/api/v1/license_keys/validate \ -H "Content-Type: application/json" \ -d '{ "key": "PERLY-8KQ2-X7LM-4N9P", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50" }' ``` **Node** ```js const licenseCheck = await pay.licenseKeys.validate({ key: "PERLY-8KQ2-X7LM-4N9P", product: "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", }); ``` **PHP** ```php $licenseCheck = $perly->licenseKeys->validate([ 'key' => 'PERLY-8KQ2-X7LM-4N9P', 'product' => 'prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', ]); ``` ### Activate a license key on an instance `POST /license_keys/activate` — no key needed Public, with the license key and your `product` (required). Activating the same `instance_name` again returns its activation. Refused (`limit_reached`) when the key is active on as many instances as it allows. **Body** | Name | Type | Description | | --- | --- | --- | | `key` — required | `string` | XXXXX-XXXXX-XXXXX-XXXXX | | `product` | `string` | Your product, `prod_…`. Required unless you call with your secret key: a key for any other product, or from another account, is refused. | | `merchant` | `string` | Your account's slug. Only needed if two accounts ever share a key (practically never). | | `livemode` | `boolean` | Optional: `true` accepts only a live key, `false` only a test-mode one. Your product's own mode either way. | | `instance_name` — required | `string` | Your name for the machine or site, e.g. a hostname. | **Returns:** `LicenseCheck` **curl** ```bash curl https://pay.perly.io/api/v1/license_keys/activate \ -H "Content-Type: application/json" \ -d '{ "key": "PERLY-8KQ2-X7LM-4N9P", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "instance_name": "ada-laptop" }' ``` **Node** ```js const licenseCheck = await pay.licenseKeys.activate({ key: "PERLY-8KQ2-X7LM-4N9P", product: "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", instance_name: "ada-laptop", }); ``` **PHP** ```php $licenseCheck = $perly->licenseKeys->activate([ 'key' => 'PERLY-8KQ2-X7LM-4N9P', 'product' => 'prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'instance_name' => 'ada-laptop', ]); ``` ### Deactivate a license key on an instance `POST /license_keys/deactivate` — no key needed Public, with the license key and your `product` (required). Frees a seat: give the activation id (`instance`) or the `instance_name`. **Body** | Name | Type | Description | | --- | --- | --- | | `key` — required | `string` | XXXXX-XXXXX-XXXXX-XXXXX | | `product` | `string` | Your product, `prod_…`. Required unless you call with your secret key: a key for any other product, or from another account, is refused. | | `merchant` | `string` | Your account's slug. Only needed if two accounts ever share a key (practically never). | | `livemode` | `boolean` | Optional: `true` accepts only a live key, `false` only a test-mode one. Your product's own mode either way. | | `instance` | `string` | | | `instance_name` | `string` | Your name for the machine or site, e.g. a hostname. | **Returns:** `LicenseCheck` **curl** ```bash curl https://pay.perly.io/api/v1/license_keys/deactivate \ -H "Content-Type: application/json" \ -d '{ "key": "PERLY-8KQ2-X7LM-4N9P", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "instance_name": "ada-laptop" }' ``` **Node** ```js const licenseCheck = await pay.licenseKeys.deactivate({ key: "PERLY-8KQ2-X7LM-4N9P", product: "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", instance_name: "ada-laptop", }); ``` **PHP** ```php $licenseCheck = $perly->licenseKeys->deactivate([ 'key' => 'PERLY-8KQ2-X7LM-4N9P', 'product' => 'prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'instance_name' => 'ada-laptop', ]); ``` ### List license keys `GET /license_keys` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `customer (query)` | `string` | A customer id, `cus_…`. | | `product (query)` | `string` | A product id, `prod_…`. | | `status (query)` | `"active" \| "suspended" \| "revoked" \| "expired"` | | **Returns:** a page of `LicenseKey` **curl** ```bash curl "https://pay.perly.io/api/v1/license_keys?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const licenseKeys = await pay.licenseKeys.list({ limit: 10, }); ``` **PHP** ```php $licenseKeys = $perly->licenseKeys->list([ 'limit' => 10, ]); ``` ### Retrieve a license key `GET /license_keys/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `LicenseKey` **curl** ```bash curl https://pay.perly.io/api/v1/license_keys/lic_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const licenseKey = await pay.licenseKeys.retrieve("lic_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $licenseKey = $perly->licenseKeys->retrieve('lic_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Revoke a license key `POST /license_keys/{id}/revoke` For good: validation answers `revoked` from now on. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Returns:** `LicenseKey` **curl** ```bash curl https://pay.perly.io/api/v1/license_keys/lic_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/revoke \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{}' ``` **Node** ```js const licenseKey = await pay.licenseKeys.revoke("lic_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $licenseKey = $perly->licenseKeys->revoke('lic_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ## Buyer portal ### Create a buyer-portal link `POST /portal_sessions` A one-hour link where your customer sees their subscriptions, receipts and license keys. Make one when they click "Billing" in your app and redirect them to `url`. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `customer` — required | `string` | A customer id, `cus_…`. | | `return_url` | `string` | Where "Back" goes. | **Returns:** `PortalSession` **curl** ```bash curl https://pay.perly.io/api/v1/portal_sessions \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "return_url": "https://shop.example/account" }' ``` **Node** ```js const portalSession = await pay.portalSessions.create({ customer: "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", return_url: "https://shop.example/account", }); ``` **PHP** ```php $portalSession = $perly->portalSessions->create([ 'customer' => 'cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', 'return_url' => 'https://shop.example/account', ]); ``` ## Payments ### List payments `GET /payments` Transfers seen on chain into addresses we handed out, newest first. **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `invoice (query)` | `string` | A invoice id, `inv_…`. | | `asset (query)` | `"BTC" \| "USDC" \| "USDT"` | | **Returns:** a page of `Payment` **curl** ```bash curl "https://pay.perly.io/api/v1/payments?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const payments = await pay.payments.list({ limit: 10, }); ``` **PHP** ```php $payments = $perly->payments->list([ 'limit' => 10, ]); ``` ### Retrieve a payment `GET /payments/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Payment` **curl** ```bash curl https://pay.perly.io/api/v1/payments/pay_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const payment = await pay.payments.retrieve("pay_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $payment = $perly->payments->retrieve('pay_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ## Refunds ### Quote a refund `GET /refunds/quote` What a refund of this invoice would be now: the amount left, today's network fee, what you would send. **Parameters** | Name | Type | Description | | --- | --- | --- | | `invoice (query)` — required | `string` | A invoice id, `inv_…`. | **Returns:** `RefundQuote` **curl** ```bash curl "https://pay.perly.io/api/v1/refunds/quote?invoice=inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const refundQuote = await pay.refunds.quote({ invoice: "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", }); ``` **PHP** ```php $refundQuote = $perly->refunds->quote([ 'invoice' => 'inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', ]); ``` ### List refunds `GET /refunds` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `invoice (query)` | `string` | A invoice id, `inv_…`. | | `status (query)` | `"requested" \| "awaiting_address" \| "ready_to_send" \| "sent" \| "confirmed" \| "canceled"` | | **Returns:** a page of `Refund` **curl** ```bash curl "https://pay.perly.io/api/v1/refunds?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const refunds = await pay.refunds.list({ limit: 10, }); ``` **PHP** ```php $refunds = $perly->refunds->list([ 'limit' => 10, ]); ``` ### Open a refund `POST /refunds` Starts a refund in the asset that was paid. We email the buyer a link to give an address; the link is never returned, since whoever holds it decides where the money goes. With no email on the invoice, the owner makes one in the dashboard. When the address is checked, the refund is `ready_to_send` with `amount_to_send`. Refused with `sanctioned_sender` when money on the invoice came from an address on a sanctions list, or one the coin's issuer froze: sending it back may itself be prohibited, so nothing refunds it — leave it and talk to a lawyer. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `invoice` — required | `string` | A invoice id, `inv_…`. | | `amount` | `string` | In the paid asset's smallest unit. Default: everything still refundable. | | `reason` | `string` | | **Returns:** `Refund` **curl** ```bash curl https://pay.perly.io/api/v1/refunds \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"invoice":"inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"}' ``` **Node** ```js const refund = await pay.refunds.create({ invoice: "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", }); ``` **PHP** ```php $refund = $perly->refunds->create([ 'invoice' => 'inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', ]); ``` ### Retrieve a refund `GET /refunds/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Refund` **curl** ```bash curl https://pay.perly.io/api/v1/refunds/re_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const refund = await pay.refunds.retrieve("re_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $refund = $perly->refunds->retrieve('re_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Record the refund's transaction `POST /refunds/{id}/mark_sent` After you sent `amount_to_send` to `refund_address`. We read that transaction on the network: it must pay the refund address the amount to send. Then the refund is `sent` (or `confirmed`, with its receipt, when already deep). This is the only way a refund moves on: a transfer we merely see at the address may be someone else's, so it never counts by itself. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `txid` — required | `string` | The transaction hash. | **Returns:** `Refund` **curl** ```bash curl https://pay.perly.io/api/v1/refunds/re_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/mark_sent \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "txid": "0x9f1c3e5a7b2d4f6081a3c5e7f9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7" }' ``` **Node** ```js const refund = await pay.refunds.markSent("re_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { txid: "0x9f1c3e5a7b2d4f6081a3c5e7f9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7", }); ``` **PHP** ```php $refund = $perly->refunds->markSent('re_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'txid' => '0x9f1c3e5a7b2d4f6081a3c5e7f9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7', ]); ``` ## Receipts ### List receipts `GET /receipts` One for every fulfilled payment and every confirmed refund, newest first. **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `invoice (query)` | `string` | A invoice id, `inv_…`. | | `kind (query)` | `"payment" \| "refund"` | | **Returns:** a page of `Receipt` **curl** ```bash curl "https://pay.perly.io/api/v1/receipts?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const receipts = await pay.receipts.list({ limit: 10, }); ``` **PHP** ```php $receipts = $perly->receipts->list([ 'limit' => 10, ]); ``` ### Retrieve a receipt `GET /receipts/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Receipt` **curl** ```bash curl https://pay.perly.io/api/v1/receipts/rcpt_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const receipt = await pay.receipts.retrieve("rcpt_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $receipt = $perly->receipts->retrieve('rcpt_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Download a receipt as PDF `GET /receipts/{id}/pdf` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `locale (query)` | `string` | | **Returns:** `application/pdf` **curl** ```bash curl https://pay.perly.io/api/v1/receipts/rcpt_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/pdf \ -H "Authorization: Bearer sk_test_…" \ -o receipt.pdf ``` **Node** ```js const pdf = await pay.receipts.pdf("rcpt_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $pdf = $perly->receipts->pdf('rcpt_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ## Events ### List events `GET /events` What happened in the last 30 days, newest first. `type` filters (`invoice.paid`, or `invoice.*`). **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | | `type (query)` | `string` | | | `object (query)` | `string` | Only events about this object (`inv_…`, `sub_…`). | **Returns:** a page of `Event` **curl** ```bash curl "https://pay.perly.io/api/v1/events?type=invoice.fulfilled&limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const events = await pay.events.list({ type: "invoice.fulfilled", limit: 10, }); ``` **PHP** ```php $events = $perly->events->list([ 'type' => 'invoice.fulfilled', 'limit' => 10, ]); ``` ### Retrieve an event `GET /events/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Event` **curl** ```bash curl https://pay.perly.io/api/v1/events/evt_1042 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const event = await pay.events.retrieve("evt_1042"); ``` **PHP** ```php $event = $perly->events->retrieve('evt_1042'); ``` ## Webhook endpoints ### List webhook endpoints `GET /webhook_endpoints` **Parameters** | Name | Type | Description | | --- | --- | --- | | `limit (query)` | `integer` | How many objects to return, 1–100. Default 10. | | `cursor (query)` | `string` | `next_cursor` from the previous page. | **Returns:** a page of `WebhookEndpoint` **curl** ```bash curl "https://pay.perly.io/api/v1/webhook_endpoints?limit=10" \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const webhookEndpoints = await pay.webhookEndpoints.list({ limit: 10, }); ``` **PHP** ```php $webhookEndpoints = $perly->webhookEndpoints->list([ 'limit' => 10, ]); ``` ### Create a webhook endpoint `POST /webhook_endpoints` The response carries `secret` — the only time you see it. Keep it to verify `Perly-Signature`. **Parameters** | Name | Type | Description | | --- | --- | --- | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `url` — required | `string` | | | `enabled_events` | `"*" \| string[]` | Event types to send, or `["*"]` for every one. | | `description` | `string` | | **Returns:** `WebhookEndpoint` **curl** ```bash curl https://pay.perly.io/api/v1/webhook_endpoints \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "url": "https://shop.example/webhooks/perly", "enabled_events": [ "invoice.fulfilled", "refund.confirmed" ] }' ``` **Node** ```js const webhookEndpoint = await pay.webhookEndpoints.create({ url: "https://shop.example/webhooks/perly", enabled_events: ["invoice.fulfilled", "refund.confirmed"], }); ``` **PHP** ```php $webhookEndpoint = $perly->webhookEndpoints->create([ 'url' => 'https://shop.example/webhooks/perly', 'enabled_events' => ['invoice.fulfilled', 'refund.confirmed'], ]); ``` ### Retrieve a webhook endpoint `GET /webhook_endpoints/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `WebhookEndpoint` **curl** ```bash curl https://pay.perly.io/api/v1/webhook_endpoints/we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const webhookEndpoint = await pay.webhookEndpoints.retrieve("we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $webhookEndpoint = $perly->webhookEndpoints->retrieve('we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Update a webhook endpoint `POST /webhook_endpoints/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `url` | `string` | | | `enabled_events` | `"*" \| string[]` | Event types to send, or `["*"]` for every one. | | `description` | `string \| null` | | | `disabled` | `boolean` | Stop sending without deleting it. | **Returns:** `WebhookEndpoint` **curl** ```bash curl https://pay.perly.io/api/v1/webhook_endpoints/we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "enabled_events": [ "invoice.fulfilled", "invoice.expired", "refund.confirmed" ] }' ``` **Node** ```js const webhookEndpoint = await pay.webhookEndpoints.update("we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { enabled_events: ["invoice.fulfilled", "invoice.expired", "refund.confirmed"], }); ``` **PHP** ```php $webhookEndpoint = $perly->webhookEndpoints->update('we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'enabled_events' => ['invoice.fulfilled', 'invoice.expired', 'refund.confirmed'], ]); ``` ### Delete a webhook endpoint `DELETE /webhook_endpoints/{id}` **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | **Returns:** `Deleted` **curl** ```bash curl -X DELETE https://pay.perly.io/api/v1/webhook_endpoints/we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50 \ -H "Authorization: Bearer sk_test_…" ``` **Node** ```js const deleted = await pay.webhookEndpoints.del("we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $deleted = $perly->webhookEndpoints->delete('we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Roll the signing secret `POST /webhook_endpoints/{id}/roll_secret` A new secret from now on; the old one stops at once. The response carries the new `secret`. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Returns:** `WebhookEndpoint` **curl** ```bash curl https://pay.perly.io/api/v1/webhook_endpoints/we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/roll_secret \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{}' ``` **Node** ```js const webhookEndpoint = await pay.webhookEndpoints.rollSecret("we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50"); ``` **PHP** ```php $webhookEndpoint = $perly->webhookEndpoints->rollSecret('we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50'); ``` ### Claim a delivery `POST /webhook_endpoints/{id}/claim` Takes this endpoint's delivery of `event` once and answers the event. A second claim answers 409 `already_claimed`; an event never sent to this endpoint answers 404 `not_delivered`. For apps that cannot verify `Perly-Signature` themselves, and against replays. **Parameters** | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `Idempotency-Key (header)` | `string` | Any unique string (a UUID). A retry with the same key returns the first response for 24 hours. | **Body** | Name | Type | Description | | --- | --- | --- | | `event` — required | `string` | The delivered event's `id`. | **Returns:** `Event` **curl** ```bash curl https://pay.perly.io/api/v1/webhook_endpoints/we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50/claim \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{"event":"evt_1042"}' ``` **Node** ```js const event = await pay.webhookEndpoints.claim("we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", { event: "evt_1042", }); ``` **PHP** ```php $event = $perly->webhookEndpoints->claim('we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50', [ 'event' => 'evt_1042', ]); ``` ## Objects What the API answers with. Lists wrap them as `{ "object": "list", "data": […], "has_more", "next_cursor" }`; pass `next_cursor` back as `cursor`. ### Customer | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `cus_…` | | `object` — required | `"customer"` | | | `livemode` — required | `boolean` | | | `email` — required | `string \| null` | | | `name` — required | `string \| null` | | | `external_id` — required | `string \| null` | Your own id for this buyer. | | `country` — required | `string \| null` | | | `locale` — required | `string \| null` | | | `metadata` — required | `map` | | | `created` — required | `string` | ISO 8601 | ```json { "id": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "customer", "livemode": false, "email": "ada@example.com", "name": "Pro plan", "external_id": "user_42", "country": "PT", "locale": "en", "metadata": { "order_id": "1042" }, "created": "2026-10-19T09:41:00Z" } ``` ### Product | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `prod_…` | | `object` — required | `"product"` | | | `livemode` — required | `boolean` | | | `name` — required | `string` | | | `description` — required | `string \| null` | | | `image_url` — required | `string \| null` | | | `issues_license` — required | `boolean` | | | `license_activation_limit` — required | `integer \| null` | | | `active` — required | `boolean` | | | `metadata` — required | `map` | | | `created` — required | `string` | ISO 8601 | | `updated` — required | `string` | ISO 8601 | ```json { "id": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "product", "livemode": false, "name": "Pro plan", "description": "Everything in Pro, for a year.", "image_url": "https://shop.example/thanks", "issues_license": true, "license_activation_limit": 1, "active": true, "metadata": { "order_id": "1042" }, "created": "2026-10-19T09:41:00Z", "updated": "2026-10-19T09:41:00Z" } ``` ### Price | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `price_…` | | `object` — required | `"price"` | | | `livemode` — required | `boolean` | | | `product` — required | `string` | `prod_…` | | `model` — required | `"one_time" \| "pay_what_you_want" \| "fixed_term" \| "subscription" \| "lifetime" \| "credit_pack"` | | | `currency` — required | `"USD" \| "EUR"` | | | `nickname` — required | `string \| null` | | | `active` — required | `boolean` | | | `unit_amount` — required | `integer \| null` | Null for pay-what-you-want and tiered lifetime prices. | | `custom_amount` — required | `object \| null` | | | `term_days` — required | `integer \| null` | | | `recurring` — required | `object \| null` | | | `lifetime` — required | `object \| null` | | | `credits` — required | `integer \| null` | | | `refunds` — required | `object` | | | `metadata` — required | `map` | | | `created` — required | `string` | ISO 8601 | ```json { "id": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "price", "livemode": false, "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "model": "one_time", "currency": "USD", "nickname": "Launch price", "active": true, "unit_amount": 4900, "custom_amount": { "minimum": 4900, "suggested": 4900 }, "term_days": 14, "recurring": { "interval": "day", "interval_days": 14, "trial_days": 14, "grace_days": 14, "prepay_max_periods": 1, "keep_renewal_price": true }, "lifetime": { "quantity_cap": 1, "tiers": [ { "id": "tier_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "position": 1, "name": "Pro plan", "seats": 1, "unit_amount": 4900 } ] }, "credits": 1, "refunds": { "allowed": true, "window_days": 14, "network_fee_payer": "buyer" }, "metadata": { "order_id": "1042" }, "created": "2026-10-19T09:41:00Z" } ``` ### Coupon | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `cpn_…` | | `object` — required | `"coupon"` | | | `livemode` — required | `boolean` | | | `code` — required | `string` | | | `percent_off` — required | `integer \| null` | | | `amount_off` — required | `integer \| null` | Minor units (cents). | | `currency` — required | `"USD" \| "EUR" \| null` | | | `duration` — required | `"once" \| "repeating" \| "forever"` | | | `duration_periods` — required | `integer \| null` | | | `max_redemptions` — required | `integer \| null` | | | `times_redeemed` — required | `integer` | | | `products` — required | `string[]` | Empty means every product. | | `expires_at` — required | `string \| null` | ISO 8601 | | `active` — required | `boolean` | | | `created` — required | `string` | ISO 8601 | ```json { "id": "cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "coupon", "livemode": false, "code": "WELCOME10", "percent_off": 0, "amount_off": 0, "currency": "USD", "duration": "once", "duration_periods": 1, "max_redemptions": 1, "times_redeemed": 1, "products": [ "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50" ], "expires_at": "2026-10-19T09:41:00Z", "active": true, "created": "2026-10-19T09:41:00Z" } ``` ### PaymentLink | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `plink_…` | | `object` — required | `"payment_link"` | | | `livemode` — required | `boolean` | | | `url` — required | `string` | | | `slug` — required | `string` | | | `price` — required | `string` | `price_…` | | `quantity` — required | `integer` | | | `adjustable_quantity` — required | `boolean` | | | `allow_coupons` — required | `boolean` | | | `collect_name` — required | `boolean` | | | `success_url` — required | `string \| null` | | | `active` — required | `boolean` | | | `metadata` — required | `map` | | | `created` — required | `string` | ISO 8601 | ```json { "id": "plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "payment_link", "livemode": false, "url": "https://pay.perly.io/l/pro-plan", "slug": "pro-plan", "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "quantity": 1, "adjustable_quantity": true, "allow_coupons": true, "collect_name": true, "success_url": "https://shop.example/thanks", "active": true, "metadata": { "order_id": "1042" }, "created": "2026-10-19T09:41:00Z" } ``` ### CheckoutSession | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `cs_…` | | `object` — required | `"checkout_session"` | | | `livemode` — required | `boolean` | | | `url` — required | `string` | Send the buyer here, or open it with pay.js. | | `status` — required | `"open" \| "complete" \| "expired"` | | | `currency` — required | `"USD" \| "EUR"` | | | `line_items` — required | `object[]` | | | `subtotal` — required | `integer` | Minor units (cents). | | `discount` — required | `integer` | Minor units (cents). | | `total` — required | `integer` | Minor units (cents). | | `customer` — required | `string \| null` | `cus_…` | | `customer_email` — required | `string \| null` | | | `coupon` — required | `string \| null` | `cpn_…` | | `payment_link` — required | `string \| null` | `plink_…` | | `return_url` — required | `string \| null` | | | `cancel_url` — required | `string \| null` | | | `invoice` — required | `string \| null` | `inv_…` | | `expires_at` — required | `string` | ISO 8601 | | `metadata` — required | `map` | | | `created` — required | `string` | ISO 8601 | ```json { "id": "cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "checkout_session", "livemode": false, "url": "https://pay.perly.io/c/cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "status": "open", "currency": "USD", "line_items": [ { "id": "csl_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "quantity": 1, "custom_amount": 4900, "unit_amount": 4900, "amount": 4900 } ], "subtotal": 4900, "discount": 0, "total": 4900, "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "customer_email": "ada@example.com", "coupon": "cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "payment_link": "plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "return_url": "https://shop.example/thanks", "cancel_url": "https://shop.example/cart", "invoice": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "expires_at": "2026-10-19T09:41:00Z", "metadata": { "order_id": "1042" }, "created": "2026-10-19T09:41:00Z" } ``` ### Invoice | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `inv_…` | | `object` — required | `"invoice"` | | | `livemode` — required | `boolean` | | | `url` — required | `string` | The buyer's payment page. | | `status` — required | `string` | | | `kind` — required | `"checkout" \| "payment_link" \| "subscription" \| "manual" \| "api" \| "plan"` | | | `number` — required | `string \| null` | | | `customer` — required | `string \| null` | `cus_…` | | `customer_email` — required | `string \| null` | | | `customer_name` — required | `string \| null` | | | `description` — required | `string \| null` | | | `currency` — required | `"USD" \| "EUR"` | | | `subtotal` — required | `integer` | Minor units (cents). | | `discount` — required | `integer` | Minor units (cents). | | `total` — required | `integer` | Minor units (cents). | | `total_usd` — required | `integer` | What is settled, in US cents, locked when the invoice opened. | | `received_usd` — required | `integer` | Minor units (cents). | | `pending_usd` — required | `integer` | Minor units (cents). | | `amount_received` — required | `string` | Integer string in the asset's smallest unit. | | `payment` — required | `object \| null` | The chain and asset the buyer chose; null until then. | | `lines` — required | `object[]` | | | `subscription` — required | `string \| null` | `sub_…` | | `checkout_session` — required | `string \| null` | `cs_…` | | `payment_link` — required | `string \| null` | `plink_…` | | `coupon` — required | `string \| null` | `cpn_…` | | `receipt_number` — required | `string \| null` | | | `review_reason` — required | `string \| null` | | | `due_at` — required | `string \| null` | ISO 8601 | | `paid_at` — required | `string \| null` | ISO 8601 | | `fulfilled_at` — required | `string \| null` | ISO 8601 | | `canceled_at` — required | `string \| null` | ISO 8601 | | `metadata` — required | `map` | | | `created` — required | `string` | ISO 8601 | ```json { "id": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "invoice", "livemode": false, "url": "https://pay.perly.io/i/0b6f2c1e-8d4a-4f3e-9a7b-5c2d1e0f8a9b", "status": "open", "kind": "checkout", "number": "KS-2026-000142", "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "customer_email": "ada@example.com", "customer_name": null, "description": "Everything in Pro, for a year.", "currency": "USD", "subtotal": 4900, "discount": 0, "total": 4900, "total_usd": 4900, "received_usd": 4900, "pending_usd": 4900, "amount_received": "49000000", "payment": { "chain": "base", "asset": "USDC", "address": "0x7a3f…1e0b", "amount_due": "49000000", "decimals": 6, "btc_usd_cents": 6800000, "opened_at": "2026-10-19T09:41:00Z", "expires_at": "2026-10-19T09:41:00Z" }, "lines": [ { "id": "il_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "description": "Everything in Pro, for a year.", "quantity": 1, "unit_amount": 4900, "amount": 4900, "period_start": "2026-10-19T09:41:00Z", "period_end": "2026-10-19T09:41:00Z" } ], "subscription": "sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "checkout_session": "cs_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "payment_link": "plink_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "coupon": "cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "receipt_number": null, "review_reason": null, "due_at": "2026-10-19T09:41:00Z", "paid_at": "2026-10-19T09:41:00Z", "fulfilled_at": "2026-10-19T09:41:00Z", "canceled_at": "2026-10-19T09:41:00Z", "metadata": { "order_id": "1042" }, "created": "2026-10-19T09:41:00Z" } ``` ### Subscription | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `sub_…` | | `object` — required | `"subscription"` | | | `livemode` — required | `boolean` | | | `status` — required | `string` | | | `customer` — required | `string` | `cus_…` | | `price` — required | `string` | `price_…` | | `quantity` — required | `integer` | | | `current_period_start` — required | `string` | ISO 8601 | | `current_period_end` — required | `string` | ISO 8601 | | `trial_end` — required | `string \| null` | ISO 8601 | | `grace_days` — required | `integer` | | | `cancel_at_period_end` — required | `boolean` | | | `canceled_at` — required | `string \| null` | ISO 8601 | | `ended_at` — required | `string \| null` | ISO 8601 | | `paused_at` — required | `string \| null` | ISO 8601 | | `pending_price` — required | `string \| null` | A downgrade waiting for the period end. | | `kept_price` — required | `integer \| null` | The renewal price this subscriber keeps. | | `latest_invoice` — required | `string \| null` | `inv_…` | | `coupon` — required | `string \| null` | `cpn_…` | | `metadata` — required | `map` | | | `created` — required | `string` | ISO 8601 | ```json { "id": "sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "subscription", "livemode": false, "status": "trialing", "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "quantity": 1, "current_period_start": "2026-10-19T09:41:00Z", "current_period_end": "2026-10-19T09:41:00Z", "trial_end": "2026-10-19T09:41:00Z", "grace_days": 14, "cancel_at_period_end": true, "canceled_at": "2026-10-19T09:41:00Z", "ended_at": "2026-10-19T09:41:00Z", "paused_at": "2026-10-19T09:41:00Z", "pending_price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "kept_price": 4900, "latest_invoice": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "coupon": "cpn_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "metadata": { "order_id": "1042" }, "created": "2026-10-19T09:41:00Z" } ``` ### Entitlement | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `ent_…` | | `object` — required | `"entitlement"` | | | `livemode` — required | `boolean` | | | `active` — required | `boolean` | Started, not ended, not revoked. | | `customer` — required | `string \| null` | `cus_…` | | `product` — required | `string \| null` | `prod_…` | | `price` — required | `string \| null` | `price_…` | | `model` — required | `"one_time" \| "pay_what_you_want" \| "fixed_term" \| "subscription" \| "lifetime" \| "credit_pack"` | | | `subscription` — required | `string \| null` | `sub_…` | | `invoice` — required | `string` | `inv_…` | | `starts_at` — required | `string` | ISO 8601 | | `ends_at` — required | `string \| null` | Null = for ever. | | `credits_total` — required | `integer \| null` | | | `credits_used` — required | `integer` | | | `credits_remaining` — required | `integer \| null` | | | `tier` — required | `string \| null` | `tier_…` | | `seat_number` — required | `integer \| null` | | | `revoked_at` — required | `string \| null` | ISO 8601 | | `created` — required | `string` | ISO 8601 | ```json { "id": "ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "entitlement", "livemode": false, "active": true, "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "model": "one_time", "subscription": "sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "invoice": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "starts_at": "2026-10-19T09:41:00Z", "ends_at": "2026-10-19T09:41:00Z", "credits_total": 4900, "credits_used": 1, "credits_remaining": 1, "tier": "tier_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "seat_number": 1, "revoked_at": "2026-10-19T09:41:00Z", "created": "2026-10-19T09:41:00Z" } ``` ### EntitlementCheck | Name | Type | Description | | --- | --- | --- | | `object` — required | `"entitlement_check"` | | | `has_access` — required | `boolean` | | | `customer` — required | `string \| null` | `cus_…` | | `product` — required | `string \| null` | `prod_…` | | `ends_at` — required | `string \| null` | The latest end among active entitlements; null = for ever (or none). | | `credits_remaining` — required | `integer \| null` | | | `entitlements` — required | `Entitlement[]` | | ```json { "object": "entitlement_check", "has_access": true, "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "ends_at": "2026-10-19T09:41:00Z", "credits_remaining": 1, "entitlements": [ { "id": "ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "entitlement", "livemode": false, "active": true, "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "price": "price_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "model": "one_time", "subscription": "sub_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "invoice": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "starts_at": "2026-10-19T09:41:00Z", "ends_at": "2026-10-19T09:41:00Z", "credits_total": 4900, "credits_used": 1, "credits_remaining": 1, "tier": "tier_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "seat_number": 1, "revoked_at": "2026-10-19T09:41:00Z", "created": "2026-10-19T09:41:00Z" } ] } ``` ### CreditBalance | Name | Type | Description | | --- | --- | --- | | `object` — required | `"credit_balance"` | | | `livemode` — required | `boolean` | | | `customer` — required | `string` | `cus_…` | | `product` — required | `string \| null` | Null = every product's credits together. | | `balance` — required | `integer` | Credits left on active packs. | | `low_balance_threshold` — required | `integer \| null` | The product's threshold; null = none set. | | `low` — required | `boolean` | At or under the threshold (or zero). | | `top_up_url` — required | `string` | The buyer's own top-up page: stable, safe to email or show in your app. | ```json { "object": "credit_balance", "livemode": false, "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "balance": 1, "low_balance_threshold": 1, "low": true, "top_up_url": "https://shop.example/thanks" } ``` ### CreditUsage | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `cu_…` | | `object` — required | `"credit_usage"` | | | `livemode` — required | `boolean` | | | `customer` — required | `string \| null` | `cus_…` | | `product` — required | `string \| null` | `prod_…` | | `entitlement` — required | `string \| null` | Set when one pack was named. | | `credits` — required | `integer` | | | `action` — required | `string \| null` | Your label for what was done (`chat`, `image.hd`, …). | | `balance` — required | `integer` | What was left right after. | | `created` — required | `string` | ISO 8601 | ```json { "id": "cu_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "credit_usage", "livemode": false, "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "entitlement": "ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "credits": 1, "action": null, "balance": 1, "created": "2026-10-19T09:41:00Z" } ``` ### LicenseKey | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `lic_…` | | `object` — required | `"license_key"` | | | `livemode` — required | `boolean` | | | `key` — required | `string` | | | `status` — required | `"active" \| "suspended" \| "revoked" \| "expired"` | | | `product` — required | `string \| null` | `prod_…` | | `customer` — required | `string \| null` | `cus_…` | | `entitlement` — required | `string` | `ent_…` | | `activation_limit` — required | `integer \| null` | | | `activations` — required | `integer` | | | `expires_at` — required | `string \| null` | ISO 8601 | | `created` — required | `string` | ISO 8601 | ```json { "id": "lic_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "license_key", "livemode": false, "key": "PERLY-8KQ2-X7LM-4N9P", "status": "active", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "entitlement": "ent_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "activation_limit": 1, "activations": 1, "expires_at": "2026-10-19T09:41:00Z", "created": "2026-10-19T09:41:00Z" } ``` ### LicenseCheck | Name | Type | Description | | --- | --- | --- | | `object` — required | `"license_check"` | | | `valid` — required | `boolean` | | | `reason` — required | `string \| null` | | | `license_key` — required | `object \| null` | | | `activation` — required | `object \| null` | | ```json { "object": "license_check", "valid": true, "reason": "not_found", "license_key": { "key": "PERLY-8KQ2-X7LM-4N9P", "livemode": false, "status": "active", "product": "prod_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "activation_limit": 1, "activations": 1, "expires_at": "2026-10-19T09:41:00Z" }, "activation": { "id": "act_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "instance_name": "ada-laptop", "active": true, "created": "2026-10-19T09:41:00Z" } } ``` ### PortalSession | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `bps_…` | | `object` — required | `"portal_session"` | | | `livemode` — required | `boolean` | | | `customer` — required | `string` | `cus_…` | | `url` — required | `string` | Shown once. Valid until expires_at. | | `return_url` — required | `string \| null` | | | `expires_at` — required | `string` | ISO 8601 | | `created` — required | `string` | ISO 8601 | ```json { "id": "bps_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "portal_session", "livemode": false, "customer": "cus_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "url": "https://pay.perly.io/portal/session", "return_url": "https://shop.example/thanks", "expires_at": "2026-10-19T09:41:00Z", "created": "2026-10-19T09:41:00Z" } ``` ### Payment | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `pay_…` | | `object` — required | `"payment"` | | | `livemode` — required | `boolean` | | | `invoice` — required | `string \| null` | `inv_…` | | `chain` — required | `string` | | | `asset` — required | `"BTC" \| "USDC" \| "USDT"` | `BTC`, `USDC` or `USDT`. On `arbitrum` and `optimism`, `USDT` is USDT0 (LayerZero's omnichain USDT); show it as USDT0 there. | | `token` — required | `string \| null` | | | `txid` — required | `string` | | | `position` — required | `integer` | | | `amount` — required | `string` | Integer string in the asset's smallest unit. | | `decimals` — required | `integer` | | | `usd_cents` — required | `integer \| null` | | | `from_address` — required | `string \| null` | | | `to_address` — required | `string` | | | `block_number` — required | `integer \| null` | | | `confirmations` — required | `integer` | | | `arrived_at` — required | `string` | ISO 8601 | | `confirmed_at` — required | `string \| null` | ISO 8601 | | `dropped_at` — required | `string \| null` | ISO 8601 | | `created` — required | `string` | ISO 8601 | ```json { "id": "pay_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "payment", "livemode": false, "invoice": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "chain": "base", "asset": "USDC", "token": null, "txid": "0x9f1c…e5b7", "position": 1, "amount": "49000000", "decimals": 6, "usd_cents": 4900, "from_address": null, "to_address": "…", "block_number": 1, "confirmations": 3, "arrived_at": "2026-10-19T09:41:00Z", "confirmed_at": "2026-10-19T09:41:00Z", "dropped_at": "2026-10-19T09:41:00Z", "created": "2026-10-19T09:41:00Z" } ``` ### Refund | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `re_…` | | `object` — required | `"refund"` | | | `livemode` — required | `boolean` | | | `invoice` — required | `string` | `inv_…` | | `payment` — required | `string \| null` | `pay_…` | | `status` — required | `"requested" \| "awaiting_address" \| "ready_to_send" \| "sent" \| "confirmed" \| "canceled"` | | | `chain` — required | `string` | | | `asset` — required | `"BTC" \| "USDC" \| "USDT"` | `BTC`, `USDC` or `USDT`. On `arbitrum` and `optimism`, `USDT` is USDT0 (LayerZero's omnichain USDT); show it as USDT0 there. | | `decimals` — required | `integer` | | | `amount` — required | `string` | Integer string in the asset's smallest unit. | | `network_fee` — required | `string \| null` | Integer string in the asset's smallest unit. | | `network_fee_payer` — required | `"buyer" \| "merchant"` | | | `amount_to_send` — required | `string \| null` | Integer string in the asset's smallest unit. | | `reason` — required | `string \| null` | | | `refund_address` — required | `string \| null` | | | `txid` — required | `string \| null` | The transaction you named with mark_sent. Only it confirms the refund. | | `sent_at` — required | `string \| null` | ISO 8601 | | `confirmed_at` — required | `string \| null` | ISO 8601 | | `canceled_at` — required | `string \| null` | ISO 8601 | | `created` — required | `string` | ISO 8601 | ```json { "id": "re_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "refund", "livemode": false, "invoice": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "payment": "pay_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "status": "requested", "chain": "base", "asset": "USDC", "decimals": 6, "amount": "49000000", "network_fee": "49000000", "network_fee_payer": "buyer", "amount_to_send": "49000000", "reason": "requested_by_customer", "refund_address": "0x4b1e…9c2d", "txid": "0x9f1c…e5b7", "sent_at": "2026-10-19T09:41:00Z", "confirmed_at": "2026-10-19T09:41:00Z", "canceled_at": "2026-10-19T09:41:00Z", "created": "2026-10-19T09:41:00Z" } ``` ### RefundQuote | Name | Type | Description | | --- | --- | --- | | `object` — required | `"refund_quote"` | | | `invoice` — required | `string` | `inv_…` | | `refundable` — required | `boolean` | | | `reason` — required | `string \| null` | | | `chain` — required | `string \| null` | | | `asset` — required | `"BTC" \| "USDC" \| "USDT" \| null` | `BTC`, `USDC` or `USDT`. On `arbitrum` and `optimism`, `USDT` is USDT0 (LayerZero's omnichain USDT); show it as USDT0 there. | | `decimals` — required | `integer \| null` | | | `amount` — required | `string \| null` | What can still be refunded. | | `network_fee` — required | `string \| null` | Integer string in the asset's smallest unit. | | `network_fee_payer` — required | `"buyer" \| "merchant" \| null` | | | `amount_to_send` — required | `string \| null` | Integer string in the asset's smallest unit. | ```json { "object": "refund_quote", "invoice": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "refundable": true, "reason": "requested_by_customer", "chain": "base", "asset": "USDC", "decimals": 6, "amount": "49000000", "network_fee": "49000000", "network_fee_payer": "buyer", "amount_to_send": "49000000" } ``` ### Receipt | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `rcpt_…` | | `object` — required | `"receipt"` | | | `livemode` — required | `boolean` | | | `kind` — required | `"payment" \| "refund"` | | | `number` — required | `string` | | | `invoice` — required | `string` | `inv_…` | | `refund` — required | `string \| null` | `re_…` | | `url` — required | `string` | The public receipt page. | | `pdf` — required | `string` | This API's PDF of it. | | `customer_email` — required | `string \| null` | | | `currency` — required | `"USD" \| "EUR"` | | | `amount` — required | `integer` | Minor units (cents). | | `asset` — required | `"BTC" \| "USDC" \| "USDT" \| null` | `BTC`, `USDC` or `USDT`. On `arbitrum` and `optimism`, `USDT` is USDT0 (LayerZero's omnichain USDT); show it as USDT0 there. | | `asset_amount` — required | `string \| null` | Integer string in the asset's smallest unit. | | `decimals` — required | `integer \| null` | | | `issued_at` — required | `string` | ISO 8601 | | `emailed_at` — required | `string \| null` | ISO 8601 | ```json { "id": "rcpt_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "receipt", "livemode": false, "kind": "payment", "number": "KS-2026-000142", "invoice": "inv_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "refund": "re_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "url": "https://pay.perly.io/r/KS-2026-000142", "pdf": "…", "customer_email": "ada@example.com", "currency": "USD", "amount": 4900, "asset": "USDC", "asset_amount": "49000000", "decimals": 6, "issued_at": "2026-10-19T09:41:00Z", "emailed_at": "2026-10-19T09:41:00Z" } ``` ### Event | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `evt_…` | | `object` — required | `"event"` | | | `livemode` — required | `boolean` | | | `type` — required | `string` | `.`, e.g. invoice.paid. | | `object_type` — required | `string` | | | `object_id` — required | `string \| null` | | | `data` — required | `map` | `object`: the object the event is about, as the API shows it now; plus what the event recorded. | | `created` — required | `string` | ISO 8601 | ```json { "id": "evt_1042", "object": "event", "livemode": false, "type": "invoice.fulfilled", "object_type": "invoice", "object_id": null, "data": {}, "created": "2026-10-19T09:41:00Z" } ``` ### WebhookEndpoint | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | `we_…` | | `object` — required | `"webhook_endpoint"` | | | `livemode` — required | `boolean` | | | `url` — required | `string` | | | `description` — required | `string \| null` | | | `enabled_events` — required | `string[]` | `*` is every event. | | `status` — required | `"enabled" \| "disabled"` | | | `secret_prefix` — required | `string` | | | `secret` | `string` | The signing secret, `whsec_…`. Only when created or rolled. | | `created` — required | `string` | ISO 8601 | ```json { "id": "we_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "object": "webhook_endpoint", "livemode": false, "url": "https://shop.example/webhooks/perly", "description": "Everything in Pro, for a year.", "enabled_events": [ "…" ], "status": "enabled", "secret_prefix": "…", "secret": "whsec_3f2a9c1e8b7d4a6f9e0c5b1a2d3e4f50", "created": "2026-10-19T09:41:00Z" } ``` ### Deleted | Name | Type | Description | | --- | --- | --- | | `id` — required | `string` | | | `object` — required | `string` | | | `deleted` — required | `true` | | ```json { "id": "…", "object": "…", "deleted": true } ```