Every request is signed with HMAC-SHA256 using the Merchant ID and Secret from the dashboard (Merchant List). The same secret signs webhooks (see 6.3).
stringToSign = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(rawBody)) signature = hex(HMAC-SHA256(secret, stringToSign))
METHOD — uppercase, eg: POSTPATH — without host and query string, eg: /v2/orders/listTIMESTAMP — value of X-Stripay-Timestamp, must be within 5 minutes of server timeNONCE — value of X-Stripay-Nonce, a reused nonce is rejected as a replayrawBody — the exact body string you send. Serialize once, hash and send the same stringAuthentication failures return 401:
{ "code": 401, "message": "Invalid signature" } // or "Invalid mid" / "Request expired" / "Replayed request"
Node.js:
const crypto = require('crypto')
const MID = 'YOUR_MERCHANT_ID'
const SECRET = 'YOUR_SECRET'
async function stripayRequest (path, payload) {
const body = JSON.stringify(payload)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = crypto.randomUUID()
const bodyHash = crypto.createHash('sha256').update(body).digest('hex')
const signature = crypto.createHmac('sha256', SECRET)
.update(['POST', path, timestamp, nonce, bodyHash].join('\n'))
.digest('hex')
const res = await fetch('https://api.stripay.io' + path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Stripay-Mid': MID,
'X-Stripay-Timestamp': timestamp,
'X-Stripay-Nonce': nonce,
'X-Stripay-Signature': signature
},
body
})
return res.json()
}
stripayRequest('/v2/orders/get', { orderId: 1 })
PHP:
function stripay_request($path, $payload) {
$mid = 'YOUR_MERCHANT_ID';
$secret = 'YOUR_SECRET';
$body = json_encode($payload);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$signature = hash_hmac('sha256', implode("\n", ['POST', $path, $timestamp, $nonce, hash('sha256', $body)]), $secret);
$ch = curl_init('https://api.stripay.io' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Stripay-Mid: ' . $mid,
'X-Stripay-Timestamp: ' . $timestamp,
'X-Stripay-Nonce: ' . $nonce,
'X-Stripay-Signature: ' . $signature,
],
]);
$res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true);
}
Every response uses the same JSON envelope, and the HTTP status equals code.
{ "code": 200, "data": { ... } } // success
{ "code": 400, "message": "Invalid amount" } // failure
code === 200 before reading data. Response tables below describe the success case only.200 means the order was created, not that it was paid. See 2.8.All endpoints take a signed JSON POST (see 1) and return the same response (see 2.8).
POST https://api.stripay.io/v2/direct/pay
Request body:
POST https://api.stripay.io/v2/inline/pay
Request body:
Load data.redirectUrl as the src of an iframe on your checkout page. The iframe renders the card form, and your page drives it with postMessage:
iframeElement.contentWindow.postMessage({ action: 'pay' })
window.addEventListener('message', (e) => {
if (e.data.sender === 'stripay') {
if (e.data.action === 'pay_success') {
location.href = e.data.value // successUrl
} else if (e.data.action === 'pay_fail') {
errorElement.innerHTML = e.data.value // error message
} else if (e.data.action === 'pay_3ds') {
location.href = e.data.value // 3ds url
} else if (e.data.action === 'pay_validate_error') {
errorElement.innerHTML = e.data.value // error message
} else if (e.data.action === 'height') {
iframeElement.style.setProperty('height', e.data.value + 'px')
}
}
})
POST https://api.stripay.io/v2/checkout/pay
Request body:
POST https://api.stripay.io/v2/paypal/pay
Request body:
POST https://api.stripay.io/v2/applepay/pay
Request body:
POST https://api.stripay.io/v2/googlepay/pay
Request body:
POST https://api.stripay.io/v2/cashapp/pay
Request body:
Cash App Pay supports USD only. Other currencies are converted to USD at the current rate, and the order, callbacks and webhooks all use the converted amount / currency.
The HTTP code tells you whether a Stripay order exists:
{ code, message } — the request failed before an order was created (invalid parameters, auth/permission error, rate limit, no account available, etc.). Nothing to reconcile.{ code, data: Order } — the order was created, whether or not the PSP accepted it. Use data.status and data.redirectUrl to decide the next step:| data.status | data.redirectUrl | Meaning / next step |
|---|---|---|
pending | url | Redirect the customer (Checkout / PayPal / wallets), or load it in an iframe (Inline) |
3ds | url | Direct: 3DS required, so redirect the customer to verify |
completed | null | Direct: paid, so show your success page |
pending | null | Direct: still processing at the PSP, so wait for the webhook or poll 3.2 |
rejected / 3dsFail / canceled | null | Declined by risk control or the PSP (eg: card declined, amount too small). Show data.statusNote |
data.redirectUrl is set, send the customer there. Otherwise, act on data.status.statusNote is always Your payment was declined.returnUrl on both success and failure. Use the webhook or 3.2 for the final status.POST https://api.stripay.io/v2/checkout/pay
Request headers (see 1):
Content-Type: application/json X-Stripay-Mid: 1001 X-Stripay-Timestamp: 1761955200 X-Stripay-Nonce: 3f1c9a52-7b0e-4d0a-9a7e-2f5b8c6d1e40 X-Stripay-Signature: 9c6f0e3b... (64 hex chars)
Request body:
{
"merchantOrderId": "1",
"amount": 9.99,
"returnUrl": "https://stripay.io/return.html",
"ip": "38.94.109.229",
"email": "test@stripay.io"
}
Order created, so redirect the customer to data.redirectUrl:
{
"code": 200,
"data": {
"id": 1,
"merchantOrderId": "1",
"amount": 9.99,
"currency": "USD",
"originalAmount": 9.99,
"originalCurrency": "USD",
"riskScore": 0,
"status": "pending",
"statusCode": null,
"statusNote": null,
"createdAt": "2023-02-14T12:39:13.000Z",
"updatedAt": "2023-02-14T12:39:13.000Z",
"redirectUrl": "https://checkout.stripe.com/c/pay/cs_test_xxx"
}
}
Order created but rejected by the PSP, so show statusNote:
{
"code": 200,
"data": {
"id": 2,
"merchantOrderId": "2",
"amount": 9.99,
"currency": "USD",
"originalAmount": 9.99,
"originalCurrency": "USD",
"riskScore": 0,
"status": "rejected",
"statusCode": "card_declined",
"statusNote": "Your card was declined.",
"createdAt": "2023-02-14T12:40:13.000Z",
"updatedAt": "2023-02-14T12:40:14.000Z",
"redirectUrl": null
}
}
Direct (2.1) results, other fields as above:
{ "code": 200, "data": { "id": 3, "status": "completed", "redirectUrl": null, ... } } // paid
{ "code": 200, "data": { "id": 4, "status": "3ds", "redirectUrl": "https://...", ... } } // redirect for 3DS
{ "code": 200, "data": { "id": 5, "status": "rejected", "statusCode": "card_declined", "statusNote": "Your card was declined.", "redirectUrl": null, ... } }
No order created (4xx):
{ "code": 400, "message": "Invalid amount" }
{ "code": 401, "message": "Invalid signature" }
{ "code": 403, "message": "The merchant is offline" }
POST https://api.stripay.io/v2/orders/list
Request body:
Response body:
Response example:
{
"code": 200,
"data": {
"total": 90,
"orders": [{
"id": 1,
"merchantOrderId": "your order id",
"amount": 9.99,
"currency": "USD",
"originalAmount": 9.99,
"originalCurrency": "USD",
"riskScore": 65,
"status": "completed",
"statusCode": null,
"statusNote": null,
"createdAt": "2023-02-14T12:39:13.000Z",
"updatedAt": "2023-02-14T12:39:21.000Z"
}]
}
}
POST https://api.stripay.io/v2/orders/get
Request body:
Response body:
Response example:
{
"code": 200,
"data": {
"id": 1,
"merchantOrderId": "your order id",
"amount": 9.99,
"currency": "USD",
"originalAmount": 9.99,
"originalCurrency": "USD",
"riskScore": 0,
"status": "rejected",
"statusCode": "insufficient_funds",
"statusNote": "Your card has insufficient funds.",
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:43:37.000Z"
}
}
Failed response (order not found):
{ "code": 400, "message": "Order not found" }
POST https://api.stripay.io/v2/refunds/create
Request header (optional):
Idempotency-Key: up to 64 chars of [A-Za-z0-9_-]. A repeated key returns the first result instead of refunding twice.
Request body:
amount for a full refund of what remains.pending / completed, see 4.4). 400 means the refund failed, including a synchronous PSP rejection, with the reason in message.Response body:
Response example:
{
"code": 200,
"data": {
"id": 1, // refund id, poll its status via /v2/refunds/get
"orderId": 1,
"merchantOrderId": "your order id",
"amount": 0.5,
"currency": "USD",
"status": "pending",
"statusCode": null, // PSP error code when the refund fails
"statusNote": null,
"reason": null,
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:39:53.000Z",
"refundedAt": null
}
}
Failed response:
{ "code": 400, "message": "The refund amount should not be greater than the refundable amount" }
Example request (partial refund of an order):
{
"orderId": 1,
"amount": 0.5
}
POST https://api.stripay.io/v2/refunds/list
Request body:
Response body:
Response example:
{
"code": 200,
"data": {
"total": 2,
"refunds": [{
"id": 2,
"orderId": 1,
"merchantOrderId": "your order id",
"amount": 0.5,
"currency": "USD",
"status": "completed",
"statusCode": null,
"statusNote": null,
"reason": "requested_by_customer",
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:40:21.000Z",
"refundedAt": "2023-01-10T03:40:21.000Z"
}]
}
}
POST https://api.stripay.io/v2/refunds/get
Request body:
Response body:
Response example:
{
"code": 200,
"data": {
"id": 2,
"orderId": 1,
"merchantOrderId": "your order id",
"amount": 0.5,
"currency": "USD",
"status": "completed",
"statusCode": null,
"statusNote": null,
"reason": "requested_by_customer",
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:40:21.000Z",
"refundedAt": "2023-01-10T03:40:21.000Z"
}
}
A new refund starts as pending and the order moves to refunding, then to partiallyRefunded / refunded on completion. Some PSPs (eg: Stripe, Square) refund asynchronously, so the final status may take a few minutes to arrive by webhook.
Webhooks (see 6.1): refund.created → refund.completed or refund.failed.
Disputes (chargebacks) are opened by the card issuer or PayPal and synced automatically. They are read-only via the API.
POST https://api.stripay.io/v2/disputes/list
Request body:
Response body:
Response example:
{
"code": 200,
"data": {
"total": 1,
"disputes": [{
"id": 1,
"orderId": 1,
"merchantOrderId": "your order id",
"amount": 9.99,
"currency": "USD",
"status": "open",
"reason": "product_not_received",
"dueAt": "2023-01-20T03:39:53.000Z", // deadline to submit evidence / respond
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:39:53.000Z",
"closedAt": null
}]
}
}
POST https://api.stripay.io/v2/disputes/get
Request body:
Response body:
Response example:
{
"code": 200,
"data": {
"id": 1,
"orderId": 1,
"merchantOrderId": "your order id",
"amount": 9.99,
"currency": "USD",
"status": "open",
"reason": "product_not_received",
"dueAt": "2023-01-20T03:39:53.000Z", // deadline to submit evidence / respond
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:39:53.000Z",
"closedAt": null
}
}
If the order has a notifyUrl, Stripay POSTs JSON to it when the order is paid, a refund or dispute changes, or an RDR / Ethoca alert is handled. Headers (see 6.3):
Payload:
Order paid:
{
"eventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"eventType": "order.completed",
"data": {
"order": {
"id": 1,
"merchantOrderId": "your order id",
"amount": 9.99,
"currency": "USD",
"originalAmount": 9.99,
"originalCurrency": "USD",
"riskScore": 0,
"status": "completed",
"statusCode": null,
"statusNote": null,
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:43:37.000Z"
}
},
"createdAt": "2023-01-10T03:43:37.000Z",
"updatedAt": "2023-01-10T03:43:37.000Z"
}
Refund succeeded:
{
"eventId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"eventType": "refund.completed",
"data": {
"refund": {
"id": 2,
"orderId": 1,
"merchantOrderId": "your order id",
"amount": 0.5,
"currency": "USD",
"status": "completed",
"statusCode": null,
"statusNote": null,
"reason": "requested_by_customer",
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:40:21.000Z",
"refundedAt": "2023-01-10T03:40:21.000Z"
}
},
"createdAt": "2023-01-10T03:40:21.000Z",
"updatedAt": "2023-01-10T03:40:21.000Z"
}
Dispute opened:
{
"eventId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"eventType": "dispute.created",
"data": {
"dispute": {
"id": 1,
"orderId": 1,
"merchantOrderId": "your order id",
"amount": 9.99,
"currency": "USD",
"status": "open",
"reason": "product_not_received",
"dueAt": "2023-01-20T03:39:53.000Z",
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:39:53.000Z",
"closedAt": null
}
},
"createdAt": "2023-01-10T03:39:53.000Z",
"updatedAt": "2023-01-10T03:39:53.000Z"
}
Respond with the plain string OK. Anything else counts as a failure and is retried up to 9 times (5min, 15min, 30min, 1h, 3h, 6h, 12h, 24h, 24h), about 3 days in total.
Webhooks are signed exactly like API requests (see 1.2), so you can reuse your signing function:
stringToSign = "POST" + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(rawBody)) signature = hex(HMAC-SHA256(secret, stringToSign))
PATH — path of the notifyUrl you passed, eg: https://example.com/stripay/notify?token=1 → /stripay/notify. Don't read it from the incoming request, because proxies may rewrite itTIMESTAMP / NONCE — values of X-Stripay-Timestamp / X-Stripay-NonceX-Stripay-Mid — use it to pick the secret if several merchants share one notifyUrlWhen verifying:
eventId (retries reuse it with a new timestamp/nonce/signature) and return OK for events you've already handledNode.js (Express):
const crypto = require('crypto')
const NOTIFY_PATH = '/stripay/notify' // path of your notifyUrl
app.post(NOTIFY_PATH, express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8')
const timestamp = req.get('X-Stripay-Timestamp') || ''
const nonce = req.get('X-Stripay-Nonce') || ''
const signature = req.get('X-Stripay-Signature') || ''
const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex')
const expected = crypto.createHmac('sha256', SECRET)
.update(['POST', NOTIFY_PATH, timestamp, nonce, bodyHash].join('\n'))
.digest('hex')
if (signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) ||
Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('Invalid signature')
}
const event = JSON.parse(rawBody)
// deduplicate by event.eventId, then handle event.eventType / event.data
res.send('OK')
})
PHP:
$notifyPath = '/stripay/notify'; // path of your notifyUrl
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_STRIPAY_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_X_STRIPAY_NONCE'] ?? '';
$signature = $_SERVER['HTTP_X_STRIPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', implode("\n", ['POST', $notifyPath, $timestamp, $nonce, hash('sha256', $rawBody)]), $secret);
if (!hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
// deduplicate by $event['eventId'], then handle $event['eventType'] / $event['data']
echo 'OK';
Test account and test credit card reference: