Reference
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 | A payment method built on the PHP SDK. |
| WHMCS | Module | A payment gateway built on the PHP SDK. |
| Zapier | App | Triggers and actions for Zaps, without code. |
| Make | App | Instant triggers and modules for scenarios, without code. |
| AI assistants | MCP server | perly-pay-mcp: Claude Code, Cursor or Codex set up and test payments for you. |
Install
Make a client with your secret key from Dashboard → Developers. Keep it on your server. A sk_test_… key works in test mode.
import { PerlyPay } from "perly-pay";
const pay = new PerlyPay(process.env.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.
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);Node returns typed objects; PHP returns the same JSON as associative arrays. Every call in the API reference 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.
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 });
}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:
await pay.invoices.create(params, { idempotencyKey: `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).
In live mode, once a calendar month's paid sales pass your plan's limit and its 10% buffer, a new invoice or checkout session is refused as a PermissionError (PermissionException) with code plan_limit_reached (403) until you move up a plan in Dashboard → Billing. 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 alone.
import { LicenseClient } from "perly-pay";
const result = await new LicenseClient().activate({ key, product: "prod_…", instance_name: os.hostname() });WooCommerce and WHMCS
The WooCommerce plugin and the WHMCS module are built on the PHP SDK, on their way to WordPress.org and the WHMCS Marketplace. Write to us to try either first.
Without an SDK
Any language with HTTPS works: the API is JSON over HTTPS, described by an OpenAPI document you can generate a client from. The Webhooks page has signature checks in Node, Python and PHP without an SDK.