Skip to content

External Integrations

Every integration sits behind an interface owned by exactly one module. Provider SDK types, webhook payloads, and credentials never leak outside the owning module. All outbound calls are made from queued jobs unless the user is actively waiting on the result (e.g., creating a payment session).

Integration Owner module Phase Sandbox
MyFatoorah / Tap (PSP) Financial MVP Yes — both provide full sandboxes incl. KNET test cards
Firebase Cloud Messaging Communication MVP Yes — separate Firebase project per environment
Mailgun / SES (email) Communication MVP Yes — Mailgun sandbox domain / SES sandbox mode
Google Maps / Places Vendor, Event MVP Yes — API key with quota; billable above free tier
Google / Apple OAuth Identity MVP Yes — test clients per environment
S3-compatible storage + CDN Platform MVP Yes — MinIO locally, staging bucket
Meilisearch Platform MVP Yes — local/staging instances, disposable indexes
WhatsApp Business API Communication Scale Meta sandbox numbers
Google Calendar sync Vendor Scale Yes — OAuth test project

Payment Service Provider

Purpose. All money movement: vendor subscription charges, booking deposit payments, refunds. Methods: KNET (primary), Visa/Mastercard, Apple Pay, Google Pay. Currency at launch: KWD, 3 decimal places — amounts are always sent to the PSP in KWD with exactly 3 dp (e.g., 150.000), matching our DECIMAL(12,3) storage (see tables.md).

Provider. MyFatoorah or Tap Payments (final selection during integration spike). Both support: hosted payment page (redirect) and embedded iframe, KNET + cards + wallets, webhooks, refunds, and sandbox environments with KNET test credentials.

Phase. MVP.

Flow

sequenceDiagram
    participant C as Client (web/app)
    participant API as Laravel API (Financial)
    participant PSP as MyFatoorah / Tap
    C->>API: POST /api/v1/payments (Idempotency-Key)
    API->>API: create Payment row (status=pending)
    API->>PSP: create payment session (amount KWD 3dp, callback URLs)
    PSP-->>API: session id + payment URL
    API-->>C: payment_url (hosted page / iframe src)
    C->>PSP: completes KNET / card / wallet flow
    PSP-->>C: redirect to success/failure callback URL
    PSP->>API: POST /api/v1/webhooks/psp (signed)
    API->>API: verify signature, verify amount & currency,<br/>idempotent transition pending → succeeded/failed
    API->>API: emit PaymentSucceeded / PaymentFailed

Never trust the redirect. The client redirect updates UI only; the payment status transitions exclusively on (a) the verified webhook, or (b) a server-to-server status query (GetPaymentStatus) used by the reconciliation job.

Webhook verification

  • Verify the provider signature header (HMAC-SHA256 over raw body with the webhook secret — exact header name per provider adapter).
  • Reject if timestamp skew > 5 minutes (replay protection) where the provider supplies one.
  • Look up our payments row by our reference (we always pass payment.public_id as the merchant reference); reject unknown references.
  • Verify amount and currency match the stored payment. Mismatch → mark flagged, alert ops, do not confirm.
  • Process idempotently: a webhook for an already-final payment is acknowledged with 200 and ignored.
  • Always return 200 fast; heavy work (booking confirmation, notifications) is dispatched to the payments queue.

Refunds

  • Initiated by admin (MVP) via POST /api/v1/admin/refunds; customer-requested refunds create a refund_requested booking state first (see Booking SRS).
  • Full or partial (KWD 3dp). Refund row references the original payment; provider refund ID stored on completion.
  • Refund status also confirmed by webhook/reconciliation, never assumed from the API call's synchronous response.

Failure & retry strategy

Failure Handling
Session creation fails (5xx/timeout) Retry ×2 with backoff in-request (total < 8 s), then return PAYMENT_PROVIDER_UNAVAILABLE; payment row marked failed
Webhook never arrives Reconciliation job: every 15 min query status of pending payments older than 10 min; expire after 24 h
Duplicate webhook Idempotent no-op (payment already final)
Refund API failure Queued job retries (backoff up to 12 h), then dead-letter + ops alert; refund stays processing

Abstraction interface

interface PaymentProviderInterface
{
    /** Create a hosted-page session. Amount is minor-unit-safe Money (KWD, 3dp). */
    public function createPaymentSession(PaymentIntentData $intent): PaymentSession; // url, provider_ref

    public function getPaymentStatus(string $providerRef): ProviderPaymentStatus;

    public function refund(string $providerRef, Money $amount, string $idempotencyKey): ProviderRefundResult;

    /** Verify signature and parse the raw webhook into a provider-neutral event. */
    public function parseWebhook(Request $request): PspWebhookEvent; // throws InvalidSignatureException
}

Adapters: MyFatoorahProvider, TapProvider. Selected per country via config (payments.providers.KW = myfatoorah).


Firebase Cloud Messaging (Push)

Purpose. Push notifications to Flutter apps (and web push later): new quotation received, RFQ received (vendor), message received, booking/payment status changes, subscription expiry warnings.

Phase. MVP.

Design.

  • Device tokens registered via POST /api/v1/notifications/devices (token, platform, locale); stored per user, pruned on FCM UNREGISTERED errors.
  • One notification engine in the Communication module: a Notification is persisted first (in-app inbox is source of truth), then fan-out jobs deliver via enabled channels (push, email) respecting user channel preferences.
  • FCM HTTP v1 API with service-account auth; localized title/body chosen by the recipient's locale (ar/en) at send time; deep-link payload (type, entity_id) for client routing.

Failure & retry. Queued (notifications queue), 3 retries with backoff; UNREGISTERED/INVALID_ARGUMENT deletes the token; FCM outage degrades to in-app + email only — push is never load-bearing for correctness.

Sandbox. Separate Firebase projects for local/staging/production; staging apps point at the staging project.

Interface sketch.

interface PushChannelInterface
{
    public function send(PushMessage $message, DeviceTokenCollection $tokens): PushResult; // per-token outcomes
}

Email Provider (Mailgun / SES)

Purpose. Transactional email: OTP codes, email verification, welcome, quotation received, booking confirmations, payment receipts, subscription invoices/renewal reminders, admin alerts.

Phase. MVP. (Marketing/digest campaigns: Scale, likely a separate tool.)

Design.

  • Laravel mail with the Mailgun (or SES) transport; provider swap is a config change.
  • Bilingual templates; direction (RTL/LTR) and language chosen per recipient locale. From-domain mail.yam3at.com with SPF, DKIM, DMARC configured before launch.
  • OTP emails bypass user notification preferences (security-critical); everything else respects preferences.

Failure & retry. Queued, 5 retries over ~2 h; bounce/complaint webhooks mark addresses suppressed; OTP email failure surfaces to the client as OTP_DELIVERY_FAILED so the user can retry with SMS/phone (per Auth SRS).

Sandbox. Mailgun sandbox domain / SES sandbox (verified recipients only); local dev uses Mailpit — no real mail leaves a developer machine.

Interface. Laravel's Mailer contract is sufficient; no custom abstraction beyond notification-channel classes.


Google Maps / Places

Purpose. (a) Vendor onboarding: place autocomplete + map pin for branch locations; (b) listing pages: static map/embed; (c) event location entry; (d) distance sorting in search (lat/lng fed to Meilisearch _geo).

Phase. MVP (autocomplete + geocoding + static display). Distance-based "near me" search: MVP if timeline allows, else first Scale iteration.

Design.

  • Client-side: Places Autocomplete (session tokens to control billing) in web and Flutter; the client submits place_id + display text.
  • Server-side: geocoding job resolves/validates place_id → lat/lng, formatted address (ar + en via language param), stored on vendors/events. Server never trusts client-provided coordinates alone.
  • API keys: separate browser key (HTTP-referrer restricted), Android/iOS keys (app-restricted), and server key (IP restricted). Daily quota alarms.

Failure & retry. Geocoding job retries ×3; on persistent failure the record keeps the text address and is flagged for admin review — maps degrade to text, nothing blocks onboarding. Autocomplete outage degrades to free-text address entry.

Sandbox. No true sandbox; a dev API key with low quota caps + billing alerts.

Interface sketch.

interface GeocodingServiceInterface
{
    public function resolvePlace(string $placeId, string $lang = 'ar'): ResolvedPlace; // lat, lng, formatted ar/en, city match
}

Google / Apple OAuth (Social Sign-in)

Purpose. Customer registration/login with Google and Apple (Apple mandatory on iOS when social login is offered). Vendors use email/phone + business verification instead.

Phase. MVP.

Design.

  • Token-verification pattern, not server-side redirect: clients obtain an ID token natively (Google Identity Services / Sign in with Apple), then call POST /api/v1/auth/social with {provider, id_token}.
  • Server verifies the token: signature against provider JWKS (cached), aud = our client IDs, iss, exp. Extract stable subject (sub), email, email-verified flag.
  • Account linking: match on provider sub first, then verified email; new users are created with provider + provider_sub recorded. Apple's private-relay emails are supported (email may be relay or absent on re-auth — sub is the durable key).

Failure & retry. JWKS fetch failures fall back to cached keys (24 h); invalid tokens → AUTH_INVALID_SOCIAL_TOKEN. No retries — auth is synchronous.

Sandbox. Separate OAuth clients per environment; Apple requires the paid developer account for Sign in with Apple even in staging.


S3-Compatible Storage & CDN

Purpose. All binary assets: vendor gallery images and videos, package images, commercial license documents (private), RFQ/quotation/message attachments (private), review photos, CMS assets.

Phase. MVP.

Design.

  • Two buckets: yam3at-public (listing media, served via CDN with long cache TTLs and image-resizing at the edge or via pre-generated variants) and yam3at-private (licenses, attachments — no public ACLs, ever).
  • Uploads are direct-to-S3 via pre-signed PUT URLs issued by POST /api/v1/media (declares filename, mime, size; server validates policy per context and returns the signed URL + a media row in pending state). Client uploads, then confirms; a queued job validates the object (real content-type sniffing, size, image sanity), generates thumbnails, strips EXIF, and flips status to ready. See Security — file uploads.
  • Private files are served only via short-lived (≤ 5 min) pre-signed GET URLs after an authorization check (e.g., only conversation participants can fetch a message attachment).

Failure & retry. Upload confirmation job retries ×3; objects never confirmed within 24 h are garbage-collected. CDN origin failure falls back to direct signed S3 URLs (feature flag).

Sandbox. MinIO in Docker Compose locally; dedicated staging bucket with lifecycle rules that purge after 30 days.

Interface. Laravel Storage (S3 driver) + an internal MediaService (create, confirm, attach(mediaId, entity), signedUrl).


Meilisearch Indexing Pipeline

Purpose. Instant, typo-tolerant, faceted, bilingual search over vendors, services, and packages; geo sorting; featured boosting.

Phase. MVP.

Design.

  • Indexes: vendors, services, packages (per-country suffix when expansion arrives: vendors_kw).
  • Documents are denormalized projections, not table dumps: a vendor document carries name_ar/en, category ids + names, city, _geo, rating aggregate, review count, min price, plan tier (for featured boost), is_approved.
  • Sync via Laravel Scout queued (search queue): model observers dispatch index/update/delete jobs; rating recomputation and approval events also trigger re-index via domain-event listeners (VendorApproved, ReviewPublished).
  • Only approved + active-subscription-visible entities are indexed; suspension/rejection triggers immediate delete-from-index.
  • Relevance config in code (committed settings files applied by a deploy task): searchable attributes ordered (name_ar, name_en, category_names, description_*), filterable (category_ids, city_id, price_min, rating, _geo), sortable (rating, price_min, created_at), synonyms list for Arabic orthographic variants, ranking rules with featured_rank before typo.

Failure & retry. Index jobs retry ×5 with backoff; Meilisearch down → search endpoint returns SEARCH_UNAVAILABLE with a degraded MySQL LIKE fallback for basic category browsing (flagged, temporary). Full rebuild at any time: php artisan scout:import (index is disposable, MySQL is the source of truth).

Sandbox. Local/staging instances; indexes rebuilt by seeders.

Interface sketch.

interface SearchIndexerInterface
{
    public function upsert(SearchableDocument $doc): void;
    public function remove(string $index, string $documentId): void;
    public function search(SearchQuery $query): SearchResults; // used by the Marketplace search facade
}

Related pages: Architecture Overview · ADRs · Security · API Conventions · Endpoints