Building fastify-satim: Drop-in SATIM Payment Integration for Fastify
Transforming repetitive payment boilerplate into a plug-and-play Fastify plugin
When I set out to integrate the SATIM payment gateway (Algeria's national payment platform) into a Fastify server, I noticed the same boilerplate popping up in every project. Each integration required manual setup: managing credentials, crafting request handlers for payment register and confirm operations, validating request payloads, and handling errors and logging. Having already built a TypeScript SDK for SATIM, I realized the next logical step was a Fastify plugin to make SATIM integration a plug-and-play experience. In this case study, I'll share how I built fastify-satim, a Fastify plugin that provides a drop-in SATIM payment integration for Node.js backends, and how it reduces repetitive code while following Fastify best practices. f
The Problem: Repetitive Fastify Integration Boilerplate
Before this plugin existed, integrating SATIM into a Fastify app meant writing a lot of repetitive code. Every project needed to:
- Configure the SATIM client : Load credentials (username, password, terminal ID) and endpoints, often from environment variables.
- Register payment routes : Set up routes for initiating payments (registering an order), confirming payments after customer redirection, and possibly refunding payments, each with similar logic.
- Validate requests : Ensure that required fields like orderNumber, amount, and returnUrl are present and correctly formatted to avoid gateway errors.
- Handle errors and logging : Map gateway errors to HTTP responses, avoid leaking sensitive info, and log warnings for any misuse or security issues (e.g. using GET where POST is required).
- Repeat for every service : If multiple Fastify services or tenants needed SATIM, this boilerplate would be duplicated with slight variations.
In short, without a plugin, every Fastify developer integrating SATIM was reinventing the wheel. Even with the SATIM-TS SDK simplifying direct API calls, you still had to wire it into Fastify manually. I often found myself copying and pasting similar handlers, schemas, and error handling across projects. This repetitive pattern motivated me to encapsulate all that into a reusable plugin.
Plugin Architecture and Encapsulation
I designed fastify-satim as a typical Fastify plugin that cleanly encapsulates the SATIM integration logic. Using Fastify's plugin system ensured that the solution would play nicely with the framework's encapsulation and lifecycle. In practice, this meant using fastify-plugin to wrap the plugin function, so that the SATIM client instance becomes available in any scope of the app (root or child plugins).
By decorating the Fastify instance with a new property, developers can access the SATIM client via fastify.satim in their route handlers, without global variables or manual wiring. This decorator is available everywhere the Fastify instance is available, thanks to proper encapsulation via fastify-plugin.
Inside the plugin, the registration function takes care of setup and safety checks. For example, it throws an error if you accidentally register the plugin twice on the same Fastify instance, preventing conflicting configurations. It also refuses to start if no configuration is provided you must supply either an existing SATIM client, a config object, environment flag, or a getClient function for multi-tenant setups. This guarantees that the plugin always has what it needs to operate and saves developers from debugging why fastify.satim might be undefined.
Moreover, the plugin taps into Fastify's lifecycle by setting a global error handler for SATIM-related errors. Using fastify.setErrorHandler, I plugged in a centralized error mapper so that any error thrown during a SATIM operation is caught and translated into an appropriate HTTP response. This keeps error-handling logic out of individual route handlers and ensures consistent responses.
Flexible Configuration Design
A key design goal was to make configuration as flexible and developer-friendly as possible. Fastify-satim supports multiple configuration methods to fit different scenarios. Under the hood, the plugin tries these options (in priority order) to obtain a SATIM client:
Multi-Tenant getClient Function
You can provide a getClient(request) function that returns a SatimClient instance based on the incoming request (for example, picking a client per tenant or per domain). If this is provided, the plugin will defer client resolution to each request, allowing truly multi-tenant operation with different credentials.
Pre-created Client Instance
If you have already created a SATIM client (using the underlying SDK's factory) with custom settings, you can pass it directly via options.client. The plugin will simply use it as-is.
Configuration Object
You can pass the raw config (username, password, terminalId, URLs, etc.) to the plugin. The plugin will create a new client for you using the core SDK's createSatimClient under the hood.
Environment Variables
For simplicity in single-tenant apps, a boolean flag fromEnv: true tells the plugin to read all needed config from environment variables (e.g. SATIM_USERNAME, SATIM_PASSWORD, etc.) and instantiate the client accordingly.
If none of these are provided, the plugin refuses to register. In my own usage, environment config is the default choice for single-tenant apps: just set your secrets in .env or the environment and enable fromEnv. The plugin uses the SATIM-TS SDK's built-in fromEnv() helper to load those values safely. This design adheres to twelve-factor app principles by keeping secrets out of code and in the environment.
For multi-tenant systems, the getClient approach is powerful. I included this after realizing that some applications serve multiple merchants, each with their own SATIM credentials. With getClient, you can examine the request (for example, an X-Tenant-ID header or JWT payload) and return the appropriate SatimClient. The plugin will decorate fastify.satim with a placeholder in this mode (since there's no single client at startup) and fetch the correct client on each request automatically.
Request Validation and Fastify Decorators
One of the most tedious parts of payment integration is validating request data. With fastify-satim, I wanted to eliminate the need for developers to write their own AJV schemas or fastify.route definitions for SATIM's request payloads. The plugin automatically validates request bodies for the payment routes using JSON schemas. I defined schemas (using TypeBox) for each route's expected body: the register route requires fields like orderNumber, amount, returnUrl, etc., the confirm route expects an orderId, and so on. These schemas are applied when the plugin registers its routes, so Fastify will reject invalid requests with a clear 400 error before ever calling the SATIM API.
Importantly, the validation supports big integers for amounts. SATIM amounts are in centimes (minor currency units), which can be large for big transactions. I allowed the schema to accept amount as a number, string, or integer type, and internally normalize it to a BigInt before calling the SDK. This means developers don't have to worry about converting currency units or losing precision they can even pass "999999999999999999" (18 digits) as a string and the plugin will handle it gracefully.
The Fastify decorator provided by the plugin is fastify.satim, which is an instance of the SATIM client (or a facade in multi-tenant mode). This makes the integration feel like a native part of Fastify you can call fastify.satim.register(params) from any request handler to initiate a payment. I took care to augment Fastify's TypeScript definitions so that fastify.satim is properly typed: as soon as you register the plugin, Fastify's types know about the new decorator. This avoids any need for casting and helps with discoverability in IDEs.
Observability: Logging, Error Handling, and Tracing
Building a payments integration plugin isn't just about happy-path requests; observability and robustness are crucial. In fastify-satim, I put a lot of thought into logging and error handling to make debugging easier while keeping production systems secure.
Centralized Error Handling
The plugin registers a custom error handler (satimErrorHandler) with Fastify to catch any errors thrown during payment operations. This handler knows how to recognize errors from the SATIM SDK for instance, a ValidationError (e.g. an invalid request parameter) results in a 400 Bad Request response to the client, whereas a SatimApiError (an error returned by the SATIM gateway, like insufficient funds) is translated to a 502 Bad Gateway with the SATIM error code and message included. Timeout errors become 504 Gateway Timeout, and any unknown errors default to 500 Internal Server Error. Crucially, the error handler never leaks sensitive details it won't echo back your credentials or internal stack traces in the response.
Logging and Warnings
Fastify's logging is utilized to warn developers of misconfigurations. For example, if someone tried to configure a payment route to use GET (which would risk sensitive data in URL query strings), the plugin logs a warning explaining that this is not recommended. Security is paramount in payment flows all transactions should use POST and be protected. Beyond that, the plugin itself does not log any sensitive information. It defers to the underlying SDK's logger settings for things like debug info.
Tracing and Metrics
The plugin exposes hooks that can help with instrumentation. You can provide a Fastify onSend hook globally or per-route via the options. In the advanced usage, a developer can pass an onSend hook that logs or records metrics whenever a payment is confirmed. Similarly, the underlying SATIM SDK allows hooking into request/response lifecycle (you can pass an onRequest or onResponse when creating the client, to integrate with a tracer or custom logger).
Example Usage in a Fastify App
To illustrate how fastify-satim simplifies SATIM payment integration, let's look at a typical usage scenario:
1import Fastify from 'fastify'; 2import fastifySatim from '@bakissation/fastify-satim'; 3 4const fastify = Fastify(); 5 6// Register the plugin with environment-based config (single-tenant) 7await fastify.register(fastifySatim, { fromEnv: true }); 8 9// Route to initiate a payment (register an order) 10fastify.post('/checkout', async (request, reply) => { 11 const response = await fastify.satim.register({ 12 orderNumber: 'ORD001', 13 amount: 5000, // 5000 DZD, will be converted to centimes internally 14 returnUrl: 'https://yoursite.com/payment/success', 15 failUrl: 'https://yoursite.com/payment/fail', 16 udf1: 'INV001', // invoice reference 17 }); 18 19 if (response.isSuccessful()) { 20 // Provide the payment page URL to redirect the user 21 return { redirectUrl: response.formUrl }; 22 } 23 24 // Handle failure (response contains error details) 25 request.log.error('Failed to create payment order'); 26 return reply.status(400).send({ error: 'Could not initiate payment' }); 27}); 28 29// Route to confirm payment after customer returns 30fastify.post('/payment/callback', async (request, reply) => { 31 const { orderId } = request.body as { orderId: string }; 32 33 const response = await fastify.satim.confirm(orderId); 34 if (response.isPaid()) { 35 // Payment was successful, proceed with fulfilling the order 36 return { status: 'paid', orderNumber: response.orderNumber }; 37 } 38 39 // Payment not successful (could be pending or failed) 40 return { status: 'failed', error: response.errorMessage }; 41});
In the code above, after registering the plugin (with one line of config), we immediately have access to fastify.satim. The /checkout handler calls fastify.satim.register(...) to create a payment order. Notice we didn't have to worry about constructing the HTTP request or validating the body we just pass a plain object. If any required field was missing or invalid, Fastify would have already replied with a 400 before our handler runs, thanks to the plugin's schema.
The /payment/callback route demonstrates handling the user returning from the payment gateway. We extract an orderId from the request body, then call fastify.satim.confirm(orderId) to verify the payment status. Under the hood, the confirm call might throw an error (for example, if the orderId is invalid or the SATIM service is down), but our global error handler will catch that and turn it into a proper error response automatically.
Optional Built-in Routes
As an alternative to writing your own handlers, the plugin can register routes for you. By enabling routes: true (and possibly a prefix), fastify-satim will create /satim/register, /satim/confirm, and /satim/refund endpoints automatically. These come with the same validation and error handling baked in. This feature is great for quick prototypes or internal tools you get a fully functional payment API by just toggling one option.
Testing Strategy and Quality Assurance
Given that payments are a critical piece of infrastructure, I wanted to ensure fastify-satim was rock-solid. I wrote a comprehensive test suite covering unit tests and integration-like tests using Fastify's in-memory server capabilities.
For unit tests, I created mock SATIM client objects that mimic the behavior of the real SDK client. Using Vitest (a Vite-native test runner similar to Jest), I stubbed the register, confirm, and refund methods to return fake responses. This way, I could test the plugin logic in isolation: does it attach the decorator properly? Does it call the right client method with correctly transformed parameters (like converting amount to bigint)? Does it handle multi-tenant selection correctly?
I also leveraged Fastify's inject mechanism to simulate HTTP requests to the plugin's routes. This was essentially an integration test without a network I would register the plugin on a Fastify instance with routes: true and then perform fake HTTP POST requests to /satim/register, /satim/confirm, etc. Every edge case we could think of (double registration, no config provided, etc.) was covered by tests to prevent regressions.
Results and Lessons Learned
Building fastify-satim has had a noticeable impact on our development workflow and integration quality:
-
Dramatic reduction in boilerplate: What used to be hundreds of lines of repetitive code is now encapsulated in a single plugin. Teams can integrate SATIM by adding one
fastify.register()call and a few lines for route logic. -
Faster integration time: Integrations that previously took hours now take minutes. With the optional auto-routes and a correct environment setup, you can get a basic payment flow running almost immediately.
-
Consistency and best practices by default: Because the plugin enforces things like using POST, validating input, and not logging secrets, even developers who are new to SATIM can avoid common pitfalls.
-
Improved observability and confidence: With structured error responses and integrated logging, issues in the payment flow are easier to diagnose.
Conclusion
Working on fastify-satim has been a rewarding experience. Not only did it solve a real problem I faced (repeatedly integrating a payment gateway in Fastify apps), but it also reinforced the value of Fastify's plugin architecture. I was able to build a module that feels like a natural extension of the framework providing a seamless, secure, and type-safe integration for SATIM payments in Node.js.
If you're a Fastify developer looking to integrate payments (especially in the Algerian market with SATIM), I encourage you to check out the plugin. You can install the package from npm and explore the code and issues on GitHub.