aptcloud Pay API · v1

Take payments from any app

Create an invoice with one request, send your customer to a secure hosted payment page, and get a signed callback when they've paid. Powered by Stripe.

Overview

aptcloud Pay handles one-time payments for any aptcloud service. Your app never touches card details. Payment happens on our hosted page, built on Stripe, which supports cards, Apple Pay, Google Pay and Link.

  1. Authenticate. Exchange your app's client credentials for a short-lived access token (OAuth 2.0).
  2. Create an invoice. POST /invoices returns a pay_url.
  3. Redirect the customer to pay_url. When they've paid, they come back to your success_url.
  4. Fulfil on the webhook. We send a signed invoice.paid event to your callback URL.
Base URL
https://aptcloud.com/wp-json/aptcloud-pay/v1
Token endpoint
https://aptcloud.com/wp-json/aptcloud-pay/v1/oauth/token
Format
JSON over HTTPS. Errors use RFC 9457 Problem Details.
Discovery
OAuth metadata · JWKS

Quickstart

1. Get credentials

An aptcloud administrator creates your app in WP admin → aptcloud Pay → Apps & credentials. You receive three test-mode values:

  • a client ID (cli_test_…)
  • a client secret (cs_test_…)
  • a webhook signing secret (whsec_…)

When you're ready to take real payments, you get a separate live set. See Test and live mode. The administrator also records which hosts your customers may be sent back to. Store all values as server-side secrets, for example in environment variables. Never put them in browser or mobile code.

2. Get an access token

curl -X POST https://aptcloud.com/wp-json/aptcloud-pay/v1/oauth/token \
  -u "$APTPAY_CLIENT_ID:$APTPAY_CLIENT_SECRET" \
  -d grant_type=client_credentials
{
  "access_token": "eyJhbGciOiJFZERTQSIsInR5cCI6ImF0K2p3dCIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "invoices:read invoices:write refunds:write"
}

3. Create an invoice

curl -X POST https://aptcloud.com/wp-json/aptcloud-pay/v1/invoices \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{
    "reference": "order-1042",
    "currency": "usd",
    "items": [
      { "description": "Pro plan (12 months)", "quantity": 1, "unit_amount": 9900 },
      { "description": "Extra seat", "quantity": 2, "unit_amount": 1500 }
    ],
    "customer": { "email": "jane@example.com", "name": "Jane Doe" },
    "success_url": "https://app.example.com/billing/done",
    "cancel_url": "https://app.example.com/billing"
  }'

4. Redirect, then fulfil on the webhook

Send the customer to pay_url. When invoice.paid arrives at your callback URL, mark the order paid. See Webhooks for how to verify it.

Authentication

The API uses the OAuth 2.0 client credentials grant (RFC 6749 §4.4). Your server exchanges its client ID and secret for an access token, then sends that token with each request:

Authorization: Bearer eyJhbGciOiJFZERTQSIsInR5cCI6ImF0K2p3dCIs…
  • Client authentication. Send credentials with HTTP Basic auth (client_secret_basic, preferred) or as client_id and client_secret form fields (client_secret_post).
  • Token lifetime. Tokens last one hour (expires_in). Cache the token and reuse it until shortly before it expires. Don't request a new token for every API call: the token endpoint allows 30 requests a minute per IP.
  • Token format. Tokens are JWTs (RFC 9068) signed with Ed25519. Treat them as opaque strings; you don't need to decode them. Our public keys are published at /.well-known/jwks.json.
  • Revocation. Rotating the client secret, or disabling the app, immediately invalidates every token issued before.

Scopes

Each app is granted a set of scopes. A token gets all of them unless you ask for fewer with scope=invoices:read. Use the narrowest scope a component needs; for example, a reporting job only needs invoices:read.

ScopeAllows
invoices:readRead invoices
invoices:writeCreate and cancel invoices
refunds:writeRefund paid invoices

Token errors

The token endpoint answers with standard OAuth error responses: {"error":"invalid_client","error_description":"…"}. Possible errors:

  • invalid_client (401): wrong credentials, or the app is disabled.
  • invalid_scope (400): you asked for a scope the app wasn't granted.
  • unsupported_grant_type (400): only client_credentials is supported.
  • slow_down (429): too many token requests.

On the other endpoints, a missing or invalid token returns 401 with a WWW-Authenticate: Bearer header. A token without the needed scope returns 403 insufficient_scope.

Test and live mode

Every app has two separate sets of credentials, and the set you authenticate with decides the mode of everything you do. The base URL is the same for both.

Test modeLive mode
Client IDcli_test_…cli_live_…
Client secretcs_test_…cs_live_…
Webhook secretits own whsec_…its own whsec_…
MoneyNone. Stripe test cards only.Real charges.
Invoices"livemode": false"livemode": true
  • Fully separate. A test token can't see, pay, cancel or refund live invoices, and the reverse. Each token's response includes "livemode".
  • Getting credentials. New apps get test credentials first. An administrator issues live credentials when your integration is ready.
  • Configuration. Keep test and live credentials in separate environments, for example staging and production. The only change between them is the three secrets; the code stays the same.
  • Callbacks. Every callback says livemode and is signed with that mode's webhook secret. If one callback URL receives both, check the signature with the secret that matches livemode.
  • Idempotency keys are scoped per mode, so reusing order-1042 in test and in live is fine.

Making requests

Amounts and currency

Amounts are integers in the currency's smallest unit: 1999 means $19.99. Zero-decimal currencies such as JPY use whole units. currency is a lowercase 3-letter ISO code and defaults to usd.

Idempotency

Network errors happen. Send an Idempotency-Key header (your order ID or a UUID) on POST /invoices and POST /invoices/{id}/refund. If you retry with the same key, you get the original result back, with 200 and Idempotent-Replayed: true, instead of a second invoice or a double refund.

Rate limits

Each app can make 120 requests per minute. Every response includes these headers:

  • RateLimit-Limit: requests allowed per minute.
  • RateLimit-Remaining: requests left in the current window.
  • RateLimit-Reset: seconds until the window resets.

Over the limit you get 429 with Retry-After. Back off for that many seconds.

Versioning

The version is in the path (/v1). Within v1 we only make additive changes: new endpoints, new optional fields and new event types. Ignore fields you don't recognise.

Invoices

An invoice is a fixed amount a customer owes your app. It holds one or more line items and is paid once.

Lifecycle

openpaidpartially_refundedrefunded

An open invoice can also become canceled (you canceled it), expired (it passed expires_at unpaid; the default is 7 days) or blocked (locked after too many failed payment attempts; see Fraud protection).

Create an invoice: POST /invoices

Scope: invoices:write. Returns 201 Created with the invoice and a Location header.

FieldTypeDescription
items required*array1–100 lines, each with description (≤250 chars), quantity (1–10000, default 1) and unit_amount. The total is computed for you.
amountinteger*Instead of items: a single line labelled with description.
currencystringDefault usd.
referencestringYour order ID (≤100 chars). You can filter by it.
descriptionstring≤500 chars. Shown on the payment page and in Stripe.
customer.email, customer.namestringPre-fills the payment form. The email also receives Stripe's receipt.
success_url, cancel_urlURLhttps only, and on one of your app's registered return hosts.
metadataobjectUp to 20 string values for your own use. Also copied to the Stripe payment.
expires_inintegerSeconds, from 300 to 2592000 (30 days). Default 604800 (7 days).
branding.logo_urlURLYour logo at the top of the payment page (https). Defaults to the logo set for your app. See Branding.
branding.header_htmlstringShort HTML text shown under the logo. Defaults to your app's text.

The invoice object

{
  "id": "inv_5c1f0e9a2b7d4e6f8a9b0c1d",
  "object": "invoice",
  "number": "INV-000042",
  "status": "paid",
  "reference": "order-1042",
  "currency": "usd",
  "amount_total": 12900,
  "amount_refunded": 0,
  "description": "",
  "items": [
    { "description": "Pro plan (12 months)", "quantity": 1, "unit_amount": 9900, "amount": 9900 },
    { "description": "Extra seat", "quantity": 2, "unit_amount": 1500, "amount": 3000 }
  ],
  "customer": { "email": "jane@example.com", "name": "Jane Doe" },
  "metadata": {},
  "pay_url": "https://aptcloud.com/pay/inv_5c1f0e9a2b7d4e6f8a9b0c1d/",
  "success_url": "https://app.example.com/billing/done",
  "cancel_url": "https://app.example.com/billing",
  "payment": { "payment_intent": "pi_3Q…", "method": "Visa •••• 4242", "receipt_url": "https://pay.stripe.com/receipts/…" },
  "livemode": false,
  "created_at": "2026-10-14T15:02:11+00:00",
  "expires_at": "2026-10-21T15:02:11+00:00",
  "paid_at": "2026-10-14T15:04:52+00:00"
}

Retrieve and list

GET /invoices/{id} returns one invoice. GET /invoices?reference=order-1042&status=paid&limit=20&page=1 lists your app's invoices, newest first, as {"object":"list","data":[…],"page":1,"has_more":false}. Both need scope invoices:read. You only ever see your own app's invoices.

Cancel: POST /invoices/{id}/cancel

Scope: invoices:write. Only works on open invoices. If the customer is mid-payment or has already paid, you get 409 invalid_state; the invoice is updated to match.

Refund: POST /invoices/{id}/refund

Scope: refunds:write. Send {"amount": 500} for a partial refund, or an empty body to refund everything not yet refunded. You can refund several times until the full amount is returned. Use an Idempotency-Key.

curl -X POST https://aptcloud.com/wp-json/aptcloud-pay/v1/invoices/inv_5c1f0e9a2b7d4e6f8a9b0c1d/refund \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: refund-order-1042-1" \
  -d '{ "amount": 1500 }'

The payment page

pay_url is a secure page on aptcloud.com. It shows your app's name, the line items and total, and Stripe's payment form. 3-D Secure and other bank checks happen there automatically.

  • After paying, the customer is redirected to success_url?invoice=inv_…&reference=order-1042&status=paid.
  • Treat the redirect as a hint, not proof. Customers can close the tab or forge a URL. Fulfil on the invoice.paid webhook, or call GET /invoices/{id} to confirm the status.
  • A "Back to your app" link goes to cancel_url. The invoice stays open and can be paid later until it expires.
  • After payment, pay_url becomes a printable receipt, so it's safe to link from your app's billing history.

Branding

The top of the payment page and receipt shows your logo and a short HTML text, for example a thank-you note or a support contact. Set defaults for your app in admin (Apps & credentials). You can also override them per invoice:

"branding": {
  "logo_url": "https://app.example.com/img/logo.png",
  "header_html": "<p><strong>Thanks for your order!</strong></p><p>Questions? <a href=\"mailto:billing@example.com\">billing@example.com</a></p>"
}
  • Logo: an https URL. It's shown up to 220×56 px; a wide PNG or SVG with a transparent background works best in both light and dark mode. If you set no logo, your app's name is shown instead.
  • Text: up to 5000 characters. Allowed tags are p, br, strong, em, b, i, u, small, span, h2–h4, ul, ol, li and a href. Everything else is removed, including scripts, styles, images and attributes. Links open in a new tab.
  • The page always shows a small "Powered by aptcloud" mark in the footer.

Native mobile apps

An iOS, Android or React Native app has two ways to take payment:

  • Open pay_url in the system browser sheet: SFSafariViewController on iOS, Chrome Custom Tabs on Android. This needs no extra work, and Apple Pay and Google Pay work there. Don't use an embedded WebView.
  • Show Stripe's native PaymentSheet in the app. This gives the best experience: native card entry, Apple Pay, Google Pay and Link. Your backend asks us for PaymentSheet parameters for an invoice, and the app presents the sheet with Stripe's SDK.
AppYour backendPOST /invoices + /payment-sheetApp shows PaymentSheetinvoice.paid to your backend

Never put your client ID or secret in an app. App binaries can be unpacked. The app should talk only to your own backend. Your backend holds the credentials and passes the app just the PaymentSheet fields.

Get PaymentSheet parameters: POST /invoices/{id}/payment-sheet

Scope: invoices:write. Works on open invoices only, and it's safe to call again (for example, if the customer closes and reopens the sheet). The payment is the same one the hosted page uses, so the invoice.paid callback, refunds and receipts all work unchanged.

curl -X POST https://aptcloud.com/wp-json/aptcloud-pay/v1/invoices/inv_5c1f0e9a2b7d4e6f8a9b0c1d/payment-sheet \
  -H "Authorization: Bearer $TOKEN"

Return these fields from your backend to the app, then present the sheet:

// Swift Package: https://github.com/stripe/stripe-ios  →  StripePaymentSheet
import StripePaymentSheet

func pay(_ sheet: PaymentSheetParams, from viewController: UIViewController) {
    STPAPIClient.shared.publishableKey = sheet.publishableKey

    var config = PaymentSheet.Configuration()
    config.merchantDisplayName = sheet.merchantDisplayName
    config.applePay = .init(merchantId: "merchant.com.example.yourapp",   // your Apple Merchant ID
                            merchantCountryCode: sheet.merchantCountry)
    config.allowsDelayedPaymentMethods = true          // e.g. bank debits that settle later
    config.returnURL = "yourapp://stripe-redirect"      // for methods that bounce through a browser

    let paymentSheet = PaymentSheet(paymentIntentClientSecret: sheet.clientSecret, configuration: config)
    paymentSheet.present(from: viewController) { result in
        switch result {
        case .completed:
            // Show "Thanks — payment received". Your backend fulfils on the invoice.paid webhook.
            break
        case .canceled:
            break
        case .failed(let error):
            print("Payment failed: \(error.localizedDescription)")
        }
    }
}

Apple Pay and Google Pay setup

  • Apple Pay (iOS). Create an Apple Merchant ID in your Apple Developer account and turn on the Apple Pay capability in Xcode. Then ask an aptcloud administrator to add an Apple Pay certificate for that Merchant ID in the Stripe dashboard (Settings → Payment methods → Apple Pay). Without the certificate the Apple Pay button won't appear.
  • Google Pay (Android). Add <meta-data android:name="com.google.android.gms.wallet.api.enabled" android:value="true" /> to your manifest. Use the test environment while livemode is false.

After the sheet closes

  • completed means Stripe accepted the payment. Some methods, such as bank debits, still settle later. Fulfil only when your backend receives invoice.paid, or confirm with GET /invoices/{id}.
  • If the invoice was paid elsewhere, canceled or has expired, /payment-sheet returns 409 invalid_state.

Webhooks

When an invoice changes state, we POST an event to your app's callback URL. Events are signed per the Standard Webhooks specification, so you can verify them with its official libraries in most languages, or with a few lines of code.

EventWhen
invoice.paidThe customer paid. Fulfil the order.
invoice.refundedA full or partial refund was made. Check amount_refunded and status. If payment.fraud_warning is set, the refund was automatic because of reported fraud: revoke what was purchased.
invoice.canceledThe invoice was canceled.
invoice.expiredThe invoice reached expires_at unpaid.
invoice.blockedThe invoice was locked after too many failed payment attempts.
invoice.disputedThe customer disputed the payment with their bank. Pause or revoke what was purchased; see dispute.
invoice.dispute_closedThe dispute ended: dispute.status is won (you keep the money) or lost (the customer got it back).
pingSent by the "Send test callback" button in admin.
POST /webhooks/aptcloud-pay HTTP/1.1
Content-Type: application/json
webhook-id: evt_9f3c2a7d1b6e4f08a2c5d9e1
webhook-timestamp: 1760454292
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id":"evt_9f3c2a7d1b6e4f08a2c5d9e1","type":"invoice.paid","created":1760454292,"livemode":false,"data":{"object":{"id":"inv_5c1f0e9a2b7d4e6f8a9b0c1d","status":"paid","reference":"order-1042", …}}}

Verifying signatures

The signature is v1, followed by the base64 of HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}"). The key is the base64-decoded part of your whsec_… secret after the prefix. To verify an event:

  • Use the raw request body, before any JSON parsing.
  • Compare signatures in constant time.
  • Reject timestamps more than 5 minutes old.
function aptpay_verify(string $body, array $headers, string $secret): bool {
    $id  = $headers['webhook-id'] ?? '';
    $ts  = $headers['webhook-timestamp'] ?? '';
    $sig = $headers['webhook-signature'] ?? '';
    if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) return false;
    $key      = base64_decode(substr($secret, strlen('whsec_')));
    $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true));
    foreach (explode(' ', $sig) as $part) {            // the header may carry several signatures
        [$version, $value] = array_pad(explode(',', $part, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $value)) return true;
    }
    return false;
}

$body    = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
if (!aptpay_verify($body, $headers, getenv('APTPAY_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}
$event = json_decode($body, true);
if ($event['type'] === 'invoice.paid') {
    $invoice = $event['data']['object'];   // mark order $invoice['reference'] as paid (once per $event['id'])
}
http_response_code(204);

Delivery and retries

  • Reply with any 2xx within 10 seconds. Do slow work after you respond, for example by queueing a job.
  • Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. Administrators can resend any event from the invoice page in admin.
  • Events can arrive more than once, and occasionally out of order. De-duplicate on webhook-id, and when order matters, re-fetch the invoice with GET /invoices/{id}.
  • Redirects are not followed. Register the final https URL.

Fraud protection

Every payment is screened by Stripe Radar, which uses signals from the payment page and the card networks. Banks may ask the customer for 3D Secure verification when Stripe recommends it. On top of that, aptcloud Pay does two things automatically:

  • Card-testing lockout. Fraudsters sometimes cycle stolen cards through a single payment link to find ones that work. After 5 failed attempts on one invoice, the invoice is locked: its status becomes blocked, the payment page stops accepting cards, and you receive invoice.blocked. If the customer is genuine, create a new invoice for them. payment.failed_attempts shows the count so far.
  • Early fraud warnings. When a card network reports a paid invoice as likely fraud, usually days before a chargeback would arrive, we refund it automatically as fraudulent. That avoids the dispute fee, keeps the dispute off your record, and adds the card and email to Radar's block list. You receive invoice.refunded with payment.fraud_warning set, for example "made_with_stolen_card". Revoke access to whatever was purchased.

Disputes

If a customer disputes a payment with their bank (a chargeback, or an inquiry first), you receive invoice.disputed. The invoice's dispute object shows the reason, the amount and the evidence_due_by deadline. aptcloud is alerted and submits evidence in Stripe. When the bank decides, you receive invoice.dispute_closed with dispute.status set to won or lost. The money is held while the dispute is open, so most apps suspend access on invoice.disputed and restore it if the dispute is won.

To help win disputes, keep evidence that the customer received or used what they paid for: delivery records, login and usage logs, and emails. Send the invoice reference so it can be matched to your records.

What helps on your side:

  • Send the customer's real customer.email and customer.name. They're used for receipts and give Radar more to match on.
  • Don't fulfil from the browser redirect. Wait for invoice.paid.
  • For high-value or instantly-consumable products, consider a short delay before fulfilment. Most fraud warnings arrive within days.

Errors

Errors use RFC 9457 Problem Details (application/problem+json). Branch on code; detail is for people. Validation errors name the offending input in field.

{
  "type": "https://aptcloud.com/developers/#error-invalid_request",
  "title": "Invalid request",
  "status": 400,
  "detail": "unit_amount must be a whole number of minor units (cents), e.g. 1999 for 19.99.",
  "code": "invalid_request",
  "field": "items[0].unit_amount"
}
CodeHTTPWhat to do
invalid_request400Fix the input named in field. Don't retry unchanged.
missing_token401Send Authorization: Bearer <token>.
invalid_token401The token expired, was revoked or is malformed. Get a new token and retry once.
insufficient_scope403Request a token with the scope named in detail, or ask an admin to grant it.
not_found404The invoice doesn't exist, or belongs to another app.
invalid_state409The invoice isn't in a state that allows this (e.g. refunding an unpaid invoice). Re-fetch it.
payment_setup_failed402Stripe rejected the payment setup (e.g. amount below the minimum for the currency). See detail.
refund_failed402Stripe refused the refund. See detail.
rate_limited429Wait Retry-After seconds, then retry.
not_configured503Payments aren't set up on this server yet. Contact aptcloud.
server_error500Retry with the same Idempotency-Key; contact aptcloud if it persists.

Testing

Use your test credentials (cli_test_… / cs_test_…). They create test invoices ("livemode": false), no real money moves, and the payment page shows a "Test mode" tag. Use Stripe's test cards with any future expiry date, any CVC and any postal code:

Card numberResult
4242 4242 4242 4242Succeeds
4000 0025 0000 3155Asks for 3-D Secure authentication, then succeeds
4000 0000 0000 9995Declined (insufficient funds). The invoice stays open.
4000 0000 0000 0002Declined (generic)

To test your webhook receiver, ask an administrator to use Send test callback on your app's admin page. That delivers a signed ping event.

Security checklist

  • Keep the client secret and webhook secret on your server only: in environment variables or a secrets manager, never in front-end, mobile or git.
  • Request tokens from your backend, cache them, and send them only to aptcloud.com over HTTPS.
  • Use the narrowest scopes each service needs.
  • Verify every webhook signature and timestamp, and de-duplicate on webhook-id.
  • Fulfil orders from webhooks or a server-side GET, never from the browser redirect alone.
  • Check that amount_total, currency and reference in the event match your order before fulfilling.
  • Use an Idempotency-Key on every create and refund.
  • If a secret leaks, ask an administrator to rotate it. Rotating the client secret revokes all of its tokens at once.