Adding a payment provider
How a new payment provider joins HyoNova Commerce — one adapter module with a manifest, canonical events and contract tests, registered in one list, without touching the core.
A payment method appears only after HyoNova has set up its payment provider.
For HyoNova engineers. A payment provider is one module in its own folder plus one entry in the provider list. The database, the commerce core, the subscription and invoice logic, the API and the screens already work from the provider's manifest and capabilities, so none of them change.
What not to touch#
- The provider contract, registry, money, HTTP client and signature helpers.
- The commerce core: never branch on a provider name.
- The database schema: no columns or tables for a provider.
- The shared vocabulary of capabilities, methods and statuses.
- Control Center and product screens: they render from the manifest.
Steps#
- Create the folder.
packages/payments/providers/<key>/index.ts, where<key>is lower case letters, digits and hyphens. - Write the manifest. Key, display name,
externalornative, environments, capabilities, methods, currencies, how renewals are collected (providerorinvoice_per_cycle), settings and secret fields with English and Arabic labels and help, the webhook path, signature header and event types, the documentation URL, the API base URL and the allow-listed hosts, and whether it is the Merchant of Record. - Implement the adapter. Required:
health,createCheckout,verifyWebhook,normalizeWebhook. Optional methods each need their capability:getSubscription(reconciliation),changeSubscription(proration,seat_quantity),cancelSubscription(cancel_at_period_end),resumeSubscription(resume),createBillingPortal(customer_portal),refund(refunds,partial_refunds, orrefund_recordreturning a manual refund),invoiceDocument(invoice_documents),paymentInstructions(manual_confirmation). - Normalize events. Map every documented provider event to canonical events; suffix ids when one notification yields several events; convert amounts only with the money helpers; map anything else to
ignored. - Verify signatures over the raw body with a constant-time comparison; reject a missing header or secret.
- Register the module in
packages/payments/providers/index.ts. The registry validates the manifest at start-up. - Add an instance in Control Center (or a disabled seed row), configure it, and run the connection test.
- Route a method to it, or give it the
secondaryrole, and add price mappings when the provider collects renewals itself. - Test. Copy the adapter contract suite
tests/payments-contract.test.tsfor the new adapter, with fixtures from the provider's official documentation. The commerce integration tests must pass unchanged.
Error rules#
Throw the typed payment error with a canonical code. Use PAYMENT_PROVIDER_UNAVAILABLE only when the provider was not reached or created nothing — that is what allows the safe fallback. Messages must be safe to show: no payload, credential or stack.
Minimal skeleton#
export const acmepayManifest: PaymentProviderManifest = {
key: 'acmepay',
displayName: 'AcmePay',
kind: 'external',
environments: ['test', 'live'],
capabilities: ['checkout', 'one_time_payment', 'invoices', 'webhooks', 'refunds', 'health_check'],
methods: ['card'],
currencies: ['USD', 'EUR'],
recurringCollection: 'invoice_per_cycle',
settings: [/* non-secret fields with labels and help in English and Arabic */],
secrets: [/* apiKey, webhookSecret: minLength, maskable, required */],
webhook: { path: 'acmepay', signatureHeader: 'X-Acme-Signature', eventTypes: ['payment.succeeded', 'payment.failed'] },
documentation: 'https://docs.acmepay.example/api',
apiBaseUrl: 'https://api.acmepay.example',
apiHosts: ['api.acmepay.example'],
defaultEnabled: false,
merchantOfRecord: false,
};
function create(ctx: ProviderRuntimeContext): PlatformPaymentProvider {
return {
manifest: acmepayManifest,
async health() { /* one authenticated read-only call → sandbox_connected / live_connected */ },
async createCheckout(request) { /* hosted checkout; provider metadata carries request.checkoutSessionId */ },
async refund(request) { /* POST with Idempotency-Key = request.idempotencyKey */ },
verifyWebhook({ rawBody, headers }) { /* HMAC over rawBody, safeEqual, else WEBHOOK_SIGNATURE_INVALID */ },
normalizeWebhook(verified) { /* → NormalizedBillingEvent[] */ },
};
}
export const acmepay: PaymentProviderModule = { manifest: acmepayManifest, create };The full runnable skeleton and the field-by-field manifest reference are in the engineering guide that ships with the code.
Was this page helpful?
Thanks. Page ratings are not collected yet, so nothing was sent. If something is missing or wrong, see the Get help page.