Stripay API Documentation

1 Authentication

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).

  • Keep the secret server-side only, never in browser/app code, URLs or logs.
  • If it leaks, reset it in the dashboard. The old secret stops working immediately.

1.1 Request Headers

1.2 Sign a Request

stringToSign = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(SHA256(rawBody))
signature    = hex(HMAC-SHA256(secret, stringToSign))
  • METHOD — uppercase, eg: POST
  • PATH — without host and query string, eg: /v2/orders/list
  • TIMESTAMP — value of X-Stripay-Timestamp, must be within 5 minutes of server time
  • NONCE — value of X-Stripay-Nonce, a reused nonce is rejected as a replay
  • rawBody — the exact body string you send. Serialize once, hash and send the same string

Authentication failures return 401:

{ "code": 401, "message": "Invalid signature" }  // or "Invalid mid" / "Request expired" / "Replayed request"

1.3 Example

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);
}

1.4 Response Format

Every response uses the same JSON envelope, and the HTTP status equals code.

{ "code": 200, "data": { ... } }          // success
{ "code": 400, "message": "Invalid amount" } // failure
  • Check code === 200 before reading data. Response tables below describe the success case only.
  • Create Order: 200 means the order was created, not that it was paid. See 2.8.
  • Invalid JSON or a body over 1MB is rejected with a plain-text body, so check the HTTP status before parsing.

2 Create Order

All endpoints take a signed JSON POST (see 1) and return the same response (see 2.8).

2.1 Create Credit Card Order (Direct)

POST https://api.stripay.io/v2/direct/pay

Request body:

2.2 Create Credit Card Order (Inline)

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:

Trigger submit payment

iframeElement.contentWindow.postMessage({ action: 'pay' })

Listen iframe events

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')
    }
  }
})

2.3 Create Credit Card Order (Checkout)

POST https://api.stripay.io/v2/checkout/pay

Request body:

2.4 Create PayPal Order

POST https://api.stripay.io/v2/paypal/pay

Request body:

2.5 Create Apple Pay Order

POST https://api.stripay.io/v2/applepay/pay

Request body:

2.6 Create Google Pay Order

POST https://api.stripay.io/v2/googlepay/pay

Request body:

2.7 Create Cash App Pay Order

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.

2.8 Create Order Response

The HTTP code tells you whether a Stripay order exists:

  • 4xx { code, message } — the request failed before an order was created (invalid parameters, auth/permission error, rate limit, no account available, etc.). Nothing to reconcile.
  • 200 { 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.statusdata.redirectUrlMeaning / next step
pendingurlRedirect the customer (Checkout / PayPal / wallets), or load it in an iframe (Inline)
3dsurlDirect: 3DS required, so redirect the customer to verify
completednullDirect: paid, so show your success page
pendingnullDirect: still processing at the PSP, so wait for the webhook or poll 3.2
rejected / 3dsFail / cancelednullDeclined by risk control or the PSP (eg: card declined, amount too small). Show data.statusNote
  • Rule of thumb: if data.redirectUrl is set, send the customer there. Otherwise, act on data.status.
  • On a risk-control rejection, statusNote is always Your payment was declined.
  • The customer returns to returnUrl on both success and failure. Use the webhook or 3.2 for the final status.

2.9 Example

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" }

3 Get Order

3.1 Get Multiple Orders

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"
    }]
  }
}

3.2 Get One Order

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" }

3.3 Order Status List

  • pending — Order created, not paid
  • rejected — Order rejected (risk control triggered or payment declined)
  • 3ds — 3ds triggered, user did not complete verification
  • 3dsSuccess — 3ds verification succeeded, payment failed (insufficient funds, expired card, bank rejection, etc.)
  • 3dsFail — 3ds triggered, verification failed
  • completed — Order paid
  • canceled — Order canceled
  • expired — Order expired
  • refunding — Order refunding
  • partiallyRefunded — Order partially refunded
  • refunded — Order refunded
  • disputed — Order disputed (chargeback received)
  • disputeWon — Dispute won (chargeback reversed)
  • disputeLost — Dispute lost

4 Refund

4.1 Create a Refund

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:

  • Partial refunds are supported, and the total cannot exceed the order amount. Omit amount for a full refund of what remains.
  • 200 returns the refund record (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
}

4.2 Get Multiple Refunds

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"
    }]
  }
}

4.3 Get One Refund

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"
  }
}

4.4 Refund Status Flow

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.

  • pending — Processing
  • 3ds — Customer action required (Stripe only), waiting for webhook
  • completed — Succeeded
  • rejected — Failed
  • canceled — Canceled (eg: timed out with unknown result)

Webhooks (see 6.1): refund.created → refund.completed or refund.failed.

5 Dispute

Disputes (chargebacks) are opened by the card issuer or PayPal and synced automatically. They are read-only via the API.

5.1 Get Multiple Disputes

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
    }]
  }
}

5.2 Get One Dispute

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
  }
}

5.3 Dispute Status List

  • open — Dispute open (evidence required / under review)
  • won — Merchant won the dispute (chargeback reversed)
  • lost — Customer won the dispute / merchant accepted liability

6 Webhook

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:

6.1 Event Types

6.2 Payload Example

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.

6.3 Verify Sign

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 it
  • TIMESTAMP / NONCE — values of X-Stripay-Timestamp / X-Stripay-Nonce
  • X-Stripay-Mid — use it to pick the secret if several merchants share one notifyUrl

When verifying:

  • Hash the raw body before JSON parsing
  • Compare signatures in constant time
  • Reject timestamps more than 5 minutes off
  • Deduplicate by eventId (retries reuse it with a new timestamp/nonce/signature) and return OK for events you've already handled

Node.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';

7 Testing

Test account and test credit card reference: