A FedaPay integration that survives a retried webhook
A walkthrough of the FedaPay integration running in logemoi-api: a provider-agnostic order model, an idempotent checkout endpoint, and a webhook handler built around one fact — the provider will send the same event more than once.
On this page · 7 sections
This is the FedaPay integration actually running in logemoi-api, backing two unrelated features — an announcer paying to boost a listing, and a tenant paying for instant search alerts. Same payment code underneath both. If you're wiring up FedaPay, or any webhook-driven payment provider, this is the real shape of it, not a simplified version.
Don't call the provider's SDK from your business logic
The order-creation code never calls FedaPay directly. It calls a PaymentProvider interface:
1export interface PaymentProvider {2 readonly name: PaymentProviderName;3 createCheckout(order: PaymentOrderEntity): Promise<ProviderCheckout>;4 refreshStatus(order: PaymentOrderEntity): Promise<ProviderPaymentState>;5 chargeDirect(order: PaymentOrderEntity, phoneNumber: string, operator: MobileMoneyOperator): Promise<ProviderPaymentState>;6 verifyWebhook(rawBody: Buffer, signature: string | undefined): boolean;7 parseWebhook(rawBody: Buffer): ParsedProviderEvent;8}Three implementations exist behind it: FedaPayPaymentProvider (the real one), SandboxPaymentProvider (resolves instantly, no telco involved — what local dev and tests run against), and DisabledPaymentProvider (throws a clear error instead of a confusing one, for environments with no provider configured at all). Which one you get is a single env-driven factory function:
1export const getPaymentProvider = (): PaymentProvider => {2 if (env.PAYMENT_PROVIDER === "sandbox") return new SandboxPaymentProvider();3 if (env.PAYMENT_PROVIDER === "fedapay") return new FedaPayPaymentProvider();4 return new DisabledPaymentProvider();5};Nothing elsewhere in the codebase imports the fedapay package. That's the entire point: the business logic (order creation, webhook handling, fulfillment) is provider-agnostic by construction, not by discipline.
Model the order, not the provider's transaction
A PaymentOrderEntity exists independent of whatever FedaPay calls its own transaction:
1@Entity({ name: "payment_orders" })2@Index(["ownerUserId", "purpose", "idempotencyKey"], { unique: true })3@Index(["provider", "providerReference"], { unique: true, where: "provider_reference IS NOT NULL" })4@Index(["ownerUserId", "status", "createdAt"])5export class PaymentOrderEntity {6 @PrimaryGeneratedColumn()7 id!: number;89 @Column({ name: "owner_user_id", type: "int" })10 ownerUserId!: number;1112 @Column({ type: "varchar", length: 40 })13 purpose!: PaymentPurpose; // "announcer_promotion" | "tenant_subscription"1415 @Column({ type: "varchar", length: 20, default: "pending" })16 status!: PaymentOrderStatus; // "pending" | "paid" | "failed" | "cancelled" | "expired"1718 @Column({ name: "provider_reference", type: "varchar", length: 160, nullable: true })19 providerReference!: string | null; // FedaPay's own transaction id, once we have one2021 @Column({ name: "idempotency_key", type: "varchar", length: 100 })22 idempotencyKey!: string;23 // ...24}Two unique indexes are doing real work here. (ownerUserId, purpose, idempotencyKey) means a client that retries "create checkout" after a dropped connection gets the same order back instead of a second charge. (provider, providerReference) is what lets a webhook — which only knows FedaPay's transaction id, not our order id — find the right row.
A second table, payment_events, logs every webhook delivery by (provider, providerEventId), also unique. That table is the actual answer to "what happens if FedaPay sends the same webhook twice" — which it will, because that's how webhooks work on every provider that takes delivery seriously.
Creating a checkout is a lookup, then a create
1async createOrder(input: CreatePaymentOrderInput) {2 const existing = await this.orders.findOneBy({3 ownerUserId: input.ownerUserId,4 purpose: input.purpose,5 idempotencyKey: input.idempotencyKey,6 });7 if (existing) return { order: existing, created: false };89 const provider = getPaymentProvider();10 const order = await this.orders.save(this.orders.create({ /* ... */ status: "pending" }));1112 try {13 const checkout = await provider.createCheckout(order);14 order.providerReference = checkout.providerReference;15 order.checkoutUrl = checkout.checkoutUrl;16 return { order: await this.orders.save(order), created: true };17 } catch (error) {18 order.status = "failed";19 order.failureCode = "PAYMENT_PROVIDER_UNAVAILABLE";20 await this.orders.save(order);21 throw new ConflictError(order.failureMessage);22 }23}The idempotency check happens before anything touches FedaPay, so a retried request never creates a second order, and a request that fails partway through (FedaPay's API times out, say) leaves a failed order on record instead of a silent gap.
The webhook route has to see the raw bytes
This is the part that breaks silently if you get the ordering wrong. FedaPay signs the exact raw request body; if Express has already parsed it into a JSON object by the time your handler runs, the bytes you'd verify against no longer exist.
1app.post(`${env.API_PREFIX}/webhooks/fedapay`, express.raw({ type: "application/json" }), asyncHandler(paymentsController.fedapayWebhook));2app.use(express.json({ limit: "2mb" }));The webhook route is registered with its own express.raw() middleware before the app-wide express.json() is mounted. Express matches routes in registration order, so this one route gets the raw Buffer it needs, and every route registered after still gets normal parsed JSON. Flip these two lines and signature verification starts failing for a reason that has nothing to do with your signing secret.
Verifying the signature, with room for secret rotation
1verifyWebhook(rawBody: Buffer, signature: string | undefined): boolean {2 if (!signature || env.FEDAPAY_WEBHOOK_SECRETS.length === 0) return false;3 for (const secret of env.FEDAPAY_WEBHOOK_SECRETS) {4 try {5 Webhook.constructEvent(rawBody, signature, secret);6 return true;7 } catch {8 continue;9 }10 }11 return false;12}FEDAPAY_WEBHOOK_SECRETS is a comma-separated list, not a single value, so rotating the signing secret is: add the new one alongside the old one, deploy, wait for FedaPay to switch over, then remove the old one — never a window where verification fails for every in-flight webhook.
Applying state exactly once
This is the function the whole design exists to protect:
1private async applyState(order: PaymentOrderEntity, state: ProviderPaymentState, event: /* ... */) {2 return AppDataSource.transaction(async (manager) => {3 const orders = manager.getRepository(PaymentOrderEntity);4 const current = await orders.findOneByOrFail({ id: order.id });56 if (event.providerEventId) {7 const events = manager.getRepository(PaymentEventEntity);8 const duplicate = await events.findOneBy({ provider: current.provider, providerEventId: event.providerEventId });9 if (duplicate) return current;10 await events.save(events.create({ /* ... */ }));11 }1213 if (current.status === "paid") return current;14 if (terminalStatuses.has(current.status) && state.status !== "paid") return current;1516 current.status = state.status;17 if (state.status === "paid") {18 current.paidAt = current.paidAt ?? new Date();19 await this.fulfillPaidOrder(manager, current);20 }2122 return orders.save(current);23 });24}Three separate guards, each against a different way this could go wrong: re-fetching current inside the transaction instead of trusting the order passed in (another concurrent webhook delivery may have already changed it); returning early if this exact providerEventId was already logged (FedaPay retried); and refusing to move a paid order to anything else, or move any terminal status back to something non-paid (a late, out-of-order webhook can't undo a completed payment). Fulfillment — activating a subscription, boosting a listing — only ever runs inside this same transaction, on the one path that just confirmed it hasn't already run.
One more thing, if you're also calling this from a native app
The checkout's callback_url has to be a real https:// URL — FedaPay's API rejects a custom scheme outright. A mobile app that wants FedaPay to land the user back on logeafrica://... can't pass that URL directly; it has to send a real web page as the callback, tagged so that page knows to perform the native redirect itself once it loads. Not the subject of this post, but it trips up exactly one person per project, so: better here than in your own error logs.
None of this is FedaPay-specific, really. Any payment provider that uses webhooks will eventually redeliver one, and any checkout endpoint will eventually get called twice by the same confused client. The design above doesn't prevent either thing from happening — it just makes sure that when they do, nothing gets charged twice and nothing gets fulfilled twice either.