Building your app
Payment apps
A payment app registers a pluggable payment gateway/method that a tenant
installs once, after which any other app can charge through it via a
uniform payment contract in okta-web. You build the provider once and the
platform calls it with charge(...) regardless of the actual gateway
(Tabby, Tamara, Noon Payments, ...).
Manifest block
{
"integrationType": "payment",
"payment": {
"payment_methods": ["card", "mada", "applepay", "stcpay", "tabby", "tamara", "bank_transfer", "wallet", "cash"],
"delivery": "api",
"api": {
"charge_endpoint": "https://your-gateway.example/okta/charge",
"auth": "hmac"
},
"embedded": {
"provider_class": "Modules\\AcmePay\\Payments\\AcmePayProvider"
},
"capabilities": {
"supports_refunds": true,
"supports_partial_refunds": false,
"supports_installments": true,
"min_amount": null,
"max_amount": null,
"currencies": ["SAR"]
},
"settings_ui": {
"has_settings_page": true,
"livewire_component": "vendor-x-payment-settings"
}
}
}
Validation rules the platform enforces:
payment.payment_methods— a non-empty array of known values:card,mada,applepay,stcpay,tabby,tamara,bank_transfer,wallet,cash.payment.deliverymust beapi,embeddedorhybrid.delivery=api/hybrid⇒payment.api.charge_endpointmust be HTTPS.delivery=embedded/hybrid⇒payment.embedded.provider_classmust be a valid FQCN.- A
paymentblock is rejected unlessintegrationType=payment.
Custom payment methods
Beyond the nine standard methods, a payment provider may declare custom
methods via a custom_methods block alongside payment_methods:
"payment": {
"payment_methods": ["card", "mada", "my_regional_wallet"],
"custom_methods": [
{ "key": "my_regional_wallet", "label": "محفظتي الإقليمية", "label_en": "My Regional Wallet", "kind": "wallet" }
],
"delivery": "api"
}
Rules:
key: matches^[a-z][a-z0-9_]{1,31}$, must NOT equal any standard value, and is unique within the list.label(Arabic) andlabel_en(English): required, 2–60 chars each.kind: required, one ofcard|wallet|bnpl|transfer|cash|other.- Every
custom_methodskey MUST appear inpayment_methods(and vice versa: any non-standardpayment_methodsentry must have a matchingcustom_methodsentry). The create wizard appends custom keys intopayment_methodsautomatically — you never tick them by hand. custom_methodsis omitted from the manifest entirely when empty.
Consumer apps see custom methods (with their labels) via
GET /api/apps/payments/methods exactly like the standard ones, and pass
the key when creating a charge.
Scopes (granted automatically)
Picking integrationType=payment auto-attaches:
payments.charges.update(required — lets you update charge status)payments.charges.read(optional — read charge status)
Path 1: delivery=api
okta-web sends your endpoint a signed charge request:
POST https://your-gateway.example/okta/charge
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Charge-Id: <uuid>
X-Okta-Idempotency-Key: <key>
{
"method": "tabby",
"amount": 150.00,
"currency": "SAR",
"description": "Annual subscription",
"customer": { "name": "Ahmed", "phone": "+966500000000", "email": "a@example.com" },
"metadata": {},
"charge_ref": "chg_01H..."
}
- Signature:
X-Okta-Signature=hmac_sha256("<timestamp>.<body>", signingSecret)— same scheme as notification. - No automatic retry from okta-web; idempotency is handled via the
X-Okta-Idempotency-Keyheader so a replayed request never double-charges.
Expected response (2xx):
{
"status": "pending",
"provider_ref": "tabby_pay_123",
"redirect_url": "https://checkout.tabby.ai/..."
}
statusis one ofpending|paid|failed.redirect_urlis optional — for flows that redirect the customer.
Status updates flow back later via
POST /api/apps/payments/charges/{ref}/status using the provider's own
installation token (scope payments.charges.update).
Path 2: delivery=embedded
You ship a class inside okta-web implementing
App\Contracts\PartnerPaymentProvider (charge / refund / supports /
isConfigured). Never throw from charge() — wrap in try/catch and return
a failure result.
Path 3: delivery=hybrid
The platform tries the embedded provider first and falls back to the api
path on isConfigured()=false or a non-permanent failure.
Consuming payments from your app (Embedded only)
Strict rule: payment consumption (create / read / list / refund a charge) is available exclusively to Embedded apps. Any attempt from an External app is rejected with
payment_consumption_embedded_only. Reason: reacting to a charge outcome happens via an in-process event, not a webhook — an externally-hosted app has no way to listen for an internal Laravel event, so it would stay blind to the final outcome if allowed to charge. Payment providers themselves (integrationType=payment) don't consume either — their role is only to receive the charge call and push status updates (payments.charges.update), never to initiate charges for other apps.
How it works, end to end
- The tenant installs one or more payment providers
(
integrationType=payment) from/partner-apps/payment/providers— an administrative step the tenant takes; your app has no part in it. - Your embedded app requests a charge through the uniform
CreateChargecontract without knowing which gateway the tenant actually installed. - The platform (
ResolveTenantPaymentProvider) resolves the tenant's active provider that supports the requested method and executes the charge through it (the same api/embedded/hybrid paths described above from the provider's side). - Your app follows up on the result in two complementary ways:
- Immediately, from the response itself
(
status: paid|pending|failed). - Later, via the
ChargeUpdatedevent the platform fires on every status transition — needed forpendingcharges that later becomepaidthrough BNPL gateways once the customer completes checkout.
- Immediately, from the response itself
(
Required scopes
Pick these from the scope picker when building your app (payments
resource):
| Scope | Grants |
|---|---|
payments.methods.read |
Read the tenant's available payment methods |
payments.charges.create |
Create a charge — requires idempotency_key |
payments.charges.read |
Read one charge or list all of your own charges |
payments.refunds.create |
Refund a charge you previously created |
payments.charges.updateis not in the consumer picker — it is exclusive to payment providers themselves (auto-granted only forintegrationType=payment, to update the status of the charges they execute). Calling it from a consumer context is rejected withpayment_status_update_provider_only.
Services (in-process — the primary path)
Same pattern as the rest of App\Services\PartnerApi\* — no HTTP, no
exceptions for normal control flow, scanner-clean. Every service returns
an array (no Eloquent, no schema leaking):
| Service | Signature | HTTP equivalent | Scope |
|---|---|---|---|
ListAvailableMethods |
__invoke(): array |
GET /api/apps/payments/methods |
payments.methods.read |
CreateCharge |
__invoke(array $input): array |
POST /api/apps/payments/charges (+ Idempotency-Key) |
payments.charges.create |
GetCharge |
__invoke(string $chargeRef): array |
GET /api/apps/payments/charges/{ref} |
payments.charges.read |
ListCharges |
__invoke(array $filters = []): array |
GET /api/apps/payments/charges |
payments.charges.read |
RefundCharge |
__invoke(string $chargeRef, array $input): array |
POST /api/apps/payments/charges/{ref}/refund |
payments.refunds.create |
ListCharges returns only the charges your own app created
(filtered by your installation id) — supported filters: status,
method, from, to, plus page/per_page (up to 100), newest first.
Example 1 — selling a feature/service from your app
// Modules/SchoolPortal/app/Services/Reports/SellPremiumReport.php
namespace Modules\SchoolPortal\Services\Reports;
use App\Services\PartnerApi\Payments\CreateCharge;
final class SellPremiumReport
{
public function __construct(
private readonly CreateCharge $createCharge,
) {}
/**
* @return array<string, mixed>
*/
public function __invoke(string $orderId, float $amount): array
{
return ($this->createCharge)([
'method' => 'tabby',
'amount' => $amount,
'currency' => 'SAR',
'description' => 'Premium analytics report',
'metadata' => [
'feature' => 'premium_reports',
'order_id' => $orderId,
],
'idempotency_key' => "premium-report-{$orderId}",
]);
}
}
From the calling Livewire component:
$result = app(SellPremiumReport::class)($orderId, 49.00);
if ($result['status'] === 'paid') {
// Unlock the feature immediately — the response came back paid
// synchronously (card/mada).
$this->dispatch('feature-unlocked');
return;
}
if ($result['status'] === 'pending' && $result['redirect_url']) {
// BNPL gateways (Tabby/Tamara) — send the customer to finish there.
$this->redirect($result['redirect_url']);
return;
}
if ($result['status'] === 'failed') {
$this->addError('payment', __('Payment failed. Try another method.'));
return;
}
// pending with no redirect_url — wait for the ChargeUpdated event (example 2).
Never re-call CreateCharge on a page reload or a network failure — always
pass the same idempotency_key; the platform returns the same stored
charge instead of charging twice (no automatic retry on charging).
Example 2 — unlocking a feature when the payment completes (ChargeUpdated)
A charge that started pending (BNPL gateway) later transitions to
paid/failed outside the original CreateCharge request — no webhook
reaches you for this (consumption is in-process only); listen for the
event inside your own app's ServiceProvider boot():
// Modules/SchoolPortal/app/Providers/SchoolPortalServiceProvider.php
namespace Modules\SchoolPortal\Providers;
use App\Events\PartnerPayments\ChargeUpdated;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\SchoolPortal\Services\Reports\GrantOrRevokePremiumReport;
class SchoolPortalServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(function (ChargeUpdated $event): void {
// Mandatory filter — the platform broadcasts the same event
// to every listening app, not just yours.
if ($event->consumerModuleSlug !== 'school-portal') {
return;
}
if (($event->metadata['feature'] ?? null) !== 'premium_reports') {
return;
}
$orderId = $event->metadata['order_id'] ?? null;
match ($event->status) {
'paid' => app(GrantOrRevokePremiumReport::class)->grant($orderId),
'refunded', 'partially_refunded' => app(GrantOrRevokePremiumReport::class)->revoke($orderId),
default => null, // failed — nothing further; pending never reaches here
};
});
}
}
Important notes about the event:
- It only fires on an actual status change — you will not receive it
for the initial
pendingcreation (that is not a "transition"), only forpending→paid|failedor a laterpaid→refunded|partially_refunded. A failed refund does not fire it. - Make your handler idempotent — check your own current state before granting/revoking, in case of a listener restart or rare duplicate.
- Always handle
refunded/partially_refunded— the event may reach you later for a charge that previously unlocked a feature; turn it off. - Available fields:
chargeRef,tenantId,consumerModuleSlug,previousStatus,status,method,amount,currency,refundedAmount,metadata,occurredAt.
Example 3 — reconciliation
use App\Services\PartnerApi\Payments\ListCharges;
$page = app(ListCharges::class)([
'status' => 'paid',
'from' => '2026-07-01',
'to' => '2026-07-31',
'per_page' => 100,
]);
foreach ($page['data'] as $charge) {
// $charge['charge_ref'] is the only public identifier — never a numeric id
// Match $charge['metadata']['order_id'] against your own records
}
$page['pagination']; // total / per_page / current_page / last_page
Refunds
use App\Services\PartnerApi\Payments\RefundCharge;
$result = app(RefundCharge::class)($chargeRef, ['amount' => null]); // null = full refund
RefundCharge guards:
- The charge must be owned by your app (created by it — otherwise it is treated as not found).
- Its current status must be
paid(no refund onpending/failed). amount≤ the remaining refundable balance (amount − refunded_amount) — previous partial refunds are respected automatically.- No automatic retry here either; a failed refund does not fire
ChargeUpdated.
Per-version overrides
Every payment field above is overridable per version from the Integration tab. Blank fields inherit module-level values at manifest-build time.