PHP SDK
One file, no dependencies. createInvoice(), getInvoice() and verifyWebhook() — drop it into any project.
A single dependency-free PHP SDK: create invoices, redirect to the hosted checkout, and verify signed webhooks. REST is available for any language.
api.payora.moneyOne API call with an order ID and a fiat or crypto amount returns a hosted pay URL.
They pick a coin, scan the QR or open a wallet, and the checkout confirms live on finality.
Verify the HMAC signature, credit the order once, and watch the payment land on your balance.
One file, no dependencies. createInvoice(), getInvoice() and verifyWebhook() — drop it into any project.
Requests and webhooks are HMAC-SHA256 signed with timestamp skew and idempotency keys.
Plain JSON over HTTPS — integrate from any stack, not just PHP.
<?php
require 'Payora.php';
$payora = new Payora('https://api.payora.money', getenv('PAYORA_KEY'), getenv('PAYORA_SECRET'));
// 1 — create an invoice (fiat-priced; the payer picks a coin)
$inv = $payora->createInvoice([
'order_id' => 'ORDER-42',
'mode' => 'fiat',
'amount' => '9.99',
'return_url' => 'https://shop.example/thanks',
'lang' => 'en',
], 'ORDER-42'); // Idempotency-Key: a retry never creates a second invoice
header('Location: ' . $inv['pay_url']);
// 2 — webhook endpoint: ALWAYS verify the signature first
$event = $payora->verifyWebhook(file_get_contents('php://input'), getallheaders());
if ($event === null) { http_response_code(400); exit; }
if ($event['status'] === 'paid') {
creditOrderOnce($event['id'], $event['order_id'], $event['amount'], $event['currency']);
}
http_response_code(200); // 2xx stops retriesconst Payora = require('./payora');
const payora = new Payora('https://api.payora.money', process.env.PAYORA_KEY, process.env.PAYORA_SECRET);
// 1 — create an invoice and redirect
app.post('/checkout', async (req, res) => {
const inv = await payora.createInvoice({ order_id: 'ORDER-42', mode: 'fiat', amount: '9.99' }, 'ORDER-42');
res.redirect(inv.pay_url);
});
// 2 — webhook: verify over the RAW body
app.post('/payora/webhook', express.raw({ type: '*/*' }), (req, res) => {
const event = payora.verifyWebhook(req.body, req.headers);
if (event === null) return res.status(400).end();
if (event.status === 'paid') creditOrderOnce(event.id, event.order_id);
res.status(200).end();
});import os
from payora import Payora
payora = Payora('https://api.payora.money', os.environ['PAYORA_KEY'], os.environ['PAYORA_SECRET'])
# 1 — create an invoice and redirect
inv = payora.create_invoice({'order_id': 'ORDER-42', 'mode': 'fiat', 'amount': '9.99'}, 'ORDER-42')
return redirect(inv['pay_url'])
# 2 — webhook: verify over the RAW body
event = payora.verify_webhook(request.get_data(), dict(request.headers))
if event is None:
return ('', 400)
if event['status'] == 'paid':
credit_order_once(event['id'], event['order_id'])
return ('', 200)# signature = lowercase hex HMAC-SHA256(api_secret, timestamp + "." + raw_body)
BODY='{"order_id":"ORDER-42","mode":"fiat","amount":"9.99"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$PAYORA_SECRET" | cut -d' ' -f2)
curl -X POST https://api.payora.money/v1/invoice \
-H "X-Payora-Key: $PAYORA_KEY" -H "X-Payora-Timestamp: $TS" \
-H "X-Payora-Signature: $SIG" -H 'Idempotency-Key: ORDER-42' \
-H 'Content-Type: application/json' -d "$BODY"
# → 201 { "invoice_id": …, "status": "pending", "pay_url": "https://pay.payora.money/i/…", "expires": … }Get your API key and secret by creating an account. The full SDK is in your dashboard.
Every call except /v1/health carries three signed headers. The signature proves the request comes from the holder of your api_secret and was not changed on the way.
| Header | Value | Description |
|---|---|---|
X-Payora-Key | pk_… | Your public api_key. Identifies the store. |
X-Payora-Timestamp | unix seconds | The moment you signed the request, in Unix seconds. |
X-Payora-Signature | hex · 64 | Lowercase hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your api_secret. |
X-Payora-Test | 1 | Marks an invoice as a test of your integration: it is real and can receive coins, but stays out of demand and conversion reports. |
Idempotency-Key | string | Optional on POST /v1/invoice and POST /v1/payout. A retry with the same key returns the first result instead of creating a duplicate; the same key with a different body answers 409. |
Content-Type | application/json | Required on requests that carry a body. |
hex( HMAC-SHA256( api_secret, timestamp + "." + raw_body ) )Sign the exact bytes you send. Re-encoding the JSON after signing — even reordering keys or adding a space — breaks the signature.
For a GET request the body is empty, so the signed string is the timestamp followed by a dot.
The API rejects a timestamp more than 120 seconds away from its clock. Keep your server in sync with NTP.
Keep api_secret on your server. It signs requests and webhooks alike: whoever holds it can create invoices in your name and forge callbacks.
<?php
$body = json_encode(['order_id' => 'ORDER-42', 'mode' => 'fiat', 'amount' => '9.99'], JSON_UNESCAPED_SLASHES);
$ts = (string) time();
$sig = hash_hmac('sha256', $ts . '.' . $body, $apiSecret); // lowercase hex
$ch = curl_init('https://api.payora.money/v1/invoice');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-Payora-Key: ' . $apiKey,
'X-Payora-Timestamp: ' . $ts,
'X-Payora-Signature: ' . $sig,
'Idempotency-Key: ORDER-42',
'Content-Type: application/json',
],
]);
$invoice = json_decode(curl_exec($ch), true);const crypto = require('crypto');
const body = JSON.stringify({ order_id: 'ORDER-42', mode: 'fiat', amount: '9.99' });
const ts = String(Math.floor(Date.now() / 1000));
const sig = crypto.createHmac('sha256', API_SECRET).update(ts + '.' + body).digest('hex');
const res = await fetch('https://api.payora.money/v1/invoice', {
method: 'POST',
headers: {
'X-Payora-Key': API_KEY,
'X-Payora-Timestamp': ts,
'X-Payora-Signature': sig,
'Idempotency-Key': 'ORDER-42',
'Content-Type': 'application/json',
},
body, // send exactly the bytes you signed
});import hashlib, hmac, json, time, urllib.request
body = json.dumps({'order_id': 'ORDER-42', 'mode': 'fiat', 'amount': '9.99'}, separators=(',', ':'))
ts = str(int(time.time()))
sig = hmac.new(API_SECRET.encode(), (ts + '.' + body).encode(), hashlib.sha256).hexdigest()
req = urllib.request.Request('https://api.payora.money/v1/invoice', data=body.encode(), method='POST', headers={
'X-Payora-Key': API_KEY,
'X-Payora-Timestamp': ts,
'X-Payora-Signature': sig,
'Idempotency-Key': 'ORDER-42',
'Content-Type': 'application/json',
})
invoice = json.load(urllib.request.urlopen(req))# GET has an empty body: the signed string is just the timestamp and a dot
TS=$(date +%s)
SIG=$(printf '%s.' "$TS" | openssl dgst -sha256 -hmac "$PAYORA_SECRET" | cut -d' ' -f2)
curl https://api.payora.money/v1/invoice/1042 \
-H "X-Payora-Key: $PAYORA_KEY" \
-H "X-Payora-Timestamp: $TS" \
-H "X-Payora-Signature: $SIG"All optional except order_id and amount. Fiat mode lets the payer pick any coin enabled on your account; crypto mode also needs currency.
| Parameter | Type | Description |
|---|---|---|
order_id | string · required | Your unique order reference (≤190 chars). A repeat rejects with 409 — safe against double-submits. |
amount | string · required | Fiat amount (mode=fiat) or exact crypto amount (mode=crypto). |
mode | fiat | crypto | Default fiat: rate locked per coin at checkout. crypto fixes the coin + amount up front. |
fiat_currency | string | ISO fiat code for fiat mode (default USD). |
currency | string | Coin for crypto mode (e.g. TON, TRX, USDT_TRON). |
return_url | https URL | Where the checkout's “Return to store” button sends the customer back — your site. Absolute http(s) only. |
lang | en·ru·uk·es·de | Checkout language, so a customer from your Russian store sees the Russian checkout — not English. Also settable as ?lang=ru on the pay URL. |
ttl | seconds | How long the invoice stays payable (120–86400 seconds, default 1800). |
customer_email | Optional — receipt + emailed to the payer. | |
notes | string | Free-form note stored on the invoice. |
return_url and lang also work as query params appended to the pay_url the API gives you — <pay_url>&lang=ru&return_url=https://shop.example/thanks. Always send the buyer to the pay_url returned by the API: it carries a one-per-invoice access token, so a checkout page cannot be reached by guessing invoice numbers. Do not rebuild the link from the invoice id.
Fill in the fields and copy a ready cURL command. It signs itself in your shell with $PAYORA_KEY and $PAYORA_SECRET — this page never sees your keys and sends nothing.
BODY='{"order_id":"ORDER-42","mode":"fiat","amount":"9.99","fiat_currency":"USD","return_url":"https://shop.example/thanks","lang":"en","ttl":1800}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$PAYORA_SECRET" | cut -d' ' -f2)
curl -X POST https://api.payora.money/v1/invoice \
-H "X-Payora-Key: $PAYORA_KEY" \
-H "X-Payora-Timestamp: $TS" \
-H "X-Payora-Signature: $SIG" \
-H 'Idempotency-Key: ORDER-42' \
-H 'Content-Type: application/json' \
-d "$BODY"Nothing is sent from this page.
When an invoice or a payout changes state, Payora POSTs a signed JSON event to your callback URL. Verify the signature first, credit the order once, answer 2xx.
| Event | status | When it fires |
|---|---|---|
invoice.paid | paid | The payment is confirmed on-chain. Carries the amount, the coin and tx_hash. |
invoice.underpaid | underpaid | The invoice expired after less than the expected amount arrived. |
invoice.expired | expired | The invoice expired with nothing received. |
invoice.cancelled | cancelled | The invoice was cancelled. Money that had already arrived, if any, is described in the event. |
invoice.cancel_settled | — | Money that arrived on a cancelled invoice has been dealt with: credited or sent back. |
invoice.late_payment | — | Money that arrived on an invoice after it was cancelled. Sent once for each such deposit; result says whether it was credited or is being returned. |
payout.sent | sent | A payout batch was signed and broadcast. Carries every item with its tx_hash. |
| Header | Value | Description |
|---|---|---|
X-Payora-Id | uuid | Unique event id, the same as id in the body. Store it to ignore repeats. |
X-Payora-Event | invoice.paid | Event name. |
X-Payora-Timestamp | unix seconds | When this attempt was signed. |
X-Payora-Signature | hex · 64 | Same scheme as requests: HMAC-SHA256 of timestamp + "." + raw body with your api_secret. |
| Field | Type | Description |
|---|---|---|
id | uuid | Event id for de-duplication. |
event | string | Event name. |
invoice_id | int | Payora invoice number. |
order_id | string | Your order reference, as sent when the invoice was created. |
status | string | paid for invoice.paid. |
currency | string | Coin the payer used, e.g. USDT_TRON. |
amount | decimal string | Amount received. |
amount_units | integer string | The same amount in the coin's smallest units — use it for exact comparisons. |
expected | decimal string | Amount the invoice asked for. |
fiat_amount | decimal string | null | Fiat price of the invoice as you created it; null for crypto-mode invoices. |
fiat_currency | string | null | ISO code of fiat_amount; null for crypto-mode invoices. |
tx_hash | string | On-chain transaction hash. |
paid_at | unix seconds | When the event was created. |
test | bool | true for sandbox invoices. |
{
"id": "6f1c2a4e-8b0d-4c1e-9a57-2f3d9e1b7c40",
"event": "invoice.paid",
"invoice_id": 1042,
"order_id": "ORDER-42",
"status": "paid",
"currency": "USDT_TRON",
"amount": "9.99",
"amount_units": "9990000",
"expected": "9.99",
"fiat_amount": "9.99",
"fiat_currency": "USD",
"tx_hash": "3f9a…c21e",
"paid_at": 1758038400,
"test": false
}When the amount received differs from the invoice, amount_received and amount_expected are added.
Anything other than 2xx, or no answer within 15 seconds, is retried with exponential backoff capped at one hour between attempts — up to 12 attempts. Every attempt is signed again with a new timestamp; the body and the event id stay the same, so de-duplicate on id.
After the last attempt the event is marked failed and the store owner gets an email. Redirects are never followed, and the callback URL must be reachable from the internet — no localhost or private addresses.
hash_equals, crypto.timingSafeEqual, hmac.compare_digest.<?php
require 'Payora.php';
$payora = new Payora('https://api.payora.money', getenv('PAYORA_KEY'), getenv('PAYORA_SECRET'));
$raw = file_get_contents('php://input'); // raw, before any parsing
$event = $payora->verifyWebhook($raw, getallheaders()); // HMAC + ±300 s window, constant-time
if ($event === null) { http_response_code(400); exit; } // bad / forged / stale
if ($event['event'] === 'invoice.paid' && !alreadyProcessed($event['id'])) {
creditOrder($event['order_id'], $event['amount'], $event['currency'], $event['tx_hash']);
rememberProcessed($event['id']);
}
http_response_code(200); // 2xx stops retries// Express: capture the RAW body for this route
app.post('/payora/webhook', express.raw({ type: '*/*' }), (req, res) => {
const event = payora.verifyWebhook(req.body, req.headers); // HMAC + ±300 s, timingSafeEqual
if (event === null) return res.status(400).end();
if (event.event === 'invoice.paid' && !alreadyProcessed(event.id)) {
creditOrder(event.order_id, event.amount, event.currency, event.tx_hash);
rememberProcessed(event.id);
}
res.status(200).end(); // 2xx stops retries
});# Flask: request.get_data() is the exact raw body
@app.post('/payora/webhook')
def payora_webhook():
event = payora.verify_webhook(request.get_data(), dict(request.headers))
if event is None:
return ('', 400) # bad / forged / stale
if event['event'] == 'invoice.paid' and not already_processed(event['id']):
credit_order(event['order_id'], event['amount'], event['currency'], event['tx_hash'])
remember_processed(event['id'])
return ('', 200) # 2xx stops retries# Recompute the signature of a delivery you saved byte-for-byte to body.json
TS='1758038400' # X-Payora-Timestamp
SIG='…' # X-Payora-Signature
EXPECTED=$(printf '%s.' "$TS" | cat - body.json | openssl dgst -sha256 -hmac "$PAYORA_SECRET" | cut -d' ' -f2)
[ "$EXPECTED" = "$SIG" ] && echo valid || echo forgedEnter a secret, a timestamp and the raw body to see the exact signature Payora would send — or check one you received.
Runs in your browser with WebCrypto. Nothing you type leaves this page. Use a test secret, not a live one, on a shared computer.
1758038400.{…}X-Payora-Timestamp: 1758038400
X-Payora-Signature: …Paste a signature to compare
Send crypto to up to 500 wallets in one signed, idempotent request — for payroll, affiliate payouts or withdrawals. The batch total plus per-item fee is held on your internal balance instantly; the operator signs and broadcasts, then a signed payout.sent webhook fires. Same HMAC auth as everything else.
# create a payout batch — funds held instantly, sent by the operator
curl -X POST https://api.payora.money/v1/payout \
-H 'X-Payora-Key: pk_…' -H 'X-Payora-Timestamp: 1700000000' \
-H 'X-Payora-Signature: <hmac-sha256>' -H 'Idempotency-Key: payroll-2026-01' \
-d '{"currency":"USDT_TRON","items":[
{"address":"TR7…","amount":"250.00"},
{"address":"TX9…","amount":"99.50","tag":"batch-A"}]}'
# → 201 Created
{ "batch_id": 812, "status": "pending", "currency": "USDT_TRON",
"item_count": 2, "total": "349.50", "fee": "…", "items": [ … ] }
# poll status, or receive a signed payout.sent webhook when it is sent
curl https://api.payora.money/v1/payout/812 -H 'X-Payora-Key: …' …<?php
// The one-file PHP SDK covers invoices and webhooks; payouts are one signed POST.
$body = json_encode(['currency' => 'USDT_TRON', 'items' => [
['address' => 'TR7…', 'amount' => '250.00'],
['address' => 'TX9…', 'amount' => '99.50', 'tag' => 'batch-A'],
]], JSON_UNESCAPED_SLASHES);
$ts = (string) time();
$sig = hash_hmac('sha256', $ts . '.' . $body, $apiSecret);
$ch = curl_init('https://api.payora.money/v1/payout');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Payora-Key: ' . $apiKey, 'X-Payora-Timestamp: ' . $ts, 'X-Payora-Signature: ' . $sig,
'Idempotency-Key: payroll-2026-01', 'Content-Type: application/json']]);
$batch = json_decode(curl_exec($ch), true); // 201: batch_id, status, totals, fee, itemsconst batch = await payora.createPayout({
currency: 'USDT_TRON',
items: [
{ address: 'TR7…', amount: '250.00' },
{ address: 'TX9…', amount: '99.50', tag: 'batch-A' },
],
}, 'payroll-2026-01'); // Idempotency-Key: a retried run never doubles
const status = await payora.getPayout(batch.batch_id);
// still pending? await payora.cancelPayout(batch.batch_id) releases the holdbatch = payora.create_payout({
'currency': 'USDT_TRON',
'items': [
{'address': 'TR7…', 'amount': '250.00'},
{'address': 'TX9…', 'amount': '99.50', 'tag': 'batch-A'},
],
}, 'payroll-2026-01') # Idempotency-Key: a retried run never doubles
status = payora.get_payout(batch['batch_id'])
# still pending? payora.cancel_payout(batch['batch_id']) releases the holdValidation is strict: any bad address or over-precise amount rejects the whole batch (HTTP 422) with the offending item indexes — a payroll run never silently drops a recipient. Cancel a still-pending batch with POST /v1/payout/{id}/cancel to release the hold. See the architecture write-up.
Every endpoint speaks plain JSON over HTTPS at api.payora.money. All except /v1/health use the same HMAC-SHA256 request auth. The machine-readable spec lives at openapi.json.
| Endpoint | Access | Description |
|---|---|---|
GET/v1/health | no auth | Service status plus the list of enabled currencies. |
POST/v1/invoice | auth | Create an invoice (fiat or crypto mode). Supports Idempotency-Key. Returns 201 with invoice_id, status, pay_url and expires; crypto mode adds the address, amount and deeplink. |
GET/v1/invoice/{id} | auth | Invoice status (own invoices only): status, remaining, and the charge — currency, address, expected/received amounts and tx_hash. |
POST/v1/payout | auth · Pro+ | Create a mass payout batch (strict validation, funds held instantly). Supports Idempotency-Key. Returns 201 with the batch view: batch_id, status, totals, fee, items. |
GET/v1/payout | auth | List your recent payout batches (?limit=, default 50). |
GET/v1/payout/{id} | auth | Payout batch status and items (own batches only). |
POST/v1/payout/{id}/cancel | auth | Cancel a still-pending batch and release the held balance. Returns 409 if the batch is no longer cancellable. |
No endpoint matches.
Single files with no dependencies. Every SDK signs requests and verifies webhooks the same way.
PHP 7.4+ with cURL, one file. createInvoice, getInvoice and verifyWebhook.
Node.js with built-in https and crypto only. Invoices, balance, payouts and verifyWebhook.
Python standard library only. Invoices, balance, payouts and verify_webhook.
A Postman collection that signs every call for you.
The machine-readable spec of every endpoint, for code generators and API clients.
A ready module does all of the above for you: invoice, redirect and a verified webhook.
Straight answers, including the uncomfortable ones: custody, fees and what happens when something goes wrong.
Help centreInstall the single-file PHP SDK (no dependencies) or call the REST API directly. Create an invoice with createInvoice(), redirect the customer to its pay_url, and handle one webhook event: status=paid. A competent developer is live in 30–60 minutes.
Yes. Everything is plain JSON over HTTPS, so you can integrate from any language. The PHP SDK is a convenience wrapper around the same endpoints — create invoice, get invoice, and verify webhook signatures.
Yes. Pass return_url when you create the invoice (or ?return_url= on the pay link) and the checkout shows a “Return to store” button back to your site on the success screen — and while paying. Pass lang (en, ru, uk, es, de), or ?lang= on the link, so a customer from your Russian store lands on the Russian checkout instead of English. Both are optional; return_url must be an absolute http(s) URL.
Requests and webhooks are authenticated with HMAC-SHA256 over the timestamp and raw body using your api_secret, with a ±120-second skew window and idempotency keys. Signatures are compared in constant time to prevent timing attacks.
Create a free account. Your public api_key and secret api_secret appear in the dashboard, along with the downloadable SDK and full webhook reference. Rotate keys at any time from settings.
A signed JSON event with the invoice id, your order_id, the status (for example paid), the amount and currency, and a unique event id for idempotency. Always verify the signature before crediting an order, then record the event id to avoid double-processing on retries.
Yes. POST /v1/payout sends crypto to up to 500 wallets in one request — one {address, amount, optional tag} per item, in any coin enabled on your account. It uses the same HMAC-SHA256 auth and Idempotency-Key as the invoice API, so a retried request never creates a duplicate batch. The batch total plus per-item fee is reserved on your balance immediately; the operator signs and broadcasts, then a signed payout.sent webhook fires. Validation is strict — any invalid address or over-precise amount rejects the whole batch (422) so a payroll run never silently drops a recipient.
Build the recipient list in your own system, then call POST /v1/payout once per run with a stable Idempotency-Key (for example payroll-2026-01). Poll GET /v1/payout/{id} or wait for the payout.sent webhook to confirm delivery and record each tx hash. Submitting the batch reserves funds but does not move them by itself — the operator signs and broadcasts, so nothing leaves on an API call alone.
A single dependency-free PHP SDK: create invoices, redirect to the hosted checkout, and verify signed webhooks. REST is available for any language.