# PayLumia Developers — full reference Source: https://developers.hadruvo.com # PayLumia integration PayLumia is Hadruvo's carrier-billing aggregator. A partner server creates a **hosted payment**, the subscriber confirms on `pay.paylumia.com`, the operator charges the line, and PayLumia sends a **signed webhook**. This skill walks the developer from scoping to a go-live-ready integration. Source of truth: https://developers.hadruvo.com (Partner API v1, hosted mode). Never invent endpoints, fields, events or error codes that are not in the reference files below. If something is not covered, say so and point the user to their Hadruvo integration contact. ## How to run this skill Work through the five steps in order. Ask one short question at a time; skip questions the user already answered. ### 1. Scope the service Establish, in plain words: - **What is sold**: a subscription (daily / weekly / monthly), a consumable (coin pack, pass, credits) or a permanent unlock. In PayLumia this is the **offer type**: `SUBSCRIPTION`, `CONSUMABLE` or `NON_CONSUMABLE`. - **Offers**: the `offerUid` values configured in the partner console (for example `premium_daily`). Price, currency, period and trial live on the offer; the code only names it. - **Presentation**: `redirect` (default, works everywhere) or `embedded` (the checkout mounted in the partner's page). - **Stack**: server language/framework (Node.js, PHP, Python, other), how orders/users are stored, and where webhooks will be received. - **Market**: country and operator (discover what is available at runtime with `GET /v1/capabilities` — never hard-code consent methods). ### 2. Credentials and environments Two separate deployments of one application (credentials never cross): | Env | API | Checkout | Client ID | Operator | | --- | --- | --- | --- | --- | | Sandbox | `https://api.sandbox.paylumia.com` | `https://pay.sandbox.paylumia.com` | `plm_test_` | Mock only | | Production | `https://api.paylumia.com` | `https://pay.paylumia.com` | `plm_live_` | Live Orange | - Secrets are shown once. Store them in a secret manager or environment variables (`PAYLUMIA_CLIENT_ID`, `PAYLUMIA_CLIENT_SECRET`, `PAYLUMIA_WEBHOOK_SECRET`). Never ship them in a mobile app or a web page. - Each environment has **its own webhook URL and signing secret**, set in the console. The create request never carries a notify URL. Details: `references/authentication.md`, `references/environments-and-go-live.md`. ### 3. Generate the code Produce, for the user's stack: 1. **Token client** — `POST /oauth2/token` (client credentials), cache for ~1 hour, refresh shortly before expiry. 2. **Create payment** — `POST /v1/payments` with `mode: "HOSTED"`, `offerUid`, `returnUrl`, optional `presentation`, `prefill`, `operator`, `metadata` (put the partner's order id in `metadata`). Always send an `Idempotency-Key` (8–255 chars, stable per order/attempt). Then redirect to or embed `data.checkoutUrl`. 3. **Webhook endpoint** — read the **raw body**, verify `PayLumia-Signature` (`t={unix},v1={hex hmac}` over `{t}.{rawBody}`, HMAC-SHA256, 5-minute tolerance, constant-time compare), store the event, answer `2xx` fast, process idempotently. Grant on `payment.succeeded` / `entitlement.granted`, revoke on `entitlement.revoked`. 4. **Return page** — `returnUrl` receives `paymentUid` and `status` as hints only. Show a friendly screen and confirm server-side with the webhook or `GET /v1/payments/{paymentUid}`. 5. **Subscriptions** (if applicable) — read, cancel (`NOW` or `PERIOD_END`) and resume endpoints; react to `entitlement.*` and `subscription.*` events. Samples: `references/hosted-checkout.md`, `references/webhooks.md`, `references/subscriptions.md`. Error handling: `references/errors.md`. Forbidden in hosted mode (reject if the user asks): sending `amount`, a bare `msisdn` (use `prefill.msisdn`), `productUid` or `notifyUrl` in the create body; granting access from the return URL alone; signing query strings or the checkout URL. ### 4. Comply Check the partner's UX and code against these rules and call out any gap: - The offer name, price and billing period are shown **before** the subscriber is sent to checkout. - A clear way to cancel a subscription exists in the product. - Consent happens only on the PayLumia checkout host for that environment; prefilling the number never replaces consent. - Access is granted from a verified webhook or a server-side read. ### 5. Validate for go-live Run the checklist in `references/environments-and-go-live.md` with the user and produce a short report: passed / to fix. Suggest test cases: success, decline (`payment.failed`), abandoned session (`EXPIRED`), cancel before completion, duplicate webhook delivery, bad signature, token expiry. When everything passes, tell them to ask their Hadruvo contact for the go-live review. ## Reference files - `references/api-overview.md` — base URLs, conventions, endpoint list - `references/authentication.md` - `references/hosted-checkout.md` — create, fields, response, offer types, checkout experience - `references/payment-lifecycle.md` - `references/webhooks.md` - `references/subscriptions.md` - `references/errors.md` - `references/environments-and-go-live.md` - `references/paylumia-hosted.openapi.yaml` — machine-readable contract --- # PayLumia Partner API v1 — overview (hosted mode) | | | | --- | --- | | Token | `POST /oauth2/token` (OAuth 2.0 client credentials) | | Success envelope | `{ "success": true, "data": … }` | | Error envelope | `{ "success": false, "error": { "code": "XXX-###", "message": …, "retryable"?, "retryAfterSeconds"? } }` | | Money | Integers in the currency's minor unit (TND: millimes), always with a currency | | Identifiers | Opaque, typed: `paymentUid` (`pay_…`), `subscriptionUid`, `subscriberUid`, `offerUid`, `productUid`, `orderId` | ## Environments | Env | API | Checkout | Client ID | Operator | | --- | --- | --- | --- | --- | | Sandbox | `https://api.sandbox.paylumia.com` | `https://pay.sandbox.paylumia.com` | `plm_test_…` | Mock only | | Production | `https://api.paylumia.com` | `https://pay.paylumia.com` | `plm_live_…` | Live Orange | Checkout URLs look like `{checkout-host}/c/{paymentUid}`. Details: `environments-and-go-live.md`. ## Flow 1. Your server creates a payment (`POST /v1/payments`, `mode: HOSTED`) against the environment base URL. 2. The subscriber confirms on that environment's checkout host (PIN, network recognition or SMS keyword — decided by operator, offer and consent policy). 3. The operator charges the line (mock in sandbox; live Orange in production). 4. PayLumia posts a signed webhook (`payment.succeeded` / `payment.failed`) to your environment's webhook URL. 5. The browser returns to your `returnUrl` (UX only). Confirm with the webhook or `GET /v1/payments/{paymentUid}`. ## Endpoints | Method | Path | Purpose | | --- | --- | --- | | POST | `/oauth2/token` | Access token (1 h) | | GET | `/v1/capabilities?countryCode=TN[&operator=…]` | Modes, consent methods, instruments available at runtime | | POST | `/v1/payments` | Create a hosted payment (requires `Idempotency-Key`) | | GET | `/v1/payments/{paymentUid}` | Read a payment | | POST | `/v1/payments/{paymentUid}/cancel` | Cancel while `REQUIRES_ACTION` | | GET | `/v1/subscriptions/{subscriptionUid}` | Read a subscription | | POST | `/v1/subscriptions/{subscriptionUid}/cancel` | `{ "when": "NOW" \| "PERIOD_END" }` | | POST | `/v1/subscriptions/{subscriptionUid}/resume` | Withdraw a `PERIOD_END` cancellation | | POST | `{your webhook URL}` | Events sent by PayLumia, signed in `PayLumia-Signature` | ## Capabilities **cURL** ```bash curl "https://api.sandbox.paylumia.com/v1/capabilities?countryCode=TN&operator=ORANGE_TN" \ -H "Authorization: Bearer $PAYLUMIA_TOKEN" ``` **200 OK** ```json { "success": true, "data": { "countryCode": "TN", "operator": "ORANGE_TN", "modes": ["HOSTED", "API"], "consentMethods": ["PIN", "HEADER_ENRICHMENT", "MO_SMS"], "operatorConsentPage": false, "instruments": ["DCB"] } } ``` --- # Authentication OAuth 2.0 client credentials. The token identifies your application **and** its environment; requests never carry an application, partner or environment field (anything sent to that effect is ignored). Each route also checks that your application holds the matching capability (payments, capabilities, subscriptions). ## Get a token Tokens last one hour. Cache and reuse; request a new one shortly before expiry, not on every call. **cURL** ```bash curl https://api.sandbox.paylumia.com/oauth2/token \ -d grant_type=client_credentials \ -d client_id=$PAYLUMIA_CLIENT_ID \ -d client_secret=$PAYLUMIA_CLIENT_SECRET ``` **Node.js** ```js const res = await fetch('https://api.sandbox.paylumia.com/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.PAYLUMIA_CLIENT_ID, client_secret: process.env.PAYLUMIA_CLIENT_SECRET, }), }); const { access_token, expires_in } = await res.json(); // one hour ``` **PHP** ```php $ch = curl_init('https://api.sandbox.paylumia.com/oauth2/token'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => http_build_query([ 'grant_type' => 'client_credentials', 'client_id' => getenv('PAYLUMIA_CLIENT_ID'), 'client_secret' => getenv('PAYLUMIA_CLIENT_SECRET'), ]), ]); $token = json_decode(curl_exec($ch), true)['access_token']; ``` **Python** ```python import os, requests res = requests.post('https://api.sandbox.paylumia.com/oauth2/token', data={ 'grant_type': 'client_credentials', 'client_id': os.environ['PAYLUMIA_CLIENT_ID'], 'client_secret': os.environ['PAYLUMIA_CLIENT_SECRET'], }) token = res.json()['access_token'] ``` **Response** ```json { "access_token": "eyJ0eXAiOi…", "token_type": "bearer", "expires_in": 3600 } ``` ## Headers | Header | Direction | Required | Notes | | --- | --- | --- | --- | | `Authorization: Bearer …` | Request | Always | Token from `/oauth2/token` | | `Idempotency-Key` | Request | On `POST /v1/payments` | 8–255 characters. Replays within 24 h return the original result | | `PayLumia-Signature` | Webhook | Always | HMAC of the webhook body (see webhooks.md) | PayLumia never asks you to sign query strings or the checkout URL: the Bearer token authenticates your server, the webhook signature authenticates PayLumia. ## Secrets - Call PayLumia from your server only. Never ship a client secret in a mobile app or web page. - The client secret is shown once when the environment is created. Store it in a secret manager. - Rotating a secret affects one environment only: sandbox and production are independent. --- # Create a hosted payment `POST /v1/payments` — headers `Authorization: Bearer …` and `Idempotency-Key` (required). ## Request fields | Field | Type | Required | Notes | | --- | --- | --- | --- | | `mode` | enum | yes | Always `HOSTED` | | `offerUid` | string | yes | Offer configured in your application. Price, currency, period and trial come from it | | `returnUrl` | uri | yes | Browser return. UX only, never proof of payment | | `presentation` | enum | no | `redirect` (default) or `embedded` | | `prefill.msisdn` | E.164 string | no | Pre-fills the number | | `prefill.msisdnEditable` | boolean | no | Default `true`; `false` locks the number (then `prefill.msisdn` is required) | | `operator` | string | no | Hint, e.g. `ORANGE_TN`. Cannot select an operator the offer is not available on | | `metadata` | object ≤ 20 keys | no | Your correlation data. Echoed on reads and webhooks | Not allowed in hosted mode (→ `VAL-006`): `amount` (prices are fixed on the offer), `msisdn` (use `prefill.msisdn`; a number alone is never consent), `productUid` (derived), `notifyUrl` (webhooks go to the environment URL). **cURL** ```bash curl https://api.sandbox.paylumia.com/v1/payments \ -H "Authorization: Bearer $PAYLUMIA_TOKEN" \ -H "Idempotency-Key: order-8891" \ -H "Content-Type: application/json" \ -d '{ "mode": "HOSTED", "offerUid": "premium_daily", "returnUrl": "https://app.example.tn/return", "presentation": "redirect", "prefill": { "msisdn": "+21620123456", "msisdnEditable": true }, "metadata": { "orderRef": "order-8891" } }' ``` **Node.js** ```js const res = await fetch('https://api.sandbox.paylumia.com/v1/payments', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': order.id, // 8–255 chars, replay window 24h 'Content-Type': 'application/json', }, body: JSON.stringify({ mode: 'HOSTED', offerUid: 'premium_daily', returnUrl: 'https://app.example.tn/return', metadata: { orderRef: order.id }, }), }); const { data } = await res.json(); // Send the customer to data.checkoutUrl return Response.redirect(data.checkoutUrl, 303); ``` **PHP** ```php $ch = curl_init('https://api.sandbox.paylumia.com/v1/payments'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $token, 'Idempotency-Key: ' . $orderId, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'mode' => 'HOSTED', 'offerUid' => 'premium_daily', 'returnUrl' => 'https://app.example.tn/return', 'metadata' => ['orderRef' => $orderId], ]), ]); $payment = json_decode(curl_exec($ch), true)['data']; header('Location: ' . $payment['checkoutUrl'], true, 303); ``` **Python** ```python res = requests.post( 'https://api.sandbox.paylumia.com/v1/payments', headers={ 'Authorization': f'Bearer {token}', 'Idempotency-Key': order_id, }, json={ 'mode': 'HOSTED', 'offerUid': 'premium_daily', 'returnUrl': 'https://app.example.tn/return', 'metadata': {'orderRef': order_id}, }, ) payment = res.json()['data'] return redirect(payment['checkoutUrl'], code=303) ``` ## Response — `201 Created` **201 Created** ```json { "success": true, "data": { "object": "payment", "paymentUid": "pay_4mK9pQeRw2Ns6TfXb1Zy0a", "mode": "HOSTED", "status": "REQUIRES_ACTION", "checkoutUrl": "https://pay.sandbox.paylumia.com/c/pay_4mK9pQeRw2Ns6TfXb1Zy0a", "productUid": "premium", "offerUid": "premium_daily", "expiresAt": "2026-09-20T15:30:00Z", "metadata": { "orderRef": "order-8891" } } } ``` | Field | Notes | | --- | --- | | `paymentUid` | Opaque id of the session. Store it with your order | | `checkoutUrl` | `{checkout-host}/c/{paymentUid}` for the environment (sandbox or production) | | `status` | `REQUIRES_ACTION` until the subscriber acts | | `productUid` | Entitlement the offer grants — use it to unlock access | | `expiresAt` | An untouched session expires after this time | | `orderId` | Appears once money has moved | | `subscriberUid` | Appears once the subscriber is identified; scoped to your application, never the phone number | ## Offer types | Offer type | What a payment does | subscriptionUid | | --- | --- | --- | | `SUBSCRIPTION` | Consent, then a subscription with renewals run by PayLumia | yes | | `CONSUMABLE` | One charge, repeatable (coin packs, passes, credits) | no | | `NON_CONSUMABLE` | One charge, permanent unlock | no | Changing plans is not a payment: never start a second payment to switch a subscriber between plans. ## Checkout experience - `redirect`: navigate the browser to `checkoutUrl`. Works everywhere, including in-app browsers. - `embedded`: mount `checkoutUrl` in your layout; consent screens stay PayLumia's. - Prefill: `"prefill": { "msisdn": "+21620123456", "msisdnEditable": false }` saves a step; it never replaces consent. - Consent methods: `PIN` (SMS code), `HEADER_ENRICHMENT` (mobile data identifies the line; falls back to a code on Wi-Fi), `MO_SMS` (keyword to a short code). You never choose the method in the request — read `GET /v1/capabilities`. - Coming back: `returnUrl?paymentUid=…&status=…` — hints for a thank-you / try-again screen only. --- # Payment lifecycle | Status | Terminal | Meaning / next step | | --- | --- | --- | | `REQUIRES_ACTION` | no | Created. Open the `checkoutUrl`, or cancel | | `PROCESSING` | no | Consent given, the operator is charging. Wait for the webhook | | `SUCCEEDED` | soft | Paid. Grant access. Later changes arrive as `entitlement.*` / `subscription.*` events | | `FAILED` | yes | Declined or not completed. Offer a new payment | | `EXPIRED` | yes | Passed `expiresAt` without completing | | `CANCELED` | yes | Canceled by you before completion | ## Read `GET /v1/payments/{paymentUid}` — your server's synchronous source of truth (e.g. the subscriber returns before the webhook). **cURL** ```bash curl https://api.sandbox.paylumia.com/v1/payments/pay_4mK9pQeRw2Ns6TfXb1Zy0a \ -H "Authorization: Bearer $PAYLUMIA_TOKEN" ``` **Node.js** ```js const res = await fetch(`https://api.sandbox.paylumia.com/v1/payments/${paymentUid}`, { headers: { Authorization: `Bearer ${token}` }, }); const { data } = await res.json(); if (data.status === 'SUCCEEDED') grantAccess(data.metadata.orderRef); ``` **200 OK** ```json { "success": true, "data": { "object": "payment", "paymentUid": "pay_4mK9pQeRw2Ns6TfXb1Zy0a", "mode": "HOSTED", "status": "SUCCEEDED", "orderId": "0b8f3f5e-3f55-4c1a-9a44-2f0c7f0b9d61", "subscriberUid": "sbr_7Hq2LmN4pR8sT1vW", "productUid": "premium", "offerUid": "premium_daily", "expiresAt": "2026-09-20T15:30:00Z", "metadata": { "orderRef": "order-8891" } } } ``` ## Cancel `POST /v1/payments/{paymentUid}/cancel` — only while `REQUIRES_ACTION`, never after `SUCCEEDED`. Empty body; returns the payment with `status: CANCELED`. --- # Webhooks Signed events are the money truth. Each environment has **one webhook URL and one signing secret**, set in the partner console. You never send a URL when creating a payment. ## Events | Event | When | | --- | --- | | `payment.succeeded` | Consent given and the initial charge or subscription start succeeded | | `payment.failed` | The payment ended in failure | | `entitlement.granted` | Access starts: first payment, reactivation or trial conversion paid | | `entitlement.updated` | A renewal was collected, or the subscription entered grace | | `entitlement.revoked` | Access ended: suspension, cancellation or expiry | | `subscription.trial_started` | A trial began | | `subscription.trial_will_end` | Reminder before the trial ends | | `subscription.updated` | A cancellation at period end was scheduled or withdrawn | | `subscription.suspended` · `canceled` · `expired` · `failed` | Subscription status changes | ## Payload **payment.succeeded** ```json { "event": "payment.succeeded", "paymentUid": "pay_4mK9pQeRw2Ns6TfXb1Zy0a", "status": "SUCCEEDED", "orderId": "0b8f3f5e-3f55-4c1a-9a44-2f0c7f0b9d61", "productUid": "premium", "offerUid": "premium_daily", "subscriberUid": "sbr_7Hq2LmN4pR8sT1vW", "metadata": { "orderRef": "order-8891" }, "occurredAt": "2026-09-20T15:12:44Z" } ``` | Field | Notes | | --- | --- | | `event` | Event name | | `paymentUid` | The payment session | | `status` | Payment status at the time of the event | | `orderId` | Present once money moved | | `productUid` · `offerUid` | What was bought, through which offer | | `subscriberUid` | The payer, scoped to your application | | `amountCollected` · `periodOutstanding` | On renewals: collected so far this period, and what remains | | `metadata` | Echo of what you sent at creation | | `occurredAt` | Event time | ## Verify the signature Header: `PayLumia-Signature: t={unix},v1={hmac}`. HMAC-SHA256 with the environment's secret over `{t}.{rawBody}`. Reject events older than five minutes; compare in constant time. Verify against the **raw bytes** — re-serialised JSON breaks the signature. **Node.js** ```js import crypto from 'node:crypto'; // Header: PayLumia-Signature: t={unix},v1={hmac} // Signed payload: `${t}.${rawBody}` — use the raw bytes, not re-serialised JSON. export function verifyPayLumia(rawBody, header, secret, toleranceSec = 300) { const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('='))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 || ''); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` **PHP** ```php function verify_paylumia(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { parse_str(str_replace(',', '&', $header), $parts); $t = (int) ($parts['t'] ?? 0); if (!$t || abs(time() - $t) > $tolerance) return false; $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1'] ?? ''); } $ok = verify_paylumia(file_get_contents('php://input'), $_SERVER['HTTP_PAYLUMIA_SIGNATURE'], getenv('PAYLUMIA_WEBHOOK_SECRET')); ``` **Python** ```python import hmac, hashlib, time def verify_paylumia(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(p.strip().split('=', 1) for p in header.split(',')) t = int(parts.get('t', 0)) if not t or abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get('v1', '')) ``` ## Respond and process - Answer `2xx` as soon as the signature checks out; do slow work asynchronously. - Delivery is at least once: up to five attempts within 48 hours of the first; failed events can be replayed from the partner console. Make handlers idempotent. - Prefer the status in the signed body; you can always confirm with a read. **Express handler** ```js app.post('/paylumia/events', express.raw({ type: 'application/json' }), async (req, res) => { const ok = verifyPayLumia(req.body.toString('utf8'), req.get('PayLumia-Signature'), process.env.PAYLUMIA_WEBHOOK_SECRET); if (!ok) return res.sendStatus(400); const event = JSON.parse(req.body); // Idempotent: the same event may arrive more than once. await db.events.upsert({ key: `${event.event}:${event.paymentUid}:${event.occurredAt}` }, async () => { if (event.event === 'payment.succeeded') await grantAccess(event.metadata.orderRef, event.productUid); if (event.event === 'entitlement.revoked') await revokeAccess(event.subscriberUid, event.productUid); }); res.sendStatus(200); // answer fast, work async }); ``` --- # Subscriptions A hosted payment on a `SUBSCRIPTION` offer collects consent, starts the subscription and returns a `subscriptionUid`. Renewals are platform-managed: you never trigger a charge. Each collected renewal arrives as `entitlement.updated`; loss of access as `entitlement.revoked`. ## Trials When the offer has trial days, the first payment succeeds with nothing charged. The subscription is `TRIALING` until `trialEndsAt`; you receive `subscription.trial_started`, then `subscription.trial_will_end`. ## Endpoints - `GET /v1/subscriptions/{subscriptionUid}` → `subscriptionUid`, `status`, `productUid`, `offerUid`, `subscriberUid`, `currentPeriodEnd`, `cancelAt` - `POST /v1/subscriptions/{subscriptionUid}/cancel` with `{ "when": "NOW" }` (access ends now) or `{ "when": "PERIOD_END" }` (access until `cancelAt`, status `PRE_CANCELED`) - `POST /v1/subscriptions/{subscriptionUid}/resume` — withdraws a `PERIOD_END` cancellation (back to `ACTIVE`). No body. Not a plan change. ```bash curl -X POST https://api.sandbox.paylumia.com/v1/subscriptions/sub_2Rt8xVq0LmN4pK7a/cancel \ -H "Authorization: Bearer $PAYLUMIA_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "when": "PERIOD_END" }' ``` ## Statuses | Status | Meaning | | --- | --- | | `TRIALING` | In a free trial | | `ACTIVE` | Paid and running | | `PRE_CANCELED` | Will end at `cancelAt`; access continues until then | | `IN_GRACE` | A renewal could not be collected yet; access per the offer's grace policy | | `SUSPENDED` · `CANCELED` · `EXPIRED` | Access has ended | --- # Errors Codes follow `XXX-###`. When a retry could succeed the body says so with `retryable` and, if known, `retryAfterSeconds` (also sent as `Retry-After`). **402 Payment Required** ```json { "success": false, "error": { "code": "PAY-101", "message": "Subscriber balance is insufficient", "retryable": true, "retryAfterSeconds": 14400 } } ``` | Code | HTTP | Meaning | | --- | --- | --- | | `ATH-002` | 401 | Token missing, malformed or not recognised | | `AUZ-002` | 403 | Your application lacks the capability for this route | | `VAL-001` | 400 | A required field is missing | | `VAL-002` | 400 | A field has an invalid format or value | | `VAL-006` | 400 | Unknown property, e.g. `amount` or `msisdn` in hosted mode | | `SEC-001` | 429 | Rate limit reached | | `CAT-102` | 404 | Offer not found | | `CAT-103` | 422 | Offer is not active | | `CAT-104` | 403 | Your application is not authorised for this offer | | `CAT-109` | 409 | This one-time item is already owned | | `PAY-101` | 402 | Subscriber balance is insufficient. Retryable | | `PAY-103` | 402 | Charge declined by the operator. Retryable | | `PAY-104` | 409 | A request with this idempotency key is still in progress. Retry shortly with the same key | | `PAY-105` | 409 | Idempotency key reused with a different payload | | `SBR-102` | 400 | Mobile number is invalid | | `SBR-104` | 422 | This mobile operator is not supported | | `RSK-101` | 403 | The subscriber has opted out of carrier billing | | `SUB-101` | 404 | Subscription not found | | `SUB-104` | 409 | Subscription is already canceled | | `SUB-105` | 409 | No scheduled cancellation to withdraw | | `SUB-108` | 409 | Trial already used for this product | Branch on the code, never the message. Retry only when `retryable`, after the suggested delay. --- # Environments and go-live PayLumia has **two** environments for partners. Credentials never cross between them. | | Sandbox | Production | | --- | --- | --- | | Client ID | `plm_test_…` | `plm_live_…` | | API | `https://api.sandbox.paylumia.com` | `https://api.paylumia.com` | | Hosted checkout | `https://pay.sandbox.paylumia.com` | `https://pay.paylumia.com` | | Operator | Mock only | Live Orange | | Money | None | Real operator charges | | Status at creation | Active | Not live until approved | | Webhook URL & secret | Its own | Its own | Sandbox is mock only. Both share the application's offers and consent policy. A sandbox token cannot reach production. ## Path to production 1. Build and prove flows in **sandbox** (`plm_test_`, mock). 2. Pass the go-live checklist, then ask for production approval (`plm_live_`). ## Go-live checklist - [ ] Every create sends a unique, stable `Idempotency-Key` (order or attempt id). - [ ] Access is granted from a verified webhook or a server-side read, never from the return URL alone. - [ ] The webhook handler verifies signatures, answers 2xx quickly and tolerates the same event twice. - [ ] `entitlement.revoked` and `subscription.*` events remove access when a subscription ends. - [ ] Pages show the offer, its price and period before checkout, and a way to cancel. - [ ] Sandbox and production webhook URLs are set and reachable over HTTPS. - [ ] Secrets live in a secret manager; nothing ships in a client. ## Suggested test cases (sandbox) Success · operator decline (`payment.failed`) · abandoned session (`EXPIRED`) · cancel before completion · duplicate webhook delivery · invalid signature rejected · token expiry and refresh · subscription cancel at period end then resume. ## Approval When sandbox passes the checklist, ask your Hadruvo contact for a go-live review. Once approved, the production deployment becomes active and its credentials are issued (the secret is shown once).