Service providers
Every outbound service sends through exactly one active provider. A provider's keys are saved once and shared by every service it supports, so OneSignal is set up a single time and then picked separately for email, SMS and push.
| Service | Providers | Active provider is stored in |
|---|---|---|
| SMTP, OneSignal, Mock | service_provider_selections (service = 'email') | |
| SMS | Taqnyat, OneSignal, Twilio, Mock | service_provider_selections (service = 'sms') |
| Push | OneSignal, Mock | service_provider_selections (service = 'push') |
| Payments | Moyasar, Paymob, Mock | The plugin.payment.moyasar row in platform_settings (provider and "accept new payments"); each gateway's environments and key rotation live in its own row (plugin.payment.moyasar, plugin.payment.paymob) |
SIM operators and eSIM providers are different: several can be on at once, and customers only see plans from the ones that are on. They are switched on and off from the same Plugins screen, on its SIM & eSIM tab.
Providers are code adapters. The complete list is SERVICE_PROVIDER_CATALOG in packages/shared/src/types/service-providers.ts; the admin portal configures and selects from it and cannot add new ones.
How it works
- Keys live in
service_provider_credentials, one row per provider, as one AES-256-GCM encrypted JSON document. Secret fields (password,restApiKey,apiKey,authToken) are write-only: the API reports only which ones hold a value. Saving a secret field as an empty string keeps the stored value;nullclears it. A provider that is active somewhere cannot be left with a required key missing (422 SERVICE_PROVIDER_IN_USE). - Service options live on the service's selection row (
settings): email from-name and from-address, SMS sender ID, push Android channel ID. They carry over when the provider changes. - One active provider is guaranteed by the primary key on
service_provider_selections.service. Switching is refused when the provider cannot serve the service (SERVICE_PROVIDER_UNSUPPORTED), its keys are incomplete (SERVICE_PROVIDER_NOT_CONFIGURED), or it is the development mock in production (SERVICE_PROVIDER_MOCK_FORBIDDEN). - Resolution happens per send in
ServiceProviderRegistry(apps/api/src/services/service-provider-registry.ts). OTP codes, B2B link delivery and every notification channel ask the registry for the active provider. Each process caches the registry for 5 seconds, so a switch reaches the API and every worker within that window. - Audit: every switch, key save and option change writes an
admin.plugin.*audit row holding the redacted registry state; key saves record field names only, never values. - Both tables have RLS enabled with deny-all policies (
packages/database/drizzle/custom/0029_service_provider_registry_rls.sql). Only the API's service layer reads them.
Earlier configuration
Until an owner saves through the registry, it falls back to the configuration that existed before it:
- Email: the SMTP row in
plugin_settings(key = 'email'), including its from-name and address. - SMS:
SMS_PROVIDER,TWILIO_*andTAQNYAT_*environment variables. - Push: the mock outside production, and nothing in production.
The first save for a provider or service writes the new tables, and the fallback stops applying to it. The environment variables can be removed once the SMS selection and keys are saved in the admin portal.
OneSignal
One App ID and REST API key serve all three channels. Every send carries an idempotency key (the delivery id for notifications), so a retried job cannot send twice within OneSignal's dedupe window.
- Push targets the account, not a device: the mobile app reads
GET /push/config, initializes OneSignal with the returned App ID, and logs in with the user id as the OneSignal external id. A push to an account with no signed-in device ends assuppressedwith reasonno_address, not as a failure. - One alert per notification on the phone: while the app is open, OneSignal does not show the push and the app's own inbox sync raises the local alert as before. While the app is away, the push is the alert, and on return the sync skips local alerts for anything created while it was away. Tapping a push opens the same screen as tapping the inbox row (the push carries the notification id, template and link).
- SMS uses the SMS sender ID as
sms_from. - Email uses the email service's from-address and name, and reaches unsubscribed addresses because these messages are transactional. OneSignal email has no attachments, so a message with an inline QR image is refused (
ONESIGNAL_ATTACHMENTS_UNSUPPORTED) rather than sent with a broken image; keep SMTP active for email if eSIM QR emails must go out. - HTTP 429 and 5xx are retried; other refusals are terminal.
Admin API
All routes are super_admin only and return the updated overview where they change something.
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/plugins/services | Services, their active provider and choices, and each provider's key status |
| PUT | /admin/plugins/services/:service/provider | Make a provider active for a service |
| PATCH | /admin/plugins/services/:service/settings | Save the service's own options |
| PATCH | /admin/plugins/providers/:provider | Save a provider's keys |
| POST | /admin/plugins/providers/:provider/check | Check the connection with the saved keys |
| POST | /admin/plugins/providers/:provider/test | Send one test message through a provider, active or not |
| GET | /admin/plugins/operators | SIM operators and eSIM providers with their on/off state |
| PATCH | /admin/plugins/operators/:id | Switch one on or off |
Payment gateway keys stay in the checkout settings sheet (catalog entries marked ownSettingsPage: true) because they carry sandbox and production profiles and key rotation; they are saved through PATCH /admin/plugins/payment/{moyasar|paymob} (see payments). Switching payments between Mock, Moyasar and Paymob goes through the same PUT .../payment/provider route. It is refused with SERVICE_PROVIDER_NOT_CONFIGURED when the gateway's selected environment has no keys, and choosing a provider also turns payments on.
Adding a provider
- Write the adapter in
apps/api/src/plugins/againstEmailProvider,SmsProviderorPushProvider, includingsendNotificationso the notification workers can use it. - Add its id to
SERVICE_PROVIDER_IDSand an entry toSERVICE_PROVIDER_CATALOGwith its services and key fields (mark secretssecret: true). - Build it from its saved keys in the matching
build*method ofServiceProviderRegistry. - Add tests beside the existing
onesignal-providers.test.tsandservice-provider-registry.test.ts.
The admin screen renders email, SMS and push key fields from the catalog, so no UI change is needed for a new provider of those services. Payment gateways are the exception; see below.
Adding a payment gateway
- Write the adapter in
apps/api/src/plugins/againstPaymentProvider(seepaymob-payment-provider.ts), with its own settings service for environments and credential generations (payment-credential-history.ts) and a reconcile function dispatched fromreconcilePaymentinservices/payment-reconciliation.ts(webhooks reach it through thepayment-reconciliationqueue; add the id toPaymentReconciliationJob.provider). - Add its id to
PAYMENT_GATEWAY_IDSinpackages/shared/src/types/payment.ts, toSERVICE_PROVIDER_IDS, and a payment-only catalog entry withownSettingsPage: true. - Build it in
createPaymentProvider(apps/api/src/plugins/payment-provider-factory.ts) and report its status fromservices/payment-gateways.ts. - Add its key fields to the checkout settings sheet in the admin portal (
plugins/_components/payment-settings-sheet.tsx); payment key fields are not rendered from the catalog. - Add tests beside
paymob.test.ts,paymob-routes.test.tsandpaymob-reconciliation.test.ts.