Introduction
OrcaPayz is an orchestration layer in front of your own Stripe accounts. You integrate one API; we create the PaymentIntent on the account that your traffic split and per-account limits select, serve a white-label checkout on your payment domain, and notify you with signed webhooks. Your Stripe accounts remain the merchant of record and hold the funds.
All endpoints live under http://orcapayz.com/api/v1. Requests and responses are JSON. Amounts are decimal major units (for example 49.90, not 4990). Every response carries a boolean success field.
Authentication
Create a key pair in Dashboard, API keys and send both values as headers on every request to /api/v1.
X-API-Key: ok_live_…
X-API-Secret: os_live_…
Content-Type: application/json
Accept: application/json
Keys are scoped to your merchant account and can be rotated from the dashboard at any time. Keep the secret on your server; never ship it to a browser or mobile app. Requests are limited to 120 per minute per key; over the limit you receive HTTP 429 with a Retry-After header.
Errors
Errors return a non-2xx status and a JSON body with success: false, a human-readable message, and a machine-readable code where applicable. Validation failures (422) add an errors object keyed by field.
HTTP/1.1 422 Unprocessable Content
{
"success": false,
"message": "The given data was invalid.",
"errors": {
"amount": [ "The amount must be at least 0.50." ],
"return_url": [ "The return url field is required." ]
}
}
| Status | Code | Meaning |
|---|---|---|
| 401 | MISSING_CREDENTIALS | One or both auth headers are missing. |
| 401 | INVALID_CREDENTIALS | The key/secret pair does not match an active key. |
| 403 | ACCESS_EXPIRED | Your trial or subscription has ended. Contact us to continue. |
| 403 | INVALID_PAYMENT_ACCOUNT | payment_account_id is not one of your active accounts. |
| 400 | NO_AVAILABLE_ACCOUNT | No active, verified account can take this payment right now. |
| 400 | ACCOUNT_LIMIT_EXCEEDED | The selected account would exceed its daily or monthly limit. |
| 400 | CANNOT_CANCEL | The transaction is not pending or processing. |
| 400 | REFUND_FAILED | Stripe rejected the refund or the amount exceeds the refundable balance. |
| 422 | validation | Request body failed validation; see errors. |
| 429 | rate limit | More than 120 requests in one minute. |
| 502 | PROVIDER_ERROR | Stripe returned an error while creating or updating the intent. |
Health check
GET http://orcapayz.com/api/health, no authentication required.
Use it for uptime monitors and connectivity checks from your infrastructure.
{
"success": true,
"status": "ok",
"version": "1.0.0",
"timestamp": "2026-09-27T10:15:32+00:00"
}
Create a payment intent
POST /api/v1/payment-intents
Creates a transaction, routes it to a Stripe account, creates the PaymentIntent there and returns a hosted checkout URL on your payment domain.
Request body
| Field | Type | Description |
|---|---|---|
| amountrequired | number | Decimal major units. Minimum 0.50. |
| currency | string | Three-letter ISO code. Defaults to "usd". |
| customer_emailrequired | string | Shopper email. Used for the receipt and shown on the checkout. |
| customer_name | string | Shopper name. |
| description | string | Up to 500 characters. Appears on the checkout and in the dashboard. |
| metadata | object | Up to 20 string keys. Returned unchanged in GET responses and webhooks. |
| return_urlrequired | string | URL the shopper is sent to after paying. See Return redirect. |
| webhook_url | string | Per-transaction webhook endpoint. Receives the same signed events as your account webhook. |
| payment_account_id | integer | Bypass routing and use this account. Must be one of your active accounts (see Payment accounts). |
| locale | string | Language of the hosted checkout: en or tr. Defaults to your branding setting, then the shopper's browser. ?lang=tr on the checkout URL overrides it. |
Example request
curl -X POST http://orcapayz.com/api/v1/payment-intents \
-H "X-API-Key: $ORCA_KEY" \
-H "X-API-Secret: $ORCA_SECRET" \
-H "Content-Type: application/json" \
-d '{
"amount": 49.90,
"currency": "usd",
"customer_email": "[email protected]",
"customer_name": "Jane Doe",
"description": "Order #10422",
"metadata": { "order_id": "10422" },
"return_url": "https://yourstore.com/thank-you"
}'
Response 201 Created
{
"success": true,
"data": {
"transaction_id": "txn_9f2c1a7e3b…",
"checkout_url": "https://pay.yourdomain.com/checkout/txn_9f2c1a7e3b…",
"status": "processing",
"amount": 49.9,
"currency": "usd",
"payment_intent": {
"id": "pi_3Q…",
"client_secret": "pi_3Q…_secret_…"
},
"payment_account": {
"id": 3,
"publishable_key": "pk_live_…"
}
}
}
Recommended: redirect the shopper to checkout_url. The hosted checkout carries your branding, mounts the Stripe Payment Element under your domain and handles 3D Secure. Alternative: build your own form with Stripe Elements using client_secret and the account's publishable_key. In that case you must confirm the intent yourself and use return_url in your Stripe confirm call.
Possible errors: 422 validation, 400 NO_AVAILABLE_ACCOUNT, 400 ACCOUNT_LIMIT_EXCEEDED, 403 INVALID_PAYMENT_ACCOUNT, 502 PROVIDER_ERROR.
Return redirect
After the shopper completes (or fails) payment on the hosted checkout they are redirected to your return_url with these query parameters appended:
https://yourstore.com/thank-you
?transaction_id=txn_9f2c1a7e3b…
&status=succeeded
&payment_intent=pi_3Q…
&redirect_status=succeeded
transaction_id: the OrcaPayz transaction.status: our status at redirect time, one ofsucceeded,processingorfailed.payment_intent: the Stripe PaymentIntent id.redirect_status: the value Stripe appended after confirmation.
Never trust the redirect alone.
Query strings can be replayed or edited. Mark an order paid only after a payment_intent.succeeded webhook or a GET /api/v1/payment-intents/{transaction_id} returns status: "succeeded".
List payment intents
GET /api/v1/payment-intents
Returns your transactions across all accounts, newest first.
Query parameters
| Parameter | Description |
|---|---|
| status | One of pending, processing, succeeded, failed, refunded, cancelled. |
| customer_email | Exact match on the shopper email. |
| from_date | ISO 8601 date or datetime. Inclusive lower bound on created_at. |
| to_date | ISO 8601 date or datetime. Inclusive upper bound on created_at. |
| limit | 1 to 100. Default 50. |
| offset | Number of rows to skip. Default 0. |
Response
{
"success": true,
"data": [
{
"transaction_id": "txn_9f2c1a7e3b…",
"payment_intent_id": "pi_3Q…",
"amount": 49.9,
"refunded_amount": 0,
"currency": "usd",
"status": "succeeded",
"customer_email": "[email protected]",
"customer_name": "Jane Doe",
"description": "Order #10422",
"paid_at": "2026-09-27T10:16:04+00:00",
"created_at": "2026-09-27T10:15:32+00:00"
}
],
"count": 1,
"total": 1284
}
count is the number of rows in this page; total is the number matching your filters.
Retrieve a payment intent
GET /api/v1/payment-intents/{transaction_id}
Returns the same fields as the list endpoint plus metadata, return_url, webhook_url, payment_account_id, checkout_url, error_message and updated_at. This is the endpoint to use for reconciliation.
{
"success": true,
"data": {
"transaction_id": "txn_9f2c1a7e3b…",
"payment_intent_id": "pi_3Q…",
"amount": 49.9,
"refunded_amount": 0,
"currency": "usd",
"status": "succeeded",
"customer_email": "[email protected]",
"customer_name": "Jane Doe",
"description": "Order #10422",
"metadata": { "order_id": "10422" },
"return_url": "https://yourstore.com/thank-you",
"webhook_url": null,
"payment_account_id": 3,
"checkout_url": "https://pay.yourdomain.com/checkout/txn_9f2c1a7e3b…",
"error_message": null,
"paid_at": "2026-09-27T10:16:04+00:00",
"created_at": "2026-09-27T10:15:32+00:00",
"updated_at": "2026-09-27T10:16:04+00:00"
}
}
Cancel a payment intent
POST /api/v1/payment-intents/{transaction_id}/cancel
Cancels a transaction that is still pending or processing. The Stripe PaymentIntent is cancelled and the checkout URL stops accepting payment. No request body.
{
"success": true,
"data": {
"transaction_id": "txn_9f2c1a7e3b…",
"status": "cancelled",
"updated_at": "2026-09-27T10:20:11+00:00"
}
}
Any other status returns 400 CANNOT_CANCEL. To reverse a succeeded payment, issue a refund instead.
Refund a payment intent
POST /api/v1/payment-intents/{transaction_id}/refund
Refunds a succeeded payment on the Stripe account that processed it. Partial refunds can be repeated until the full amount is returned.
Request body
| Field | Type | Description |
|---|---|---|
| amount | number | Optional. Decimal major units. Omit to refund the full remaining balance. |
| reason | string | Optional. One of duplicate, fraudulent, requested_by_customer. |
curl -X POST http://orcapayz.com/api/v1/payment-intents/txn_9f2c1a7e3b…/refund \
-H "X-API-Key: $ORCA_KEY" \
-H "X-API-Secret: $ORCA_SECRET" \
-H "Content-Type: application/json" \
-d '{ "amount": 10.00, "reason": "requested_by_customer" }'
Response
{
"success": true,
"data": {
"transaction_id": "txn_9f2c1a7e3b…",
"refund_id": "re_3Q…",
"amount": 10,
"refunded_total": 10,
"status": "succeeded"
}
}
status becomes refunded once refunded_total equals the original amount. Failures return 400 REFUND_FAILED with Stripe's message. A charge.refunded webhook follows each successful refund.
Payment accounts
GET /api/v1/payment-accounts
Lists the Stripe accounts connected to your merchant account. Use id as payment_account_id when you want to bypass routing. Secret keys are never returned.
{
"success": true,
"data": [
{
"id": 3,
"name": "EU store",
"website": "https://yourstore.com",
"payment_domain": "pay.yourstore.com",
"domain_verified": true,
"is_active": true,
"priority": 1,
"weight_percentage": 60
},
{
"id": 4,
"name": "US store",
"website": "https://yourstore.com",
"payment_domain": "pay.yourstore.com",
"domain_verified": true,
"is_active": true,
"priority": 2,
"weight_percentage": 40
}
]
}
Accounts are managed in Dashboard, Payment accounts: keys, payment domain, daily and monthly limits, priority, weight and active state. An account can also carry an outbound proxy (SOCKS5 or HTTP); when set, every Stripe API call for that account is made through it, so Stripe only ever sees the proxy's address. Accounts without a proxy connect directly.
Routing
Routing applies to every payment intent that does not pass payment_account_id. The router first removes accounts that are inactive, have an unverified domain, or would exceed their daily or monthly limit with this amount, then picks among the rest by traffic split.
| Rule | Behaviour |
|---|---|
| Traffic split | Each active account has a target percentage (weight_percentage, edited in Dashboard, Payment accounts). The account furthest below its target share of today's successful payments gets the next one, so failed attempts never skew the distribution. Targets are normalised against the accounts that are eligible right now; with no weights set, traffic is shared equally. |
| Failover | Always on. If Stripe rejects the payment intent, the next eligible account by traffic split is tried in the same request. The response only fails with PROVIDER_ERROR when every eligible account rejected it. |
| Auto-pause | Optional. After N consecutive failed payments (declines and provider errors; a success resets the streak) the account is deactivated and a Telegram alert is sent. The last active account is never paused. Set N in Dashboard, Payment accounts, Failover. |
| Limits | Daily and monthly caps per account, counted from successful payments. An account at its cap is skipped until the day or month rolls over. |
Manual account selection
Pass payment_account_id in the create request to force a specific account. The router is skipped entirely, but the account must be yours and active; otherwise you get 403 INVALID_PAYMENT_ACCOUNT. Daily and monthly limits still apply and can return 400 ACCOUNT_LIMIT_EXCEEDED.
{
"amount": 120.00,
"currency": "eur",
"customer_email": "[email protected]",
"return_url": "https://yourstore.com/thank-you",
"payment_account_id": 3
}
Webhooks
OrcaPayz sends a POST with a JSON body to your account-level webhook URL (Dashboard, Webhooks) and, when set, to the per-transaction webhook_url. Delivery logs are visible in Dashboard, Webhook logs.
Events
payment_intent.succeeded: the payment was captured.payment_intent.payment_failed: the attempt failed;error_messageexplains why.payment_intent.canceled: the intent was cancelled via API or expired.charge.refunded: a full or partial refund settled; addsfully_refunded.ping: sent by the test button in the dashboard.
Payload
{
"event": "payment_intent.succeeded",
"data": {
"transaction_id": "txn_9f2c1a7e3b…",
"payment_intent_id": "pi_3Q…",
"amount": 49.9,
"refunded_amount": 0,
"currency": "usd",
"status": "succeeded",
"customer_email": "[email protected]",
"customer_name": "Jane Doe",
"description": "Order #10422",
"metadata": { "order_id": "10422" },
"error_message": null,
"paid_at": "2026-09-27T10:16:04+00:00",
"created_at": "2026-09-27T10:15:32+00:00"
},
"timestamp": "2026-09-27T10:16:05+00:00"
}
For charge.refunded the data object also contains fully_refunded (boolean) and an updated refunded_amount.
Delivery
- Respond with any
2xxwithin a few seconds. Do your heavy work asynchronously. - Retries are not automatic. If your endpoint was down, reconcile with
GET /api/v1/payment-intentsfiltered byfrom_date. - Events may occasionally arrive out of order. Use
statusandtimestamp, not arrival order. - Handlers should be idempotent on
transaction_id+event.
Verifying signatures
Every webhook carries an X-Orcapayz-Signature header: the lowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook secret from Dashboard, Webhooks. Compute the same digest over the exact bytes you received (before JSON parsing) and compare with a constant-time function.
X-Orcapayz-Signature: 3f1a9c…e2b7
Content-Type: application/json
User-Agent: OrcaPayz-Webhooks/1.0
<?php
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_ORCAPAYZ_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $payload, getenv('ORCA_WEBHOOK_SECRET'));
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($payload, true);
switch ($event['event']) {
case 'payment_intent.succeeded':
// Mark order $event['data']['metadata']['order_id'] as paid.
break;
case 'payment_intent.payment_failed':
// Notify the customer; see $event['data']['error_message'].
break;
case 'charge.refunded':
// $event['data']['refunded_amount'], $event['data']['fully_refunded']
break;
}
http_response_code(200);
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post(
'/webhooks/orcapayz',
express.raw({ type: 'application/json' }), // keep the raw body
(req, res) => {
const expected = crypto
.createHmac('sha256', process.env.ORCA_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const received = req.get('X-Orcapayz-Signature') || '';
const valid =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
if (event.event === 'payment_intent.succeeded') {
// Mark order event.data.metadata.order_id as paid.
}
res.sendStatus(200);
}
);
Code examples
Create a payment intent and redirect the shopper to the hosted checkout. Replace the environment variables with the key pair from your dashboard.
curl -X POST http://orcapayz.com/api/v1/payment-intents \
-H "X-API-Key: $ORCA_KEY" \
-H "X-API-Secret: $ORCA_SECRET" \
-H "Content-Type: application/json" \
-d '{
"amount": 49.90,
"currency": "usd",
"customer_email": "[email protected]",
"return_url": "https://yourstore.com/thank-you",
"metadata": { "order_id": "10422" }
}'
<?php
$ch = curl_init('http://orcapayz.com/api/v1/payment-intents');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('ORCA_KEY'),
'X-API-Secret: ' . getenv('ORCA_SECRET'),
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 49.90,
'currency' => 'usd',
'customer_email' => '[email protected]',
'return_url' => 'https://yourstore.com/thank-you',
'metadata' => ['order_id' => '10422'],
]),
]);
$json = json_decode(curl_exec($ch), true);
curl_close($ch);
if (! ($json['success'] ?? false)) {
throw new RuntimeException($json['message'] ?? 'OrcaPayz request failed');
}
// Store $json['data']['transaction_id'] against the order, then redirect.
header('Location: ' . $json['data']['checkout_url'], true, 303);
exit;
const response = await fetch('http://orcapayz.com/api/v1/payment-intents', {
method: 'POST',
headers: {
'X-API-Key': process.env.ORCA_KEY,
'X-API-Secret': process.env.ORCA_SECRET,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
amount: 49.90,
currency: 'usd',
customer_email: '[email protected]',
return_url: 'https://yourstore.com/thank-you',
metadata: { order_id: '10422' },
}),
});
const json = await response.json();
if (!json.success) {
throw new Error(json.message ?? 'OrcaPayz request failed');
}
// Store json.data.transaction_id against the order, then redirect.
res.redirect(303, json.data.checkout_url);
import os
import requests
response = requests.post(
"http://orcapayz.com/api/v1/payment-intents",
headers={
"X-API-Key": os.environ["ORCA_KEY"],
"X-API-Secret": os.environ["ORCA_SECRET"],
"Accept": "application/json",
},
json={
"amount": 49.90,
"currency": "usd",
"customer_email": "[email protected]",
"return_url": "https://yourstore.com/thank-you",
"metadata": {"order_id": "10422"},
},
timeout=15,
)
body = response.json()
if not body["success"]:
raise RuntimeError(body.get("message", "OrcaPayz request failed"))
# Store body["data"]["transaction_id"] against the order, then redirect.
checkout_url = body["data"]["checkout_url"]