Developers · API v1

Your code opens the checkout. Paywio is the seller.

A REST API and signed webhooks for digital products. Create a checkout from your server, follow every order, and let Paywio act as the seller of record.

Sign-up is by application, and every application is reviewed by hand. API keys and webhooks live in your dashboard once you are approved.

  • 22 endpoints in one OpenAPI 3.1 document
  • 11 webhook events signed with HMAC-SHA256
  • JSON over HTTPS prices in integer cents, USD, excl. VAT
Create a checkout
curl https://api.paywio.com/v1/checkouts \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{
    "variant_id": "var_01j9z8k3m4n5p6q7r8s9t0v1w2",
    "customer_email": "[email protected]",
    "metadata": {"order_ref": "1001"}
  }'
Response · 201 Created
{
  "object": "checkout",
  "id": "chk_01j9z8k3m4n5p6q7r8s9t0v1w2",
  "url": "https://pay.paywio.com/c/chk_01j9z8k3m4n5p6q7r8s9t0v1w2",
  "status": "open",
  "livemode": false,
  "variant_id": "var_01j9z8k3m4n5p6q7r8s9t0v1w2",
  "metadata": {"order_ref": "1001"},
  "created": 1730000000,
  "expires_at": 1730086400
}
Test modeSample values. Prices are integer cents, excl. VAT; times are unix seconds.

Quickstart

Three steps to a hosted checkout.

A test key moves no money, and these examples use plain HTTP, so there is nothing to install.

  1. Create a test key

    In your dashboard open Developers and create an API key in test mode. Creating a key asks for a fresh two-factor code, and the key is shown once: copy it.

    Open Developers in the dashboard
  2. Pick a variant

    Create a product in the dashboard and submit it: every product is reviewed before it can be sold. Then call GET /products and copy the id of one of its variants (var_…).

  3. Create a checkout

    Call POST /checkouts with the variant id and send your buyer to the returned url. You never send an amount: the price comes from the variant, and unknown fields are rejected.

Then learn when it is paid with the order.paid webhook, or read the order with GET /orders. Jump to webhooks

curl

# Step 2: find a variant id (products are created in the dashboard)
curl https://api.paywio.com/v1/products \
  -H "Authorization: Bearer sk_test_..."

# Step 3: create a checkout and send your buyer to its "url"
curl https://api.paywio.com/v1/checkouts \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{
    "variant_id": "var_01j9z8k3m4n5p6q7r8s9t0v1w2",
    "customer_email": "[email protected]",
    "metadata": {"order_ref": "1001"}
  }'

PHP

<?php
$key = getenv('PAYWIO_SECRET_KEY');   // sk_test_...

function paywio(string $key, string $method, string $path,
                ?array $body = null, array $extra = []): array
{
    $ch = curl_init('https://api.paywio.com/v1'.$path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => array_merge([
            'Authorization: Bearer '.$key,
            'Content-Type: application/json',
        ], $extra),
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }

    return json_decode((string) curl_exec($ch), true) ?: [];
}

// Step 2: find a variant id of an approved product
$variantId = null;
foreach (paywio($key, 'GET', '/products')['data'] ?? [] as $product) {
    if ($product['status'] === 'approved') {
        $variantId = $product['variants'][0]['id'];
        break;
    }
}

// Step 3: create a checkout and send your buyer to its "url"
$checkout = paywio($key, 'POST', '/checkouts', [
    'variant_id' => $variantId,
    'customer_email' => '[email protected]',
    'metadata' => ['order_ref' => '1001'],
], ['Idempotency-Key: order-1001']);

header('Location: '.$checkout['url']);

Node.js

// quickstart.mjs (Node 18+, no dependencies)
const KEY = process.env.PAYWIO_SECRET_KEY; // sk_test_...

async function paywio(method, path, body, headers = {}) {
  const res = await fetch(`https://api.paywio.com/v1${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      ...headers,
    },
    body: body && JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) {
    const { code, message, request_id } = json.error;
    throw new Error(`${code}: ${message} (${request_id})`);
  }
  return json;
}

// Step 2: find a variant id of an approved product
const products = await paywio('GET', '/products');
const product = products.data.find((p) => p.status === 'approved');
const variant = product?.variants[0];

// Step 3: create a checkout and send your buyer to its "url"
const checkout = await paywio(
  'POST',
  '/checkouts',
  {
    variant_id: variant.id,
    customer_email: '[email protected]',
    metadata: { order_ref: '1001' },
  },
  { 'Idempotency-Key': 'order-1001' },
);
console.log(checkout.url);
Test modeSet PAYWIO_SECRET_KEY to your test key first. Reuse the same Idempotency-Key when you retry the same order.
Response · 201 Created
{
  "object": "checkout",
  "id": "chk_01j9z8k3m4n5p6q7r8s9t0v1w2",
  "url": "https://pay.paywio.com/c/chk_01j9z8k3m4n5p6q7r8s9t0v1w2",
  "status": "open",
  "livemode": false,
  "variant_id": "var_01j9z8k3m4n5p6q7r8s9t0v1w2",
  "metadata": {"order_ref": "1001"},
  "created": 1730000000,
  "expires_at": 1730086400
}

Resources

Everything in the API, by resource.

22 endpoints across 8 resources. Each one is documented in the OpenAPI document that the API reference is built from.

Products

2 endpoints

Read your catalog with every variant, price (excl. VAT) and billing interval (one-time, monthly or yearly).

  • GET /products List products with their variants
  • GET /products/{id} Retrieve a product

Checkouts

2 endpoints

Open a hosted payment page for one variant. The page lasts 24 hours. Pass a coupon code, return URLs and up to 20 metadata keys.

  • POST /checkouts Create a checkout
  • GET /checkouts/{id} Retrieve a checkout

Orders

2 endpoints

List and filter orders by status, customer email or date, and read any order with its amounts, fee and customer.

  • GET /orders List orders
  • GET /orders/{id} Retrieve an order

Subscriptions

3 endpoints

Read subscription status and billing periods, and cancel at the end of the period or right away.

  • GET /subscriptions List subscriptions
  • GET /subscriptions/{id} Retrieve a subscription
  • POST /subscriptions/{id}/cancel Cancel a subscription

Customers

2 endpoints

Look up the buyers of your store, by email or one at a time.

  • GET /customers List customers
  • GET /customers/{id} Retrieve a customer

License keys

1 endpoints

List the license keys issued for an order. For a renewal order you get the keys of its subscription.

  • GET /license-keys List the license keys of an order

Coupons

4 endpoints

Create percent or fixed-amount coupons with dates, limits and product restrictions, then list or delete them.

  • GET /coupons List coupons
  • POST /coupons Create a coupon
  • GET /coupons/{id} Retrieve a coupon
  • DELETE /coupons/{id} Delete a coupon

Webhook endpoints

6 endpoints

Create, update, disable, test and delete webhook endpoints from code. The signing secret is returned once.

  • GET /webhook-endpoints List webhook endpoints of the key's mode
  • POST /webhook-endpoints Create a webhook endpoint
  • GET /webhook-endpoints/{id} Retrieve a webhook endpoint
  • PATCH /webhook-endpoints/{id} Update a webhook endpoint
  • DELETE /webhook-endpoints/{id} Delete a webhook endpoint
  • POST /webhook-endpoints/{id}/test Send a test event

Products are created and edited in the dashboard only, because every product goes through review before it can be sold.

Authentication

Secret keys, in test and live.

Every request carries a secret key as a bearer token. Cookies and browser sessions are never accepted.

Anatomy of a key

  • sk secret key
  • test / live test or live
  • 43 random characters (256 bits)

Authorization: Bearer sk_test_…

Idempotency-Key: order-1001

Bearer
Send Authorization: Bearer sk_test_… on every request. A missing, unknown or revoked key returns 401.
Test and live
A test key sees only test-mode data and moves no money; a live key sees only live data. Live keys charge real cards.
Idempotency
Add an Idempotency-Key header (1 to 255 printable characters) to any POST. The same key with the same body replays the first response for 24 hours; the same key with a different body is a 409. Failed requests are not remembered, so you can fix them and retry.
Key limits
Up to 20 active keys per store. Keys are stored only as a keyed hash and shown once; revoke one and it stops working immediately.
Request log
The dashboard lists your latest API requests with their request id. Bodies and query strings are never stored, and entries are kept 30 days.

Test mode

Rehearse the whole flow without moving money.

A test key runs the same flow as a live one: checkouts, orders, delivery and webhooks. No card is charged and no real money moves.

  • Buyers pay on the same hosted page, in test mode.
  • Orders are created and delivered: license keys and downloads arrive as they do for a live sale.
  • Test webhook endpoints receive the events, with "livemode": false.
  • Test orders are marked as test in your dashboard and never touch your balance: nothing is booked, nothing counts toward your level, nothing appears on a payout statement.

Going live is a key swap: use an sk_live_ key and a live webhook endpoint, and the same code keeps working.

Test keys compared with live keys
Aspect Test key Live key
Key prefix sk_test_ sk_live_
Money No card is charged Real payments
Data you see Test orders, checkouts, subscriptions and license keys only Live ones only
Webhooks Test endpoints, "livemode": false Live endpoints, "livemode": true
Balance, levels, statements Not booked, not counted Booked and counted
Rate limit 25 requests per 10 s 100 requests per 10 s
Availability Available Available

Webhooks

Signed events, delivered at least once.

Add an HTTPS endpoint under Developers → Webhooks, choose the events you want, and Paywio posts a signed JSON event whenever one happens. Each endpoint is either live or test and only receives events of that mode.

Events

Orders

  • order.paiddata.object: order

    A payment succeeded and the order was recorded.

  • order.refundeddata.object: order + refund

    A refund was issued by Paywio, in full or in part. The refund object has amount, full and refunded_total.

Subscriptions

  • subscription.createddata.object: subscription

    The first payment of a subscription succeeded.

  • subscription.reneweddata.object: subscription

    A renewal payment succeeded.

  • subscription.payment_faileddata.object: subscription

    A renewal payment failed; the status is past_due.

  • subscription.canceleddata.object: subscription

    A subscription ended.

Licenses

  • license.revokeddata.object: license

    A license key was revoked, for example after a full refund or when a subscription ends.

Disputes

  • dispute.createddata.object: dispute

    A buyer disputed a payment.

  • dispute.closeddata.object: dispute

    A dispute was closed.

Payouts

  • payout.sentdata.object: payout

    A payout was sent.

  • payout.faileddata.object: payout

    A payout did not go through.

Subscribe to specific events or to all of them with *. webhook.test is sent only by the test button in the dashboard and by POST /webhook-endpoints/{id}/test; you cannot subscribe to it.

One envelope for every event

data.object carries the resource itself. Ids are prefixed, amounts are integer cents (prices exclude VAT), times are unix seconds, and payment-processor ids and internal fields are never included.

Sample event · order.paid
{
  "id": "evt_01j9z8k3m4n5p6q7r8s9t0v1w2",
  "type": "order.paid",
  "created": 1730000000,
  "livemode": false,
  "data": {
    "object": {
      "object": "order",
      "id": "ord_01j9z8k3m4n5p6q7r8s9t0v1w2",
      "status": "paid",
      "currency": "usd",
      "subtotal": 2900,
      "discount": 0,
      "tax": 0,
      "total": 2900,
      "platform_fee": 253,
      "seller_net": 2647,
      "product_id": "prd_01j9z8k3m4n5p6q7r8s9t0v1w2",
      "variant_id": "var_01j9z8k3m4n5p6q7r8s9t0v1w2",
      "customer": {
        "id": "cus_01j9z8k3m4n5p6q7r8s9t0v1w2",
        "email": "[email protected]",
        "country": "GB"
      },
      "livemode": false,
      "paid_at": 1730000000
    }
  }
}

What Paywio sends

HeaderValue
Content-Typeapplication/json
User-AgentPaywio-Webhooks/1.0
Paywio-Signaturet=<unix>,v1=<hex>
Paywio-Event-IdThe event id (evt_…), identical on every retry
Paywio-Delivery-AttemptThe attempt number, 1 to 8

Delivery rules

  • Answer with any 2xx within 10 seconds. Everything else counts as a failure, including a timeout and a redirect (redirects are never followed).
  • Delivery is at least once and not ordered. Use the event id to ignore duplicates.
  • Endpoint URLs must be HTTPS on port 443 with a public domain name. Addresses that resolve to private networks are refused, and Paywio connects only to the address it verified.
  • Up to 10 endpoints per store.

Retries

A failed delivery is retried on a fixed schedule, 8 attempts in total. Each wait starts when the previous attempt fails.

  1. Attempt 1 Now
  2. Attempt 2 after 1 min
  3. Attempt 3 after 5 min
  4. Attempt 4 after 30 min
  5. Attempt 5 after 2 h
  6. Attempt 6 after 6 h
  7. Attempt 7 after 12 h
  8. Attempt 8 after 24 h

An endpoint that keeps failing for 3 days is disabled and the store owner gets an email. Enable it again in the dashboard, and resend any event from its delivery log.

Verify the signature

Check every request before you trust it.

Each request carries Paywio-Signature: t=<unix>,v1=<hex>. Recompute the signature from the raw body and your endpoint's signing secret (whsec_…, shown once when you create the endpoint).

  1. Read the raw request body, before any JSON parsing.
  2. Split the header into t and the v1 values.
  3. Compute HMAC-SHA256 of <t>.<raw body> with your signing secret, as hex.
  4. Compare it with each v1 in constant time. Any match is valid: for 24 hours after a secret rotation the header carries two.
  5. Reject the request when t is more than 5 minutes away from your clock.

Checked against headers built by Paywio's own signing code: valid, rotated, tampered and stale.

PHP

<?php
$payload = file_get_contents('php://input'); // the RAW body
$header = $_SERVER['HTTP_PAYWIO_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts); // t=...&v1=...
$timestamp = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $signingSecret);

// While a key rotates the header carries two v1 values;
// any match is valid.
$signatures = array_map(
    fn ($p) => substr(trim($p), 3),
    array_filter(
        explode(',', $header),
        fn ($p) => str_starts_with(trim($p), 'v1='),
    ),
);
$matches = array_filter($signatures, fn ($s) => hash_equals($expected, $s));
$valid = abs(time() - $timestamp) <= 300 && $matches !== [];
if (! $valid) { http_response_code(400); exit; }

Node.js

const crypto = require('crypto');

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const hmac = crypto.createHmac('sha256', secret);
  const expected = Buffer.from(hmac.update(`${t}.${rawBody}`).digest('hex'));
  return signatures.some((s) => {
    const given = Buffer.from(s);
    return given.length === expected.length
      && crypto.timingSafeEqual(given, expected);
  });
}

Python

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str,
           tolerance: int = 300) -> bool:
    parts = [p.strip().partition("=") for p in header.split(",")]
    t = next((int(v) for k, _, v in parts if k == "t" and v.isdigit()), 0)
    signatures = [v for k, _, v in parts if k == "v1"]
    if not t or abs(time.time() - t) > tolerance:
        return False
    message = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)

SDKs

Official PHP and Node.js clients.

Both wrap the same API with typed errors, safe retries and webhook signature checks. They are not on Packagist or npm yet, so the plain HTTP examples above are the way to start today.

paywio/paywio-php

  • PHP 8.2+
  • ext-curl, ext-json

Preview Not published yet

@paywio/node

  • Node 18+
  • Zero dependencies
  • TypeScript types

Preview Not published yet

In both clients

Safe retries
Network errors, 429 (honouring Retry-After) and 5xx are retried with exponential backoff, twice by default.
One key per request
Every POST carries an Idempotency-Key that is reused on every attempt, so a retry never creates a second checkout. Pass your own to control it.
Typed errors
A failure throws an exception with the HTTP status, error type, code, param and request id.
Auto-pagination
autoPaginate() walks every page for you with the starting_after cursor.
Webhook helper
Webhook::constructEvent(payload, header, secret) verifies the signature and returns the event.

PHP

$paywio = new \Paywio\Client('sk_test_...');

// 1. Create a checkout. The price always comes from the variant.
$checkout = $paywio->checkouts->create([
    'variant_id' => 'var_01j9z8k3m4n5p6q7r8s9t0v1w2',
    'customer_email' => '[email protected]',
    'coupon_code' => 'LAUNCH20',            // optional
    'metadata' => ['order_ref' => '1001'],
]);
header('Location: '.$checkout['url']);

// 2. Read what happened.
$orders = $paywio->orders->autoPaginate(['status' => 'paid']);
foreach ($orders as $order) {
    // ...
}

// 3. Cancel a subscription at the end of its period.
$paywio->subscriptions->action('sub_...', 'cancel', [
    'at_period_end' => true,
]);

// Webhooks: a bad or stale signature throws PaywioException
// (type "signature_verification").
$event = \Paywio\Webhook::constructEvent(
    file_get_contents('php://input'),            // the RAW body
    $_SERVER['HTTP_PAYWIO_SIGNATURE'] ?? '',
    $signingSecret,                              // whsec_...
);

Node.js

import Paywio, { Webhook } from '@paywio/node';

const paywio = new Paywio('sk_test_...');

// 1. Create a checkout. The price always comes from the variant.
const checkout = await paywio.checkouts.create({
  variant_id: 'var_01j9z8k3m4n5p6q7r8s9t0v1w2',
  customer_email: '[email protected]',
  coupon_code: 'LAUNCH20', // optional
  metadata: { order_ref: '1001' },
});
redirect(checkout.url);

// 2. Read what happened.
for await (const order of paywio.orders.autoPaginate({ status: 'paid' })) {
  // ...
}

// 3. Cancel a subscription at the end of its period.
await paywio.subscriptions.action('sub_...', 'cancel', {
  at_period_end: true,
});

// Webhooks (Express): use the RAW body, not the parsed JSON.
// A bad or stale signature throws PaywioError ("signature_verification").
app.post('/paywio', express.raw({ type: 'application/json' }), (req, res) => {
  const event = Webhook.constructEvent(
    req.body,
    req.header('Paywio-Signature'),
    process.env.PAYWIO_WHSEC,
  );
  res.sendStatus(200);
});

Errors, limits and paging

Predictable by design.

One error shape, cursor paging and visible rate limits on every endpoint.

Errors

Every error has the same shape. Quote request_id when you contact support; it is also in the Request-Id response header.

application/json
{
  "error": {
    "type": "invalid_request",
    "code": "amount_not_accepted",
    "message": "Amounts are never accepted: the price always comes from the variant.",
    "param": "amount",
    "request_id": "req_01j9z8k3m4n5p6q7r8s9t0v1w2"
  }
}
Errors
typeHTTPExample codes
invalid_request 422 amount_not_accepted, unknown_parameter, invalid_limit
authentication 401 missing_api_key, invalid_api_key
permission 403 live_mode_not_enabled, store_inactive
not_found 404 resource_not_found
rate_limit 429 rate_limit_exceeded
conflict 409 idempotency_key_reused, idempotency_in_progress
api_error 5xx internal_error

Another store's resources always look like a 404, never a 403.

Rate limits

100live key

25test key

requests per 10 seconds, per key

Every authenticated response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Beyond the limit you get 429 with Retry-After.

Paging

List endpoints return {"object": "list", "data": […], "has_more": true}, newest first. Use limit (1 to 100, default 25) and starting_after (the id of the last object you received). A bad value is a 422, never silently ignored.

GET /orders
curl "https://api.paywio.com/v1/orders?status=paid&limit=2" \
  -H "Authorization: Bearer sk_test_..."

# {"object": "list", "data": [{...}, {...}], "has_more": true}
# next page: add &starting_after=<id of the last order you received>

Versioning and conventions

Version
The version is part of the URL (/v1) and the OpenAPI document reports v1.
Money
Integer cents, always USD ("currency": "usd"). Prices exclude VAT; any tax is reported separately in tax.
Time
Unix seconds.
Unknown fields
POST /checkouts rejects anything it does not know, and amounts are never accepted.
Ids
A short prefix and 26 characters.
  • prd_product
  • var_variant
  • chk_checkout
  • ord_order
  • cus_customer
  • sub_subscription
  • lic_license key
  • cpn_coupon
  • whe_webhook endpoint
  • evt_event
  • pay_payout

Preview Not available yet

  • Creating or editing products through the API: products are managed in the dashboard because each one is reviewed.
  • Coupons that discount 100%: a percent coupon is 1 to 99.
  • SDK releases on Packagist and npm.

Start building

Create a test key and make your first call.

Apply for an account, get approved, and create a test key from your dashboard. Applications are reviewed by hand.

API v1 · REST + JSON · Private preview

  1. Create a test key
  2. Pick a variant
  3. Create a checkout
  4. Receive order.paid