Private preview · API v1

Paywio API

Create checkouts from your own server, read your orders, and get signed webhooks when money moves. JSON over HTTPS, base URL https://api.paywio.com/v1.

Quickstart

  1. In the seller dashboard open Developers and create a test API key. Copy it: it is shown once.
  2. Create (and get approved) a product in the dashboard. Copy the id of one of its variants (var_…, see GET /products).
  3. Create a checkout from your server and send your buyer to the returned 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"}}'
{
  "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
}

The buyer pays on Paywio's hosted page; you learn about the result through the order.paid webhook or by reading the order. The amount is never sent by you: the price always comes from the variant you chose.

Authentication

Send your secret key as a bearer token: Authorization: Bearer sk_test_…. Cookies and browser sessions are never accepted. Keys are secret: call the API only from your server.

  • sk_test_… keys create test-mode checkouts and see only test orders. No real money moves.
  • sk_live_… keys use live mode. They are refused with live_mode_not_enabled until live payments are enabled for the platform.
  • Keys are stored only as a keyed hash; revoke a key in the dashboard and it stops working immediately.
  • Resources of another store always look like 404, never 403.

Errors

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

{
  "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"
  }
}

type is one of invalid_request (422), authentication (401), permission (403), not_found (404), conflict (409), rate_limit (429), api_error (5xx).

Idempotency

Send an Idempotency-Key header (1–255 printable characters) on POST requests. Repeating a request with the same key and the same body returns the first response again (header Idempotent-Replayed: true) instead of creating a second checkout. The same key with a different body is a 409. Keys are remembered for 24 hours, per API key. Failed requests are not remembered, so you can fix the request and retry with the same key.

Rate limits & paging

Per key: 100 requests per 10 seconds for live keys, 25 for test keys. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; beyond the limit you get 429 with Retry-After.

List endpoints return {"object":"list","data":[…],"has_more":true}, newest first. Use limit (1–100, default 25) and starting_after (the id of the last object you received).

Endpoints

Every endpoint with its parameters and response fields is in the API reference; the same contract is published as openapi.json (OpenAPI 3.1).

Method & pathDescription
GET /productsList products with their variants
GET /products/{id}Retrieve a product
POST /checkoutsCreate a checkout
GET /checkouts/{id}Retrieve a checkout
GET /ordersList orders
GET /orders/{id}Retrieve an order
POST /orders/{id}/refundRefund an order
GET /customersList customers
GET /customers/{id}Retrieve a customer
GET /subscriptionsList subscriptions
GET /subscriptions/{id}Retrieve a subscription
POST /subscriptions/{id}/cancelCancel a subscription
GET /license-keysList the license keys of an order
GET /couponsList coupons
POST /couponsCreate a coupon
GET /coupons/{id}Retrieve a coupon
DELETE /coupons/{id}Delete a coupon
GET /webhook-endpointsList webhook endpoints of the key's mode
POST /webhook-endpointsCreate 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}/testSend a test event

Products are created and edited in the dashboard only, because every product goes through review before it can be sold. Subscription variants (monthly or yearly) are sold through the same POST /checkouts; pass coupon_code to apply a coupon you created.

Webhooks

Add an HTTPS endpoint under Developers → Webhooks and choose the events you want. Each endpoint is either live or test and only receives events of that mode. Its signing secret (whsec_…) is shown once.

Events: order.paid, order.refunded, subscription.created, subscription.renewed, subscription.payment_failed, subscription.canceled, license.revoked, dispute.created, dispute.closed, payout.sent, payout.failed.

{
  "id": "evt_01j9z8k3m4n5p6q7r8s9t0v1w2",
  "type": "order.paid",
  "created": 1730000000,
  "livemode": true,
  "data": {
    "object": {
      "object": "order",
      "id": "ord_01j9z8k3m4n5p6q7r8s9t0v1w2",
      "status": "paid",
      "currency": "usd",
      "total": 2900,
      "product_id": "prd_...",
      "variant_id": "var_...",
      "customer": {"id": "cus_...", "email": "[email protected]", "country": "GB"},
      "paid_at": 1730000000
    }
  }
}

Verify the signature

Each request carries Paywio-Signature: t=<unix>,v1=<hex> where v1 = HMAC-SHA256("<t>.<raw body>", secret). Compute it over the raw body, compare in constant time, and reject timestamps older than 5 minutes. For 24 hours after you rotate a secret the header has two v1 values (new and old); accept either.

<?php
$payload = file_get_contents('php://input');   // the RAW body, before any JSON decoding
$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);

// Anahtar döndürme sırasında başlıkta iki v1 olabilir: herhangi biri eşleşirse geçerlidir.
$signatures = array_map(fn ($p) => substr($p, 3), array_filter(explode(',', $header), fn ($p) => str_starts_with(trim($p), 'v1=')));
$valid = abs(time() - $timestamp) <= 300 && array_filter($signatures, fn ($s) => hash_equals($expected, $s)) !== [];
if (! $valid) { http_response_code(400); exit; }
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 expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
import hashlib, hmac, time

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

Delivery

  • Answer with any 2xx within 10 seconds. Redirects are not followed and count as failures.
  • On failure we retry up to 8 attempts in total (after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h). If an endpoint keeps failing for 3 days it is disabled and we email you; you can resend events from the delivery log.
  • 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, loopback or link-local networks are refused, and we connect only to the address we verified.

SDKs

Official clients for PHP (paywio/paywio-php, PHP 8.2+) and Node.js (@paywio/node, Node 18+) are in the sdk/ directory of the repository. Both retry safely with an idempotency key, throw typed errors that carry the request id, and verify webhook signatures: Webhook::constructEvent($payload, $header, $secret).

Not available yet

Honest list, so you do not build on things that are not there: success_url / cancel_url on checkouts (they need a verified-domain allow list), 100%-off coupons (free orders), and package-registry releases of the SDKs.