Accept payments anywhere with one API call.
Create a payment request, send your customer to the Payclio-hosted page (or open it in a popup), and the money goes to your wallet. The hosted page takes wallet and card payments, so your server never touches card data.
# 1. Create a payment request
curl https://payclio.com/api/v1/payment-requests \
-H "Authorization: Bearer $PAYCLIO_KEY" \
-H "Content-Type: application/json" \
-d '{"amount":"49.99","currency":"USD",
"memo":"Order #1234"}'
# 2. Redirect customer to the returned hosted_url
# 3. Poll status until paid:
curl https://payclio.com/api/v1/payment-requests/$TOKEN \
-H "Authorization: Bearer $PAYCLIO_KEY"Authentication
Every request carries your API key as a bearer token in the Authorization header. Create keys under API keys in your business account dashboard and revoke them at any time. A key acts for the whole business account, so keep it on your server. Creating payment requests needs a business account whose ID check is approved.
Authorization: Bearer <your API key> Accept: application/json Content-Type: application/json
Choose your integration
Three ways to take a payment, same payment-request engine underneath. Pick by how much control you need over the checkout UI versus how little code you want to write.
| SimplestHosted redirect | On your domainEmbedded popup | No codeWooCommerce plugin | |
|---|---|---|---|
| Effort to integrate | One API call plus a redirect | API call, a popup, and a message listener | Install plugin, paste API key |
| Buyer stays on your site | No, full-page redirect to Payclio | Yes, popup opens over your page | Yes, popup at checkout |
| Payment methods | Wallet and card | Wallet and card (same as hosted) | Wallet and card |
| Card data touches your server | Never (SAQ-A) | Never (SAQ-A) | Never (SAQ-A) |
| How payment confirms | Webhook or status poll | postMessage plus webhook or status poll | Built in: webhook and Action Scheduler poll |
| You write code | A little | A little more (front and back end) | None |
| Best for | Fastest path, any stack | Custom checkouts that must stay on brand | WordPress and WooCommerce stores |
Not sure? Start with hosted redirect, it is the fastest to ship and you can move to the embedded iframe later without changing how you create or settle requests.
Quickstart, accept a card payment
The hosted-redirect flow is the most common integration. You hand off the customer to a Payclio-hosted page that takes wallet and card payments, keeping your PCI scope at SAQ-A.
- 1
Create the request
Tie it to your local order via reference_type and reference_id so you can reconcile later.
- 2
Redirect (or iframe) to the hosted page
Send the customer to the returned hosted_url for a full-page redirect, or render embed_url in an iframe to keep them on your site.
- 3
Settle
After paying, the customer sees a confirmation and is sent to your return_url after 3 seconds, with ?payclio_token=...&payclio_status=paid added. That redirect is not proof of payment: mark the order paid when the payment_request.paid webhook arrives, or when GET /payment-requests/{token} returns status paid. In popup mode there is no redirect, listen for window.message events with { source: 'payclio', event: 'payment.paid' } instead.
Embedded checkout
Wallet + cardKeep the buyer on your own domain. Instead of redirecting to the hosted page, render the embed_url from a payment request in an iframe inside a popup, with the payer's details prefilled. Both wallet and card show by default, same as a direct visit to the hosted page, buyer picks. Your server never touches card data, the iframe is served by Payclio and stays at SAQ-A scope.
The browser postMessage event is a UX trigger, not proof of payment. Always confirm status === 'paid' from your server via GET /payment-requests/{token} (or a verified webhook) before releasing goods.
Wallet payment needs a real signed-in Payclio session. Browsers increasingly block third-party cookies inside a cross-site iframe (Safari ITP, Chrome's phase-out), so when embedded, choosing wallet opens the same link in a new tab rather than trying to authenticate inside the frame, this is the hosted page's own behaviour, nothing you need to build. Card needs no session at all and always stays inline.
1. Build the popup
A button that opens a modal on click, not an always-visible iframe, so nothing loads (or sits blank) until the buyer actually wants to pay. Lazy-load the iframe src from the embed_url returned by POST /payment-requests (it already carries ?embed=1) plus the prefill params below, and show a loading state until the frame's load event fires. The allow="payment *" attribute is required for 3-D Secure inside the frame.
Query params
| embed=1 | Embed mode. Strips the full-page chrome so it sits cleanly in a popup or inline layout. Already present on the returned embed_url. |
| card=1 | Optional. Card-only: hides the wallet tab, for a guest checkout where no one has a Payclio wallet to sign into. Omit to show both, our own recommendation. |
| name | Optional. Prefills the payer name. URL-encoded. |
| Optional. Prefills the payer email. URL-encoded. | |
| phone | Optional. Prefills the payer phone in E.164. URL-encoded. |
<!-- A button that opens the checkout in a popup, not an always-visible
iframe: nothing loads (or shows a blank white box) until the buyer
actually asks to pay. Wallet and card both appear, buyer picks. -->
<button id="payclio-open" type="button">Pay securely with Payclio</button>
<div id="payclio-modal" style="display:none;position:fixed;inset:0;z-index:100;
align-items:center;justify-content:center;">
<div id="payclio-backdrop" style="position:absolute;inset:0;background:rgba(0,0,0,.45)"></div>
<div style="position:relative;background:#fff;width:100%;max-width:480px;
max-height:92vh;border-radius:16px;overflow:hidden;display:flex;flex-direction:column;">
<div style="display:flex;justify-content:space-between;align-items:center;
padding:14px 18px;border-bottom:1px solid #f1f5f9;">
<strong>Pay securely with Payclio</strong>
<button id="payclio-close" type="button" aria-label="Close">×</button>
</div>
<div style="position:relative;flex:1;overflow-y:auto">
<div id="payclio-loading" style="position:absolute;inset:0;display:flex;
align-items:center;justify-content:center;background:#fff;">Loading…</div>
<iframe
id="payclio-iframe"
data-src="https://app.payclio.com/req/<token>?embed=1&name=Jane%20Doe&email=jane%40acme.com&phone=%2B264811234567"
allow="payment *; clipboard-write"
referrerpolicy="strict-origin"
style="width:100%;min-height:560px;border:0;opacity:0;transition:opacity .2s"
title="Secure payment">
</iframe>
</div>
</div>
</div>
<script>
var open_ = document.getElementById('payclio-open');
var modal = document.getElementById('payclio-modal');
var iframe = document.getElementById('payclio-iframe');
var loading = document.getElementById('payclio-loading');
open_.addEventListener('click', function () {
modal.style.display = 'flex';
if (!iframe.src) iframe.src = iframe.dataset.src; // lazy-load on first open
});
document.getElementById('payclio-close').addEventListener('click', close);
document.getElementById('payclio-backdrop').addEventListener('click', close);
function close() { modal.style.display = 'none'; }
iframe.addEventListener('load', function () {
loading.style.display = 'none';
iframe.style.opacity = '1';
});
</script>Vanilla HTML/JS, adapt the styling to your own design system. Prefilled fields save the buyer re-typing what they entered at checkout.
2. Listen for events, then confirm server-side
The frame posts messages to the parent window. Trust only messages whose origin matches the embed origin and whose data.source === 'payclio'.
| data.event | Meaning |
|---|---|
| payment.ready | The card field has rendered and is interactive. |
| payment.paid | The card was charged. A hint to confirm server-side, not proof of payment. |
// Trust only messages from the embed origin AND source: 'payclio'.
const origin = new URL(embedUrl).origin;
window.addEventListener('message', (e) => {
if (e.origin !== origin) return;
const d = e.data || {};
if (d.source !== 'payclio') return;
switch (d.event) {
case 'payment.ready': /* card field rendered */ break;
case 'payment.paid': confirmOnServer(); break; // hint only
}
});
// Authoritative: never fulfil on the browser event alone. Have YOUR server
// re-check status and only then mark the order paid (idempotently).
async function confirmOnServer() {
await fetch('/checkout/payclio/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
// server-side: GET /api/v1/payment-requests/{token} -> mark paid iff status === 'paid'
}Recommended settlement
- On
payment.paid(and on a 5s safety poll while the page is open), call your own confirm endpoint. - That endpoint calls
GET /api/v1/payment-requests/{token}and marks the order paid only whenstatus === 'paid'. Make it idempotent. - The
payment_request.paidwebhook may settle the same order a moment earlier, both paths are safe to run together.
Prefer the embed but want a no-code option? The WooCommerce plugin ships this exact flow.
Payment Requests
Each payment request is a one-shot, amount-locked checkout. After it's paid (or expired, or cancelled), it cannot be reused, mint a fresh one for the next order.
Request body
| amount | Required. Number or numeric string, 0.01 to 1,000,000. |
| currency | Required. USD, EUR, GBP, NAD or ZAR. Paid into your wallet in that currency, created for you if you do not have it yet. |
| memo | Optional. 280 chars. Shown to the payer. |
| reference_type | Optional. Your own label, e.g. woocommerce_order. |
| reference_id | Optional. Your internal order ID. |
| return_url | Optional. Full http or https address. After paying on the hosted page (not in popup mode) the customer is sent here with payclio_token and payclio_status=paid added. |
| expires_in_minutes | Optional. 1 to 43200 (30 days). Default 1440 (24 hours). |
Example
{
"amount": "49.99",
"currency": "USD",
"memo": "Order #1234, Acme Store",
"reference_type": "woocommerce_order",
"reference_id": "1234",
"return_url": "https://example.com/checkout/order-received/1234/",
"expires_in_minutes": 60
}Response 200 OK
{
"data": {
"payment_request": {
"token": "a1b2c3d4e5f6g7h8",
"uuid": "9b1a...",
"status": "pending",
"amount": 49.99,
"currency": "USD",
"memo": "Order #1234, Acme Store",
"hosted_url": "https://app.payclio.com/req/a1b2c3d4e5f6g7h8",
"embed_url": "https://app.payclio.com/req/a1b2c3d4e5f6g7h8?embed=1",
"reference_type": "woocommerce_order",
"reference_id": "1234",
"return_url": "https://example.com/checkout/order-received/1234/",
"transaction_id": null,
"created_at": "2026-05-13T13:00:00Z",
"expires_at": "2026-05-13T14:00:00Z",
"paid_at": null
}
}
}Redirect the customer to hosted_url, or render embed_url in an iframe.
Returns the current state of a payment request. If it is past expires_at and still pending, status reads expired. Status is one of pending, paid, expired or cancelled. Use this to verify before marking an order paid.
{
"data": {
"payment_request": {
"token": "a1b2c3d4e5f6g7h8",
"status": "paid",
"amount": 49.99,
"currency": "USD",
"transaction_id": 4821,
"paid_at": "2026-05-13T13:08:42Z",
...
}
}
}Webhooks
LiveAdd endpoints under Webhooks in your dashboard. We POST a signed JSON payload when a payment request is paid, expires or is cancelled. Retries on non-2xx and timeouts: 30s, 5m, 30m, 2h, 12h.
Events
| Event | Fires when |
|---|---|
| payment_request.paid | A payer pays a pending request by wallet or card. |
| payment_request.expired | Someone tries to pay a request after its expires_at. Do not wait for it to time out an order, check expires_at yourself. |
| payment_request.cancelled | You cancel the request in your dashboard. |
Payload
POST https://your-store.com/payclio/webhook
Content-Type: application/json
X-Payclio-Event: payment_request.paid
X-Payclio-Event-Id: evt_a1b2c3d4e5f6g7h8i9j0
X-Payclio-Signature: t=1715600000,v1=8e3a...4f1c
{
"id": "evt_a1b2c3d4e5f6g7h8i9j0",
"event": "payment_request.paid",
"created_at": "2026-05-13T14:00:00Z",
"data": {
"token": "a1b2c3d4e5f6g7h8",
"uuid": "9b1a...",
"status": "paid",
"amount": "49.99",
"currency": "USD",
"memo": "Order #1234, Acme Store",
"reference_type": "woocommerce_order",
"reference_id": "1234",
"return_url": "https://example.com/checkout/order-received/1234/",
"paid_at": "2026-05-13T13:59:55Z",
"paid_by_user_id": 9,
"transaction_id": 4821,
"expires_at": "2026-05-13T14:00:00Z",
"created_at": "2026-05-13T13:00:00Z"
}
}Verifying signatures
// PHP: recompute and constant-time compare
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PAYCLIO_SIGNATURE'] ?? '';
preg_match('/t=(\d+),v1=([0-9a-f]+)/', $header, $m);
[$_, $ts, $sig] = $m;
$expected = hash_hmac('sha256', "$ts.$body", $secret);
if (! hash_equals($expected, $sig)) {
http_response_code(400); exit;
}Reject events older than about 5 minutes (replay protection). Respond 2xx within 10 seconds, non-2xx and timeouts trigger the retry schedule above.
Errors
Error responses from the API are JSON like {"error": "Amount must be between 0.01 and 1,000,000.", "code": "VALIDATION_ERROR"}. Show or log the error text, it says what to fix.
| Status | Name | When you'll see it |
|---|---|---|
| 400 | Bad Request | Invalid body or the request can't be done, e.g. amount out of range, or the account is not a verified business account. The error field says why. |
| 401 | Unauthorized | Missing, invalid or revoked API key. |
| 404 | Not Found | The payment request token does not exist, or belongs to another account. |
| 500 | Server error | Something went wrong on our side. Safe to retry. |
Already on WooCommerce?
Skip the integration work, install our WooCommerce plugin. Popup checkout with wallet and card, webhook handling and Action Scheduler fallback polling. Create an API key in your dashboard, drop the .zip into Plugins, Add New, Upload, paste the key, save.