Skip to content

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.

ServiceProvidersActive provider is stored in
EmailSMTP, OneSignal, Mockservice_provider_selections (service = 'email')
SMSTaqnyat, OneSignal, Twilio, Mockservice_provider_selections (service = 'sms')
PushOneSignal, Mockservice_provider_selections (service = 'push')
PaymentsMoyasar, Paymob, MockThe 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; null clears 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_* and TAQNYAT_* 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 as suppressed with reason no_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.

MethodPathPurpose
GET/admin/plugins/servicesServices, their active provider and choices, and each provider's key status
PUT/admin/plugins/services/:service/providerMake a provider active for a service
PATCH/admin/plugins/services/:service/settingsSave the service's own options
PATCH/admin/plugins/providers/:providerSave a provider's keys
POST/admin/plugins/providers/:provider/checkCheck the connection with the saved keys
POST/admin/plugins/providers/:provider/testSend one test message through a provider, active or not
GET/admin/plugins/operatorsSIM operators and eSIM providers with their on/off state
PATCH/admin/plugins/operators/:idSwitch 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 ​

  1. Write the adapter in apps/api/src/plugins/ against EmailProvider, SmsProvider or PushProvider, including sendNotification so the notification workers can use it.
  2. Add its id to SERVICE_PROVIDER_IDS and an entry to SERVICE_PROVIDER_CATALOG with its services and key fields (mark secrets secret: true).
  3. Build it from its saved keys in the matching build* method of ServiceProviderRegistry.
  4. Add tests beside the existing onesignal-providers.test.ts and service-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 ​

  1. Write the adapter in apps/api/src/plugins/ against PaymentProvider (see paymob-payment-provider.ts), with its own settings service for environments and credential generations (payment-credential-history.ts) and a reconcile function dispatched from reconcilePayment in services/payment-reconciliation.ts (webhooks reach it through the payment-reconciliation queue; add the id to PaymentReconciliationJob.provider).
  2. Add its id to PAYMENT_GATEWAY_IDS in packages/shared/src/types/payment.ts, to SERVICE_PROVIDER_IDS, and a payment-only catalog entry with ownSettingsPage: true.
  3. Build it in createPaymentProvider (apps/api/src/plugins/payment-provider-factory.ts) and report its status from services/payment-gateways.ts.
  4. 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.
  5. Add tests beside paymob.test.ts, paymob-routes.test.ts and paymob-reconciliation.test.ts.

Internal documentation - Activation System