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.
- Authenticate. Exchange your app's client credentials for a short-lived access token (OAuth 2.0).
- Create an invoice.
POST /invoicesreturns apay_url. - Redirect the customer to
pay_url. When they've paid, they come back to yoursuccess_url. - Fulfil on the webhook. We send a signed
invoice.paidevent 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$ch = curl_init('https://aptcloud.com/wp-json/aptcloud-pay/v1/oauth/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_USERPWD => getenv('APTPAY_CLIENT_ID') . ':' . getenv('APTPAY_CLIENT_SECRET'),
CURLOPT_POSTFIELDS => http_build_query(['grant_type' => 'client_credentials']),
CURLOPT_RETURNTRANSFER => true,
]);
$token = json_decode(curl_exec($ch), true)['access_token'];const basic = Buffer.from(`${process.env.APTPAY_CLIENT_ID}:${process.env.APTPAY_CLIENT_SECRET}`).toString('base64');
const res = await fetch('https://aptcloud.com/wp-json/aptcloud-pay/v1/oauth/token', {
method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'client_credentials' }),
});
const { access_token, expires_in } = await res.json();import os, requests
res = requests.post(
"https://aptcloud.com/wp-json/aptcloud-pay/v1/oauth/token",
auth=(os.environ["APTPAY_CLIENT_ID"], os.environ["APTPAY_CLIENT_SECRET"]),
data={"grant_type": "client_credentials"},
)
token = res.json()["access_token"]{
"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"
}'$ch = curl_init('https://aptcloud.com/wp-json/aptcloud-pay/v1/invoices');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
'Content-Type: application/json',
'Idempotency-Key: order-1042',
],
CURLOPT_POSTFIELDS => json_encode([
'reference' => 'order-1042',
'currency' => 'usd',
'items' => [['description' => 'Pro plan (12 months)', 'quantity' => 1, 'unit_amount' => 9900]],
'customer' => ['email' => 'jane@example.com', 'name' => 'Jane Doe'],
'success_url' => 'https://app.example.com/billing/done',
'cancel_url' => 'https://app.example.com/billing',
]),
CURLOPT_RETURNTRANSFER => true,
]);
$invoice = json_decode(curl_exec($ch), true);
header('Location: ' . $invoice['pay_url']); // send the customer to payconst res = await fetch('https://aptcloud.com/wp-json/aptcloud-pay/v1/invoices', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-1042',
},
body: JSON.stringify({
reference: 'order-1042',
currency: 'usd',
items: [{ description: 'Pro plan (12 months)', quantity: 1, unit_amount: 9900 }],
customer: { email: 'jane@example.com', name: 'Jane Doe' },
success_url: 'https://app.example.com/billing/done',
cancel_url: 'https://app.example.com/billing',
}),
});
const invoice = await res.json();
// res.redirect(invoice.pay_url);res = requests.post(
"https://aptcloud.com/wp-json/aptcloud-pay/v1/invoices",
headers={"Authorization": f"Bearer {token}", "Idempotency-Key": "order-1042"},
json={
"reference": "order-1042",
"currency": "usd",
"items": [{"description": "Pro plan (12 months)", "quantity": 1, "unit_amount": 9900}],
"customer": {"email": "jane@example.com", "name": "Jane Doe"},
"success_url": "https://app.example.com/billing/done",
"cancel_url": "https://app.example.com/billing",
},
)
invoice = res.json()
# redirect the customer to invoice["pay_url"]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 asclient_idandclient_secretform 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.
| Scope | Allows |
|---|---|
invoices:read | Read invoices |
invoices:write | Create and cancel invoices |
refunds:write | Refund 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): onlyclient_credentialsis 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 mode | Live mode | |
|---|---|---|
| Client ID | cli_test_… | cli_live_… |
| Client secret | cs_test_… | cs_live_… |
| Webhook secret | its own whsec_… | its own whsec_… |
| Money | None. 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
livemodeand is signed with that mode's webhook secret. If one callback URL receives both, check the signature with the secret that matcheslivemode. - Idempotency keys are scoped per mode, so reusing
order-1042in 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
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.
| Field | Type | Description |
|---|---|---|
items required* | array | 1–100 lines, each with description (≤250 chars), quantity (1–10000, default 1) and unit_amount. The total is computed for you. |
amount | integer | *Instead of items: a single line labelled with description. |
currency | string | Default usd. |
reference | string | Your order ID (≤100 chars). You can filter by it. |
description | string | ≤500 chars. Shown on the payment page and in Stripe. |
customer.email, customer.name | string | Pre-fills the payment form. The email also receives Stripe's receipt. |
success_url, cancel_url | URL | https only, and on one of your app's registered return hosts. |
metadata | object | Up to 20 string values for your own use. Also copied to the Stripe payment. |
expires_in | integer | Seconds, from 300 to 2592000 (30 days). Default 604800 (7 days). |
branding.logo_url | URL | Your logo at the top of the payment page (https). Defaults to the logo set for your app. See Branding. |
branding.header_html | string | Short 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.paidwebhook, or callGET /invoices/{id}to confirm the status. - A "Back to your app" link goes to
cancel_url. The invoice staysopenand can be paid later until it expires. - After payment,
pay_urlbecomes 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,lianda 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_urlin the system browser sheet:SFSafariViewControlleron 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.
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"{
"object": "payment_sheet",
"invoice": "inv_5c1f0e9a2b7d4e6f8a9b0c1d",
"payment_intent": "pi_3Q…",
"client_secret": "pi_3Q…_secret_…",
"publishable_key": "pk_test_…",
"merchant_display_name": "Your app",
"merchant_country": "US",
"amount": 12900,
"currency": "usd",
"livemode": false,
"expires_at": "2026-10-21T15:02:11+00:00"
}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)")
}
}
}// build.gradle: implementation("com.stripe:stripe-android:<latest>")
class CheckoutActivity : AppCompatActivity() {
private lateinit var paymentSheet: PaymentSheet
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
paymentSheet = PaymentSheet(this, ::onPaymentSheetResult)
}
fun pay(sheet: PaymentSheetParams) {
PaymentConfiguration.init(applicationContext, sheet.publishableKey)
paymentSheet.presentWithPaymentIntent(
sheet.clientSecret,
PaymentSheet.Configuration(
merchantDisplayName = sheet.merchantDisplayName,
googlePay = PaymentSheet.GooglePayConfiguration(
environment = if (sheet.livemode) PaymentSheet.GooglePayConfiguration.Environment.Production
else PaymentSheet.GooglePayConfiguration.Environment.Test,
countryCode = sheet.merchantCountry,
currencyCode = sheet.currency.uppercase(),
),
allowsDelayedPaymentMethods = true,
),
)
}
private fun onPaymentSheetResult(result: PaymentSheetResult) {
when (result) {
is PaymentSheetResult.Completed -> { /* show success; backend fulfils on invoice.paid */ }
is PaymentSheetResult.Canceled -> {}
is PaymentSheetResult.Failed -> Log.e("Pay", "Payment failed", result.error)
}
}
}// npm install @stripe/stripe-react-native
import { StripeProvider, useStripe } from '@stripe/stripe-react-native';
// Wrap your app: <StripeProvider publishableKey={sheet.publishable_key} merchantIdentifier="merchant.com.example.yourapp">
function PayButton({ invoiceId }) {
const { initPaymentSheet, presentPaymentSheet } = useStripe();
async function pay() {
// Your own backend calls POST /invoices/{id}/payment-sheet and returns the JSON.
const sheet = await fetch(`https://api.yourapp.com/checkout/${invoiceId}/sheet`).then((r) => r.json());
const { error: initError } = await initPaymentSheet({
paymentIntentClientSecret: sheet.client_secret,
merchantDisplayName: sheet.merchant_display_name,
applePay: { merchantCountryCode: sheet.merchant_country },
googlePay: { merchantCountryCode: sheet.merchant_country, currencyCode: sheet.currency.toUpperCase(), testEnv: !sheet.livemode },
allowsDelayedPaymentMethods: true,
returnURL: 'yourapp://stripe-redirect',
});
if (initError) return console.warn(initError.message);
const { error } = await presentPaymentSheet();
if (!error) { /* show success; backend fulfils on invoice.paid */ }
}
// …
}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 whilelivemodeisfalse.
After the sheet closes
completedmeans Stripe accepted the payment. Some methods, such as bank debits, still settle later. Fulfil only when your backend receivesinvoice.paid, or confirm withGET /invoices/{id}.- If the invoice was paid elsewhere, canceled or has expired,
/payment-sheetreturns409 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.
| Event | When |
|---|---|
invoice.paid | The customer paid. Fulfil the order. |
invoice.refunded | A 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.canceled | The invoice was canceled. |
invoice.expired | The invoice reached expires_at unpaid. |
invoice.blocked | The invoice was locked after too many failed payment attempts. |
invoice.disputed | The customer disputed the payment with their bank. Pause or revoke what was purchased; see dispute. |
invoice.dispute_closed | The dispute ended: dispute.status is won (you keep the money) or lost (the customer got it back). |
ping | Sent 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);import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/aptcloud-pay', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.get('webhook-id'), ts = req.get('webhook-timestamp'), sig = req.get('webhook-signature') || '';
if (!id || !/^\d+$/.test(ts || '') || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
const key = Buffer.from(process.env.APTPAY_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${req.body}`).digest();
const ok = sig.split(' ').some((part) => {
const [version, value] = part.split(',');
const given = Buffer.from(value || '', 'base64');
return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (event.type === 'invoice.paid') { /* fulfil event.data.object.reference (once per event.id) */ }
res.sendStatus(204);
});import base64, hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/aptcloud-pay")
def aptcloud_pay():
body = request.get_data() # raw bytes
msg_id = request.headers.get("webhook-id", "")
ts = request.headers.get("webhook-timestamp", "")
sigs = request.headers.get("webhook-signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(400)
key = base64.b64decode(os.environ["APTPAY_WEBHOOK_SECRET"].removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
if not any(p.startswith("v1,") and hmac.compare_digest(p[3:], expected) for p in sigs.split()):
abort(400)
event = request.get_json()
if event["type"] == "invoice.paid":
pass # fulfil event["data"]["object"]["reference"] (once per event["id"])
return "", 204// Any official Standard Webhooks library works, e.g. Node: npm install standardwebhooks
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.APTPAY_WEBHOOK_SECRET); // the full "whsec_…" value
const event = wh.verify(rawBody, {
'webhook-id': req.get('webhook-id'),
'webhook-timestamp': req.get('webhook-timestamp'),
'webhook-signature': req.get('webhook-signature'),
}); // throws if the signature or timestamp is invalidDelivery and retries
- Reply with any
2xxwithin 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 withGET /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 receiveinvoice.blocked. If the customer is genuine, create a new invoice for them.payment.failed_attemptsshows 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.refundedwithpayment.fraud_warningset, 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.emailandcustomer.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"
}| Code | HTTP | What to do |
|---|---|---|
invalid_request | 400 | Fix the input named in field. Don't retry unchanged. |
missing_token | 401 | Send Authorization: Bearer <token>. |
invalid_token | 401 | The token expired, was revoked or is malformed. Get a new token and retry once. |
insufficient_scope | 403 | Request a token with the scope named in detail, or ask an admin to grant it. |
not_found | 404 | The invoice doesn't exist, or belongs to another app. |
invalid_state | 409 | The invoice isn't in a state that allows this (e.g. refunding an unpaid invoice). Re-fetch it. |
payment_setup_failed | 402 | Stripe rejected the payment setup (e.g. amount below the minimum for the currency). See detail. |
refund_failed | 402 | Stripe refused the refund. See detail. |
rate_limited | 429 | Wait Retry-After seconds, then retry. |
not_configured | 503 | Payments aren't set up on this server yet. Contact aptcloud. |
server_error | 500 | Retry 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 number | Result |
|---|---|
4242 4242 4242 4242 | Succeeds |
4000 0025 0000 3155 | Asks for 3-D Secure authentication, then succeeds |
4000 0000 0000 9995 | Declined (insufficient funds). The invoice stays open. |
4000 0000 0000 0002 | Declined (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.comover 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,currencyandreferencein the event match your order before fulfilling. - Use an
Idempotency-Keyon 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.