Skip to content

Reference

SDKs

Node and PHP: the whole API, with idempotency keys, retries, typed errors and webhook checks built in.

View as Markdown

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.

Both packages go up on npm and Packagist shortly. Until then, call the API directly: the API reference shows every call in curl too.
SDKPackageRuns on
Node / TypeScriptperly-pay on npmNode 18.17+, Deno, Bun, workers and edge. Zero dependencies.
PHPperly/perly-pay on ComposerPHP 8.1+ with ext-curl and ext-json. Zero dependencies.
WooCommercePluginA payment method built on the PHP SDK.
WHMCSModuleA payment gateway built on the PHP SDK.
ZapierAppTriggers and actions for Zaps, without code.
MakeAppInstant triggers and modules for scenarios, without code.
AI assistantsMCP serverperly-pay-mcp: Claude Code, Cursor or Codex set up and test payments for you.

Install

npm install 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.

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.

WhenNodePHP
A field is wrongInvalidRequestErrorInvalidRequestException
The key is missing or wrongAuthenticationErrorAuthenticationException
The key may not do thisPermissionErrorPermissionException
Too many requestsRateLimitErrorRateLimitException
Same key, different requestIdempotencyErrorIdempotencyException
No answer at allConnectionErrorConnectionException
A webhook doesn't check outSignatureVerificationErrorSignatureVerificationException
Anything elseAPIErrorApiException

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.