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.delivery must be api, embedded or hybrid.
  • delivery=api/hybrid ⇒ payment.api.charge_endpoint must be HTTPS.
  • delivery=embedded/hybrid ⇒ payment.embedded.provider_class must be a valid FQCN.
  • A payment block is rejected unless integrationType=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) and label_en (English): required, 2–60 chars each.
  • kind: required, one of card | wallet | bnpl | transfer | cash | other.
  • Every custom_methods key MUST appear in payment_methods (and vice versa: any non-standard payment_methods entry must have a matching custom_methods entry). The create wizard appends custom keys into payment_methods automatically — you never tick them by hand.
  • custom_methods is 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-Key header so a replayed request never double-charges.

Expected response (2xx):

{
  "status": "pending",
  "provider_ref": "tabby_pay_123",
  "redirect_url": "https://checkout.tabby.ai/..."
}
  • status is one of pending | paid | failed.
  • redirect_url is 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

  1. 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.
  2. Your embedded app requests a charge through the uniform CreateCharge contract without knowing which gateway the tenant actually installed.
  3. 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).
  4. Your app follows up on the result in two complementary ways:
    • Immediately, from the response itself (status: paid|pending|failed).
    • Later, via the ChargeUpdated event the platform fires on every status transition — needed for pending charges that later become paid through BNPL gateways once the customer completes checkout.

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.update is not in the consumer picker — it is exclusive to payment providers themselves (auto-granted only for integrationType=payment, to update the status of the charges they execute). Calling it from a consumer context is rejected with payment_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 pending creation (that is not a "transition"), only for pending→paid|failed or a later paid→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 on pending/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.


View the whole guide on one page · ↧ Raw Markdown