Integrating a payment gateway in test mode
The Kirana Store integrates against a seam, not against a vendor. This lesson is about what goes on each side of that seam, and why the split is where it is.
The gateway is one class
@Injectable()
export class PaymentGateway {
publicKey(): string
createIntent(orderNumber: string, amountPaise: number): string
sign(rawBody: string): string
verify(rawBody: string, signature: string): boolean
refund(providerRef: string, amountPaise: number): Promise<string>
}
Five methods. Everything a shop needs from a payment provider, and nothing else in the codebase knows which provider it is.
The implementation here is a faithful fake: the signature it produces is a real HMAC-SHA256 over the raw body, verified the way a real one must be. The part that is pretend is only who sends it.
That is a deliberate teaching choice. A sandbox account would not teach you signature verification any better — and signature verification is the piece people get wrong. What a sandbox would add is a vendor's SDK, an account, a sender registration and a set of keys, none of which teaches you anything about building applications.
The test of a good seam: swapping in a provider's SDK touches this one file. If it touches five, the seam is in the wrong place.
Fail at startup, not on the first payment
constructor(private readonly config: ConfigService) {
const secret = this.config.get<string>("PAYMENT_WEBHOOK_SECRET");
if (!secret || secret.length < 16) {
throw new Error(
"PAYMENT_WEBHOOK_SECRET must be set and at least 16 characters. " +
"Generate one with: openssl rand -base64 32",
);
}
this.secret = secret;
}
The same pattern as JWT_SECRET in module 8, and for the same reason. A missing
secret with a fallback default is not a small bug: it means every forged
webhook is accepted, silently, in production, for as long as nobody notices.
An application that refuses to start is a problem you find in thirty seconds. An application that starts with no security is a problem you find in an incident report.
Note the error message includes the command that generates a good value. Error messages that tell you the fix are worth writing.
Two keys, and only one is a secret
publicKey(): string {
return this.config.get<string>("PAYMENT_PUBLIC_KEY") ?? "kirana_test_key";
}
PAYMENT_PUBLIC_KEY=kirana_test_key # goes to the browser. Identifies the shop.
PAYMENT_WEBHOOK_SECRET=… # never leaves the server. Authorises.
Every gateway has this pair, under various names — publishable and secret, key id and key secret. The public one identifies the shop to the gateway's widget and authorises nothing; it is meant to be in the page source.
The fallback default on the public key is fine. On the secret it would be a vulnerability. Being able to tell which is which is the point.
Opening a payment
async createIntent(userId: string, orderNumber: string): Promise<PaymentIntent> {
const order = await this.prisma.order.findFirst({
where: { orderNumber, userId },
select: { id: true, orderNumber: true, status: true, totalPaise: true },
});
if (!order) throw AppException.notFound("No such order.");
if (order.status !== "PENDING_PAYMENT") {
throw new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.CONFLICT,
"That order is not waiting for payment.", { status: order.status });
}
…
}
Ownership in the where clause, as everywhere else in this course — one
query rather than a query plus a check somebody can forget. And a 404 rather
than a 403, so an order number cannot be confirmed as real.
The status check stops a customer paying twice for the same order, and stops a payment being opened against a cancelled one.
Reusing an open attempt
const existing = await this.prisma.payment.findFirst({
where: { orderId: order.id, status: "PENDING" },
orderBy: { createdAt: "desc" },
});
if (existing) {
return { …existing… };
}
This is the part that is easy to miss and expensive to miss.
Somebody opens the payment page, gets distracted, comes back and refreshes. Without this, that is two live payment attempts against one order — and either of them could succeed. Both could. Then you have taken ₹460 for a ₹230 order and the customer is, entirely reasonably, furious.
One order, one live payment attempt. A new one is opened only after the previous attempt has actually failed.
The amount comes from the order
const providerRef = this.gateway.createIntent(order.orderNumber, order.totalPaise);
Not from the request body. The request carries an order number; the server looks up what that order costs.
What goes back to the browser is for display:
return {
orderNumber: order.orderNumber,
providerRef,
amountPaise: order.totalPaise,
publicKey: this.gateway.publicKey(),
};
Tamper with it in developer tools and you change the number on the screen. You do not change what the gateway collects, because the gateway was told by the server.
Keeping the raw body
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
One option, and without it the next lesson is impossible.
Express parses a JSON body and throws the bytes away. A webhook signature covers
exactly the bytes that were sent, so verifying it needs those bytes. The
usual workaround is to re-serialise the parsed object with JSON.stringify,
which produces different whitespace and therefore a signature that never
matches — and the usual "fix" for that is to stop checking the signature.
rawBody: true keeps them on request.rawBody.
Why the order is created before the payment
Worth pausing on, because the other order is tempting.
The Kirana Store creates the order first, as PENDING_PAYMENT, holding its
stock. Only then does it open a payment.
The alternative — take the money, then create the order — sounds safer and is much worse. If order creation fails after a successful charge you are holding money for something that does not exist, and the fix is a refund, a fee and an apology. Whereas an order that is never paid for costs nothing: it is cancelled by a sweep and its stock goes back on the shelf.
Prefer the failure that costs nothing.
The cost is that a PENDING_PAYMENT order holds stock, which is why the sweep
in the fourth lesson of this module is not optional.
The development simulator
@Post("simulate")
async simulate(@Body() body: { providerRef: string; … ; succeed: boolean }) {
const event: PaymentWebhookEvent = { … };
const raw = JSON.stringify(event);
return this.payments.handleWebhook(raw, this.payments.signForSimulator(raw));
}
Local development needs some way to make a webhook arrive. This asks the server to sign an event and hand it to its own handler — which is precisely what a gateway does from its machines.
Read that carefully, because the thing it does not do is the point: the browser still cannot mark an order paid. It asks the server to act as the gateway. The signature is still real, the handler is still the only path, and none of the security properties are weakened.
In production this route does not exist. Real webhooks from a real gateway reach
your machine through the provider's own dashboard, or through a tunnel like
ngrok while you are developing against a sandbox.
Check your work
Why the gateway is one class: swapping a provider should touch one file.
Why the fake is faithful: the signature is a genuine HMAC over the raw body, which is the part people get wrong and the part a sandbox would not teach better.
Why a missing secret throws at startup: a fallback default means every forged webhook is accepted, silently, in production.
The difference between the two keys: the public one identifies and belongs in the page; the secret one authorises and never leaves the server.
Why ownership goes in the where: one query, and no check to forget.
Why an open attempt is reused: a refresh would otherwise create two live payments against one order, and both could succeed.
Why the amount is read from the order: the intent's amount is for display; what is collected is what the server told the gateway.
What rawBody: true is for: a signature covers the bytes sent, and
re-serialising a parsed body produces different ones.
Why the order is created first: an unpaid order costs nothing, and a payment without an order costs a refund and a fee.
What the simulator does and does not do: it makes the server send itself a properly signed event; the browser still cannot mark anything paid.
Practice
- Remove
PAYMENT_WEBHOOK_SECRETfrom.envand start the API. Read the error and note how long it took to find the problem. - Give it a four-character value and start again.
- Open a payment intent, then open another for the same order. Confirm the
providerRefis the same and thatSELECT count(*) FROM paymentshas not moved. - Remove the reuse check, repeat, and count the rows.
- Open an intent for an order belonging to another account. Confirm 404, not 403.
- Pay for an order, then try to open a second intent for it. Read the message.
- Change
amountPaisein the response with developer tools before paying. Confirm the stored payment amount is unaffected. - Remove
rawBody: true, restart, and send a signed webhook. Watch verification fail even though the signature is correct. - Write down the five methods a different gateway would need, and check them against a real provider's SDK.
- Decide what should happen if
createIntentsucceeds but the database write fails. Then look at the code and see whether it does that.
Next: the webhook itself — and never trusting the browser.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship