
Navigating UAE E-commerce Payments: Beyond Just Stripe
Alright, let's talk about e-commerce in the UAE. I've built enough systems for clients across Dubai and Kerala to know that payment processing isn't just about dropping in a Stripe library and calling it a day. When you’re targeting the GCC, you run into specific local players like Telr and HyperPay right alongside the global giants like Stripe. And the real headache? Making sure your system reliably processes these payments, especially when webhooks start acting up. I’ve seen firsthand how a single missed webhook or a double charge due to bad idempotency handling can cost a client a customer, or worse, a significant chunk of revenue in disputes. This isn't theoretical; this is production reality for businesses in Sharjah, Dubai, or even here in Malappuram who are selling globally. My approach has always been to build robust, fault-tolerant payment systems that account for the real-world messiness of network requests and third-party APIs. When I build a solution, whether it's for a client selling handmade goods from Kerala to the UAE or a Dubai-based service provider, I focus on the core problem: how do we ensure every payment is recorded exactly once, and our system state remains consistent, regardless of what the payment gateway throws at us?
The UAE Payment Landscape: More Than Just Stripe When a client comes to me wanting to launch an e-commerce platform targeting the UAE, the conversation quickly moves beyond just "integrating payments." Stripe is fantastic, and I use it extensively for its developer experience and global reach, but it's not always the only or even the primary choice for businesses operating strictly within the GCC. Here's a quick rundown of what I typically encounter:
Stripe: My go-to for global reach and an excellent developer API. It's solid for subscriptions, one-time payments, and supports a wide range of payment methods. The documentation is top-notch. Their webhook system is generally reliable.
-
Telr: A very popular regional player in the UAE and MENA. They offer a comprehensive suite of payment solutions, including local card processing, Apple Pay, and even some local bank transfers. Their API can be a bit more... traditional, let's say, compared to Stripe, but it gets the job done.
-
HyperPay: Another significant player in the region, often preferred by larger enterprises due to its fraud prevention and compliance features. Integrating HyperPay usually involves a bit more custom work on the frontend and backend, but it’s powerful. The challenge isn't just integrating each one individually. It's building a unified, consistent experience for the user and a bulletproof backend for the business.
The Core Problem: Webhook Idempotency Let's be brutally honest: webhooks are inherently unreliable. They can be delayed, delivered out of order, or, most frustratingly, delivered multiple times. If your system isn't prepared for this, you're looking at:
-
Double-charging customers: The quickest way to lose trust and get hit with chargebacks.
-
Incorrect order statuses: An order marked as paid when it wasn't, or vice-versa, leading to shipping errors or lost revenue.
-
Inventory discrepancies: Selling items that aren't actually paid for.
-
Reporting nightmares: Financial data that doesn't align with reality. This is where webhook idempotency becomes non-negotiable. It means that processing the same webhook event multiple times should have the same effect as processing it once.
My Idempotency Strategy: A Multi-Layered Approach When I build payment handling, I implement a few layers of defense against webhook shenanigans. This isn't just for Stripe; this applies equally to Telr, HyperPay, or any other payment gateway that sends webhooks.
1. Unique Event IDs & Database Storage Every payment gateway webhook comes with a unique identifier. Stripe has id, Telr usually has tran_ref or similar, and HyperPay has id. The first thing I do is store this unique ID in my database before I process the event. My typical WebhookEvent table schema might look something like this in a PostgreSQL database:
CREATE TABLE webhook_events ( id SERIAL PRIMARY KEY, event_id VARCHAR(255) UNIQUE NOT NULL, -- The unique ID from the payment gateway provider VARCHAR(50) NOT NULL, -- 'stripe', 'telr', 'hyperpay' event_type VARCHAR(100) NOT NULL, -- e.g., 'checkout.session.completed', 'payment.success' payload JSONB NOT NULL, -- The full webhook payload processed_at TIMESTAMP WITH TIME ZONE, -- When we successfully processed it created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Add an index for faster lookups on event_id and provider CREATE INDEX idx_webhook_events_event_id_provider ON webhook_events (event_id, provider);
Before processing any webhook, I perform an INSERT ... ON CONFLICT operation.
// Example using Prisma with PostgreSQL // This is a simplified handler, the real one would be more complex import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); async function handleWebhook(provider: string, eventId: string, eventType: string, payload: any) { try { const existingEvent = await prisma.webhookEvent.findUnique({ where: { eventId_provider: { // Composite unique constraint for eventId and provider eventId: eventId, provider: provider, }, }, }); if (existingEvent && existingEvent.processedAt) { console.warn(`[${provider}] Webhook event ${eventId} already processed. Skipping.`); return; // Already processed, acknowledge and exit } const webhookRecord = await prisma.webhookEvent.upsert({ where: { eventId_provider: { eventId: eventId, provider: provider, }, }, update: { payload: payload, // Update payload if it's a re-delivery with potentially new info updatedAt: new Date(), }, create: { eventId: eventId, provider: provider, eventType: eventType, payload: payload, }, }); // If it was already processed, we've already returned above. // If it was created or updated, now we process. await processPaymentLogic(provider, eventType, payload); // Mark as processed after successful logic execution await prisma.webhookEvent.update({ where: { id: webhookRecord.id }, data: { processedAt: new Date() }, }); console.log(`[${provider}] Webhook event ${eventId} processed successfully.`); } catch (error) { console.error(`[${provider}] Error processing webhook event ${eventId}:`, error); // Depending on the error, you might want to retry or send an alert throw error; // Re-throw to signal failure to the webhook sender } }
This upsert or ON CONFLICT strategy is crucial. If the record already exists and is marked as processed, I just acknowledge the webhook and do nothing. If it exists but isn't processed (maybe the previous attempt failed), I can either re-process or queue it for a retry. If it's a new event, I insert it and then proceed.
2. Transactional Processing When I update my application's state (e.g., marking an order as paid, updating inventory, creating a user), I wrap all these operations in a database transaction. This ensures atomicity: either all updates succeed, or none do. If something fails mid-way, the transaction rolls back, and my database state remains consistent. The webhook record won't be marked processed_at until the entire transaction commits.
// Inside processPaymentLogic function async function processPaymentLogic(provider: string, eventType: string, payload: any) { await prisma.$transaction(async (tx) => { // Example: Stripe checkout.session.completed if (provider === 'stripe' && eventType === 'checkout.session.completed') { const sessionId = payload.data.object.id; const paymentStatus = payload.data.object.payment_status; const orderId = payload.data.object.metadata?.orderId; // Custom metadata if (paymentStatus === 'paid' && orderId) { const order = await tx.order.findUnique({ where: { id: orderId } }); if (order && order.status === 'PENDING') { await tx.order.update({ where: { id: orderId }, data: { status: 'PAID', paymentProvider: 'Stripe', paymentRef: sessionId, paidAt: new Date(), }, }); // Logic to update inventory, send confirmation email, etc. await tx.inventory.updateMany({ /* ... */ }); await tx.emailQueue.create({ /* ... */ }); } else if (order && order.status === 'PAID') { console.warn(`Order ${orderId} already marked PAID. Idempotent webhook.`); // This is fine, do nothing. } else { console.error(`Order ${orderId} not found or in unexpected state for Stripe session ${sessionId}.`); throw new Error('Order not processable.'); } } } // ... similar logic for Telr, HyperPay events }); } ``` #### 3. Asynchronous Processing & Queues For heavy lifting like sending emails, updating external systems, or complex inventory adjustments, I push these tasks to a message queue (like RabbitMQ, AWS SQS, or even a simple Redis-based queue). The webhook handler's job is just to validate, persist the event, and update the core application state. The actual side effects happen asynchronously. This makes the webhook handler fast and less prone to timeouts, which can lead to re-deliveries. The queue worker itself also needs to be idempotent. If a worker picks up the same "send email" job twice, it should only send the email once. This is usually handled by tracking a unique job ID. ### Integrating Telr & HyperPay: Specifics I've Noticed While the idempotency strategy remains the same, the integration details for Telr and HyperPay differ quite a bit from Stripe. #### Telr Integration: Direct & Redirect Telr often involves a redirect flow. You initiate a payment, redirect the user to Telr's hosted payment page, and then Telr redirects them back to your `return_url` or `callback_url`. **Initiating Payment (Backend - Node.js/Express example):** ```typescript // Simplified Telr API call async function createTelrPayment(order: Order, customer: Customer) { const telrConfig = { store_id: process.env.TELR_STORE_ID, authkey: process.env.TELR_AUTH_KEY, // ... other config }; const payload = { method: 'create', store_id: telrConfig.store_id, authkey: telrConfig.authkey, order_id: order.id, amount: order.totalAmount.toFixed(2), currency: 'AED', // Or 'SAR', 'USD' etc. description: `Order #${order.id}`, return_url: `${process.env.FRONTEND_URL}/payment-success?orderId=${order.id}`, return_auth: `${process.env.BACKEND_URL}/api/webhooks/telr/auth`, // Success callback return_can: `${process.env.FRONTEND_URL}/payment-cancelled?orderId=${order.id}`, return_decl: `${process.env.FRONTEND_URL}/payment-failed?orderId=${order.id}`, // Customer details cust_name: customer.name, cust_email: customer.email, // ... more params }; try { const response = await axios.post('https://secure.telr.com/gateway/order.json', payload); const { order: telrOrder } = response.data; if (telrOrder && telrOrder.url) { return telrOrder.url; // Redirect user to this URL } throw new Error('Failed to get Telr payment URL.'); } catch (error) { console.error('Telr payment initiation error:', error); throw error; } } ``` **Webhook/Callback Handling (Backend):** Telr usually sends a `GET` request to your `return_auth` URL with transaction details. This is *not* a traditional webhook you subscribe to, but more of a synchronous callback. However, I still treat it with idempotency in mind. The `tran_ref` parameter is your unique ID here. ```typescript // Telr success callback endpoint app.get('/api/webhooks/telr/auth', async (req, res) => { const { order_id, tran_ref, cart_id, resp_status, resp_message, auth_amount, auth_tranref } = req.query; if (resp_status === 'A' && tran_ref) { // 'A' for authorized/successful try { // Use tran_ref as the eventId for idempotency await handleWebhook('telr', tran_ref as string, 'payment.success', req.query); res.redirect(`${process.env.FRONTEND_URL}/payment-success?orderId=${order_id}`); } catch (error) { console.error('Telr auth callback error:', error); res.redirect(`${process.env.FRONTEND_URL}/payment-failed?orderId=${order_id}`); } } else { console.error('Telr payment failed or cancelled:', req.query); res.redirect(`${process.env.FRONTEND_URL}/payment-failed?orderId=${order_id}`); } }); ``` #### HyperPay Integration: Hosted Pages & Server-to-Server HyperPay often uses a more robust server-to-server webhook system, alongside their hosted payment pages or direct API calls. They usually provide a `notificationUrl` where they'll send payment status updates. **Initiating Payment (Backend):** HyperPay has a two-step process: prepare the checkout, then redirect. ```typescript // Simplified HyperPay API call to prepare checkout async function createHyperPayCheckout(order: Order, customer: Customer) { const hyperpayConfig = { entityId: process.env.HYPERPAY_ENTITY_ID, // MADA, Visa/Mastercard entity ID accessToken: process.env.HYPERPAY_ACCESS_TOKEN, // ... more config }; const payload = { entityId: hyperpayConfig.entityId, amount: order.totalAmount.toFixed(2), currency: 'AED', paymentType: 'DB', // Debit merchantTransactionId: order.id, // Your unique order ID customer: { email: customer.email, }, billing: { street1: customer.address.street, city: customer.address.city, state: customer.address.state, country: 'AE', // Or relevant country code }, // Crucial for webhooks notificationUrl: `${process.env.BACKEND_URL}/api/webhooks/hyperpay`, // ... more params }; try { const response = await axios.post( `https://test.oppwa.com/v1/checkouts`, // Use test endpoint for dev new URLSearchParams(payload).toString(), // HyperPay often expects x-www-form-urlencoded { headers: { 'Authorization': `Bearer ${hyperpayConfig.accessToken}`, 'Content-Type': 'application/x-www-form-urlencoded', }, } ); const { checkoutId } = response.data; if (checkoutId) { return checkoutId; // Redirect user to HyperPay hosted page with this checkoutId } throw new Error('Failed to get HyperPay checkout ID.'); } catch (error) { console.error('HyperPay checkout initiation error:', error); throw error; } } ``` **Webhook Handling (Backend):** HyperPay sends a `POST` request to your `notificationUrl`. The `id` field in the response is your unique event ID for idempotency. ```typescript // HyperPay webhook endpoint app.post('/api/webhooks/hyperpay', async (req, res) => { const { id, ndc, paymentBrand, result } = req.body; // 'id' is the unique transaction ID // Validate webhook signature if HyperPay provides one (they usually do) // This is critical for security! if (result.code.startsWith('000.') && id) { // 000.xxx for success try { await handleWebhook('hyperpay', id as string, 'payment.success', req.body); res.status(200).send('Webhook received and processed.'); } catch (error) { console.error('HyperPay webhook error:', error); res.status(500).send('Error processing webhook.'); } } else { console.warn('HyperPay payment failed or pending:', req.body); res.status(200).send('Webhook received, no action needed or failed.'); } }); ``` ### Architectural Breakdown: My Typical Setup Here's how I usually structure a robust e-commerce payment system for the UAE market: | Component | Role | Key Technology/Considerations | | :------------------------ | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- | |
