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

> **Note:** Both packages go up on npm and Packagist shortly. Until then, call the API directly: the [API reference](https://pay.perly.io/docs/api.md) shows every call in curl too.

| 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. |
| Zapier | [App](https://pay.perly.io/docs/zapier.md) | Triggers and actions for Zaps, without code. |
| 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).

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.

**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.
