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
- In the seller dashboard open Developers and create a test API key. Copy it: it is shown once.
- Create (and get approved) a product in the dashboard. Copy the id of one of its variants (
var_…, seeGET /products). - 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 withlive_mode_not_enableduntil 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, never403.
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 & path | Description |
|---|---|
| GET /products | List products with their variants |
| GET /products/{id} | Retrieve a product |
| POST /checkouts | Create a checkout |
| GET /checkouts/{id} | Retrieve a checkout |
| GET /orders | List orders |
| GET /orders/{id} | Retrieve an order |
| POST /orders/{id}/refund | Refund an order |
| GET /customers | List customers |
| GET /customers/{id} | Retrieve a customer |
| GET /subscriptions | List subscriptions |
| GET /subscriptions/{id} | Retrieve a subscription |
| POST /subscriptions/{id}/cancel | Cancel a subscription |
| GET /license-keys | List the license keys of an order |
| GET /coupons | List coupons |
| POST /coupons | Create a coupon |
| GET /coupons/{id} | Retrieve a coupon |
| DELETE /coupons/{id} | Delete a coupon |
| 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. 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
2xxwithin 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
idto 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.