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 routing strategy 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/jsonKeys 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.
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). |
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": "jane@example.com",
"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=succeededtransaction_id— the OrcaPayz transaction.status— our status at redirect time:succeeded,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": "jane@example.com",
"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": "jane@example.com",
"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.
Routing strategies
The strategy is set once per merchant in Dashboard → Payment accounts and applies to every payment intent that does not pass payment_account_id. Before the strategy runs, the router removes accounts that are inactive, have an unverified domain, or would exceed their daily or monthly limit with this amount.
| Strategy | Key | Behaviour |
|---|---|---|
| Weighted Round RobinRecommended | weighted_round_robin | Distributes by target percentage. Only successful payments count. |
| Round Robin | round_robin | Cycles through accounts in order. |
| Weighted | weighted | Priority plus success rate. Prefers reliable accounts. |
| Least Used | least_used | Routes to the account with the lowest usage. |
| Failover | failover | Primary first, then secondaries. |
| Random | random | Random selection among eligible accounts. |
Weighted Round Robin uses each account's weight_percentage as a target share and counts only succeeded payments, so failed attempts do not skew distribution. Failover orders accounts by priority (lowest number first).
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": "jane@example.com",
"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": "jane@example.com",
"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": "jane@example.com",
"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' => 'jane@example.com',
'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: 'jane@example.com',
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": "jane@example.com",
"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"]Ready to make your first call?
Sign up, connect a Stripe account and create an API key pair. 7-day trial, no card required.