REST APIs, Webhooks & Event Triggers
Build a payment integration with webhooks and Hasura Event Triggers.
Introduction
30 Second Summary
When you order food online and instantly get a receipt emailed to you, that is two separate services talking to each other through APIs and webhooks. Every modern app relies on this pattern to connect payments, notifications, and third-party tools.
In this project, you will build a property management app that automatically creates invoices through an external payment service when a new lease is signed. You will also build the payment service itself, giving you complete visibility into both sides of the integration.
What You'll Build
A property management system where creating a lease triggers an automatic invoice via a REST API call, and a signed webhook confirms payment in real-time.
By the end of this project, you'll have:
- A Mock Payment Service built with Express that simulates how providers like Stripe process payments and send signed webhook notifications.
- Next.js API route handlers that call external REST APIs with proper authentication, typed responses, and structured error handling.
- A secure webhook endpoint that verifies HMAC-SHA256 cryptographic signatures and rejects tampered requests.
- Hasura Event Triggers that automatically fire when database rows change, creating a complete event-driven integration loop.
- Secret Mission: Add webhook retry logic with exponential backoff and a failed events dashboard.
Are there any prerequisites?
You should have a basic understanding of GraphQL queries and mutations, familiarity with a Next.js project structure, and TypeScript basics.
You also need Docker installed on your machine. Everything in this project runs inside containers, so no local Node.js installation is required.
Before We Start
Before We Start...
Set Up the Docker Environment
Your property management app involves four moving parts: a database, an API layer, a mock payment service, and a frontend. Each runs in its own isolated Docker container, connected through a shared network.
In this step, you will create the project scaffolding and wire all four services together using Docker Compose. By the end, every service will be running and reachable from your browser.
In this step, get ready to:
- Create the project directory structure and a docker-compose.yml file with four services.
- Scaffold the Mock Payment Service with its package.json, Dockerfile, and placeholder server.
- Scaffold the Next.js app and verify all services start with docker compose up --build.
Create the project directory and Docker Compose file
Your project needs a root folder with two subdirectories: one for the mock payment service and one for the Next.js app. The docker-compose.yml file at the root orchestrates all four services.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder structure and move into it by running these commands:
mkdir -p property-integration/mock-payment-service/src property-integration/property-app/app
cd property-integration
- Confirm the folders exist by running:
ls
You should see mock-payment-service and property-app listed in the output.
- Create the docker-compose.yml file in the property-integration root folder. Open this file in any text editor and paste the following:
version: "3.8"
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgrespassword
POSTGRES_DB: property_management
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
hasura:
image: hasura/graphql-engine:v2.40.0
ports:
- "8080:8080"
depends_on:
- postgres
environment:
HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/property_management
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: myadminsecret
HASURA_GRAPHQL_DEV_MODE: "true"
EVENT_SECRET: my_event_secret_123
mock-payment-service:
build:
context: ./mock-payment-service
ports:
- "4000:4000"
environment:
PORT: "4000"
API_KEY: test_api_key_12345
WEBHOOK_SECRET: whsec_mock_secret_abcdef
WEBHOOK_URL: http://next-app:3000/api/webhooks/payments
next-app:
build:
context: ./property-app
ports:
- "3000:3000"
depends_on:
- hasura
- mock-payment-service
environment:
PAYMENT_SERVICE_URL: http://mock-payment-service:4000
PAYMENT_API_KEY: test_api_key_12345
WEBHOOK_SECRET: whsec_mock_secret_abcdef
HASURA_GRAPHQL_URL: http://hasura:8080/v1/graphql
HASURA_ADMIN_SECRET: myadminsecret
EVENT_SECRET: my_event_secret_123
NEXT_PUBLIC_HASURA_URL: http://localhost:8080/v1/graphql
NEXT_PUBLIC_HASURA_ADMIN_SECRET: myadminsecret
volumes:
pgdata:
What does this file do?
This docker-compose.yml defines four services that Docker will run simultaneously:
- postgres runs a PostgreSQL database to store property and payment data.
- hasura provides a GraphQL API layer on top of the database, plus an admin console.
- mock-payment-service simulates an external payment provider like Stripe.
- next-app is your frontend and API route handler, built with Next.js.
How do Docker containers talk to each other?
Docker Compose automatically creates a shared network for all services. Containers reference each other by service name, not localhost. For example, the next-app container reaches the mock payment service at http://mock-payment-service:4000.
The ports mapping (like "4000:4000") is only for YOUR browser to access the service. Container-to-container traffic uses the internal Docker network.
- Save docker-compose.yml.
YAML indentation errors?
YAML is very sensitive to indentation. Make sure you use spaces (not tabs) and that each nested level is indented by exactly 2 spaces. Most text editors show a "Spaces: 2" indicator at the bottom.
help me fix YAML indentation issues in my docker-compose.yml
Create the mock payment service scaffold
The mock payment service simulates what Stripe or PayPal does on their end. It is a separate Express server running in its own container. You need four files to make it buildable.
- Create mock-payment-service/package.json and paste the following:
{
"name": "mock-payment-service",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx watch src/server.ts"
},
"dependencies": {
"express": "^4.21.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
What does this package.json set up?
The build script compiles TypeScript to JavaScript. The start script runs the compiled output. Express handles HTTP routing, and uuid generates unique invoice IDs.
- Create mock-payment-service/tsconfig.json and paste the following:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
What does this configure?
This tells TypeScript to compile files from src/ into dist/ using modern JavaScript features. The strict flag enables strong type checking.
- Create mock-payment-service/Dockerfile and paste the following:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
EXPOSE 4000
CMD ["npm", "start"]
What does this Dockerfile do?
It starts from a lightweight Node.js image, installs dependencies, copies the source code, compiles TypeScript, then runs the compiled server. The EXPOSE 4000 documents which port the service listens on.
- Create the placeholder server at mock-payment-service/src/server.ts and paste the following:
// Placeholder server
import express from "express";
const app = express();
app.get("/health", (_req, res) => res.json({ status: "ok" }));
app.listen(4000, () => console.log("Mock Payment Service running on port 4000"));
Why a placeholder?
This minimal server is just enough to confirm the container builds and runs. You will replace it with the full payment service implementation in Step 2.
- Save all four files.
- Confirm the mock service files exist by running:
ls mock-payment-service/
ls mock-payment-service/src/
You should see Dockerfile, package.json, and tsconfig.json in the first listing, and server.ts in the second.
Missing a file?
Double check that you created server.ts inside the src/ subfolder, not directly in mock-payment-service/. The Dockerfile copies src/ specifically, so the file must be at mock-payment-service/src/server.ts.
help me fix my mock payment service file structure
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before moving on.
ⓧ I'd like to double check the full code
Here is mock-payment-service/package.json:
{
"name": "mock-payment-service",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx watch src/server.ts"
},
"dependencies": {
"express": "^4.21.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
Here is mock-payment-service/tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
Here is mock-payment-service/Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
EXPOSE 4000
CMD ["npm", "start"]
Here is mock-payment-service/src/server.ts:
// Placeholder server
import express from "express";
const app = express();
app.get("/health", (_req, res) => res.json({ status: "ok" }));
app.listen(4000, () => console.log("Mock Payment Service running on port 4000"));
Create the Next.js app scaffold and start all services
The Next.js app is your frontend and webhook handler. It needs its own configuration files and a minimal page so that the next build step in the Dockerfile succeeds.
- Create property-app/package.json and paste the following:
{
"name": "property-app",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.2.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0"
}
}
What is in this package?
This defines a Next.js 16.2.6 project with React 19. The build script compiles your app for production, and start serves the compiled output.
- Create property-app/next.config.ts and paste the following:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
Why standalone output?
The standalone output mode produces a self-contained build that runs without node_modules. This keeps the Docker image small and fast to start.
- Create property-app/tsconfig.json and paste the following:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{"name": "next"}],
"paths": {"@/*": ["./*"]}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
- Create property-app/Dockerfile and paste the following:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
How is this Dockerfile different?
Unlike the mock service Dockerfile that copies specific folders, this one uses COPY . . to copy the entire app directory. Next.js expects all source files to be present when it runs next build.
- Create a minimal layout file at property-app/app/layout.tsx so the build succeeds:
export const metadata = {
title: "Property Management - Payment Integration",
description: "REST APIs, Webhooks, and Hasura Event Triggers",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
- Create a minimal page file at property-app/app/page.tsx:
export default function HomePage() {
return <h1>Property Management App</h1>;
}
Why these two extra files?
Next.js requires at minimum a root layout and a page file to compile. These are placeholders that you will replace with the full dashboard in Step 6.
- Save all the files.
- Confirm the property-app files exist by running:
ls property-app/
ls property-app/app/
You should see Dockerfile, next.config.ts, package.json, tsconfig.json, and app in the first listing. The second listing shows layout.tsx and page.tsx.
- Build and start all four services by running this command from the property-integration root folder:
docker compose up --build
What does this command do?
The --build flag tells Docker to build fresh images from each Dockerfile before starting the containers. Docker will download the postgres:16 and hasura/graphql-engine:v2.40.0 images, then build and launch all four services.
This takes a few minutes the first time. Watch the terminal logs for startup messages from each service.
- Wait for the logs to settle. You should see messages indicating each service is running.
- Open your browser and visit http://localhost:8080. Enter myadminsecret as the admin secret to access the Hasura Console.
- Visit http://localhost:3000 to confirm the Next.js app is running. You should see the text "Property Management App" on the page.
- Visit http://localhost:4000/health to confirm the mock payment service returns {"status":"ok"}.
Build failed or a service won't start?
- If you see a port conflict ("port is already allocated"), another process is using that port. Stop it with lsof -i :8080 to find the PID, then kill that process.
- If the next-app build fails with a TypeScript error, make sure property-app/app/layout.tsx and property-app/app/page.tsx exist and are saved.
- If mock-payment-service fails to build, confirm that src/server.ts is inside the src/ folder. The Dockerfile expects COPY src/ ./src/ to find files there.
help me debug why docker compose up --build is failing
✔️ Awesome, I've got everything!
Great. All four services are running. Keep the terminal with docker compose open so you can see logs as you work through the next steps.
ⓧ I'd like to double check the full code
Here is docker-compose.yml:
version: "3.8"
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgrespassword
POSTGRES_DB: property_management
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
hasura:
image: hasura/graphql-engine:v2.40.0
ports:
- "8080:8080"
depends_on:
- postgres
environment:
HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/property_management
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: myadminsecret
HASURA_GRAPHQL_DEV_MODE: "true"
EVENT_SECRET: my_event_secret_123
mock-payment-service:
build:
context: ./mock-payment-service
ports:
- "4000:4000"
environment:
PORT: "4000"
API_KEY: test_api_key_12345
WEBHOOK_SECRET: whsec_mock_secret_abcdef
WEBHOOK_URL: http://next-app:3000/api/webhooks/payments
next-app:
build:
context: ./property-app
ports:
- "3000:3000"
depends_on:
- hasura
- mock-payment-service
environment:
PAYMENT_SERVICE_URL: http://mock-payment-service:4000
PAYMENT_API_KEY: test_api_key_12345
WEBHOOK_SECRET: whsec_mock_secret_abcdef
HASURA_GRAPHQL_URL: http://hasura:8080/v1/graphql
HASURA_ADMIN_SECRET: myadminsecret
EVENT_SECRET: my_event_secret_123
NEXT_PUBLIC_HASURA_URL: http://localhost:8080/v1/graphql
NEXT_PUBLIC_HASURA_ADMIN_SECRET: myadminsecret
volumes:
pgdata:
Here is property-app/package.json:
{
"name": "property-app",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.2.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0"
}
}
Here is property-app/next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
Here is property-app/tsconfig.json:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{"name": "next"}],
"paths": {"@/*": ["./*"]}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Here is property-app/Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Here is mock-payment-service/package.json:
{
"name": "mock-payment-service",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx watch src/server.ts"
},
"dependencies": {
"express": "^4.21.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
Here is mock-payment-service/tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
Here is mock-payment-service/Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
EXPOSE 4000
CMD ["npm", "start"]
Here is mock-payment-service/src/server.ts:
// Placeholder server
import express from "express";
const app = express();
app.get("/health", (_req, res) => res.json({ status: "ok" }));
app.listen(4000, () => console.log("Mock Payment Service running on port 4000"));
Your entire Docker environment is running. Next up, you will build out the mock payment service with real REST API endpoints and webhook dispatch.
Build the Mock Payment Service
Your Docker environment is running, but the mock payment service is just a placeholder. Now you'll turn it into a fully functional REST API that mirrors how real payment providers like Stripe work.
Every payment provider exposes HTTP endpoints that accept requests, authenticate callers with API keys, and eventually send back event notifications via webhooks. By building this yourself, you'll see both sides of the integration and understand exactly what's happening when your app talks to an external service.
In this step, get ready to:
- Implement REST API endpoints with authentication middleware.
- Build a webhook dispatch system with HMAC-SHA256 signatures.
- Add error simulation and test the full service with curl.
Implement the Express server with REST endpoints
A REST API is a set of HTTP endpoints that accept requests and return JSON responses. Your mock payment service needs two endpoints: one to create invoices and one to retrieve invoice status.
- Open mock-payment-service/src/server.ts in your editor.
- Select all the placeholder code and delete it.
- Replace it with the server foundation by pasting this code:
import express from "express";
import crypto from "crypto";
import { v4 as uuidv4 } from "uuid";
const app = express();
app.use(express.json());
// Environment variables configured in docker-compose.yml
const PORT = process.env.PORT || 4000;
const API_KEY = process.env.API_KEY || "test_api_key_12345";
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "whsec_mock_secret_abcdef";
const WEBHOOK_URL = process.env.WEBHOOK_URL || "http://next-app:3000/api/webhooks/payments";
// In-memory invoice store (simulates a database)
const invoices: Map<string, { id: string; amount: number; status: string; lease_id: string; tenant_email: string; created_at: string }> = new Map();
What does this code do?
This sets up the Express server and reads configuration from environment variables. The fallback values after || are defaults for local development.
The invoices Map acts as an in-memory database. In a real payment service like Stripe, this would be a persistent database.
- Add the authentication middleware below the invoices Map:
// Middleware: verify API key from Authorization header
function authenticateRequest(req: express.Request, res: express.Response, next: express.NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing or invalid Authorization header. Expected: Bearer <API_KEY>" });
return;
}
const token = authHeader.split(" ")[1];
if (token !== API_KEY) {
res.status(403).json({ error: "Invalid API key" });
return;
}
next();
}
What is Bearer token authentication?
The Authorization: Bearer <token> pattern is the standard way REST APIs authenticate requests. The caller includes their API key in the request header, and the server validates it before processing.
This middleware runs before every protected endpoint. If the key is missing or wrong, the request is rejected with a 401 or 403 status code before reaching the route handler.
- Add the POST endpoint for creating invoices below the auth middleware:
// POST /api/invoices - Create a new invoice
app.post("/api/invoices", authenticateRequest, (req, res) => {
const { amount, lease_id, tenant_email } = req.body;
// Validate required fields
if (!amount || !lease_id || !tenant_email) {
res.status(400).json({
error: "Missing required fields",
required: ["amount", "lease_id", "tenant_email"],
});
return;
}
// Create the invoice
const invoice = {
id: `inv_${uuidv4()}`,
amount,
status: "pending",
lease_id,
tenant_email,
created_at: new Date().toISOString(),
};
invoices.set(invoice.id, invoice);
console.log(`[API] Created invoice ${invoice.id} for $${amount / 100}`);
// Return immediately
res.status(201).json({
id: invoice.id,
amount: invoice.amount,
status: invoice.status,
lease_id: invoice.lease_id,
tenant_email: invoice.tenant_email,
created_at: invoice.created_at,
});
});
What does this endpoint do?
This is a standard REST endpoint. It uses authenticateRequest as Express middleware, meaning the API key is checked before the handler runs.
It validates the request body, creates an invoice with a unique ID (inv_ prefix + UUID), stores it in the Map, and returns a 201 Created response with the invoice data. Amounts are in cents, so 150000 equals $1,500.
- Add the GET endpoint, health check, and server startup below the POST route:
// GET /api/invoices/:id - Retrieve invoice status
app.get("/api/invoices/:id", authenticateRequest, (req, res) => {
const invoice = invoices.get(req.params.id);
if (!invoice) {
res.status(404).json({ error: "Invoice not found" });
return;
}
res.json(invoice);
});
// Health check (no auth required)
app.get("/health", (_req, res) => {
res.json({ status: "ok", service: "mock-payment-service" });
});
app.listen(PORT, () => {
console.log(`Mock Payment Service running on port ${PORT}`);
console.log(`Webhook URL: ${WEBHOOK_URL}`);
console.log(`API Key: ${API_KEY.substring(0, 8)}...`);
});
What do these pieces do?
- GET /api/invoices/:id lets callers check an invoice's current status.
- GET /health is a standard health check that monitoring tools and Docker can use to verify the service is alive.
- The app.listen call starts the server and logs useful debugging info at startup.
- Save mock-payment-service/src/server.ts.
You won't test these endpoints yet. The next chunk adds the webhook system, and then you'll rebuild and test everything together.
Add the webhook dispatch system
When a tenant pays an invoice, the payment service needs to notify your app. This notification is called a webhook.
The critical security requirement is proving the webhook is genuine. Without verification, anyone could send a fake "payment succeeded" event to your endpoint.
- In server.ts, add these two functions between the authenticateRequest function and the app.post route:
// Helper: compute HMAC-SHA256 signature
function computeSignature(payload: string): string {
return crypto.createHmac("sha256", WEBHOOK_SECRET).update(payload).digest("hex");
}
// Helper: send webhook with signature
async function sendWebhook(eventType: string, data: object): Promise<void> {
const event = {
id: `evt_${uuidv4()}`,
type: eventType,
created_at: new Date().toISOString(),
data,
};
const body = JSON.stringify(event);
const signature = computeSignature(body);
console.log(`[Webhook] Sending ${eventType} to ${WEBHOOK_URL}`);
console.log(`[Webhook] Signature: ${signature}`);
try {
const response = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-Event-Id": event.id,
},
body,
});
console.log(`[Webhook] Response status: ${response.status}`);
} catch (error) {
console.error(`[Webhook] Failed to deliver:`, error);
}
}
How does HMAC-SHA256 signature verification work?
HMAC-SHA256 is a cryptographic algorithm that combines a secret key with a message to produce a unique hash. Only someone who knows the WEBHOOK_SECRET can produce the correct signature for a given payload.
The receiver (your app) recomputes the signature using the same secret and compares it to the header value. If they match, the webhook is genuine. This is the exact same pattern used by Stripe, GitHub, and Shopify.
Now you need to wire the webhook into the invoice creation flow. After creating an invoice, the service simulates a 5-second payment processing delay, then sends a "payment confirmed" webhook.
- In the POST /api/invoices handler, find this line near the bottom:
console.log(`[API] Created invoice ${invoice.id} for $${amount / 100}`);
// Return immediately
res.status(201).json({
- Replace the comment // Return immediately with a setTimeout block that simulates payment processing, so the section looks like this:
console.log(`[API] Created invoice ${invoice.id} for $${amount / 100}`);
// Simulate payment processing (5 second delay, then send webhook)
setTimeout(async () => {
invoice.status = "paid";
invoices.set(invoice.id, invoice);
console.log(`[Payment] Invoice ${invoice.id} marked as paid`);
await sendWebhook("invoice.paid", {
invoice_id: invoice.id,
lease_id: invoice.lease_id,
amount: invoice.amount,
status: "paid",
paid_at: new Date().toISOString(),
});
}, 5000);
// Return immediately (don't wait for payment)
res.status(201).json({
Why return immediately before the webhook fires?
Real payment services work asynchronously. When Stripe receives a charge request, it returns a "pending" response instantly. The actual payment processing happens later, and you're notified via webhook when it completes.
The setTimeout with 5000ms simulates this delay. Your app gets the invoice ID back immediately, and 5 seconds later the webhook arrives confirming payment.
- Save server.ts.
Confused about where to place the setTimeout?
The setTimeout block goes between the console.log line and the res.status(201).json call. The response still happens at the end of the function. The timeout fires asynchronously after the response is already sent.
help me place the setTimeout block in the right position in my POST handler
Add error simulation and test the endpoints
Real external services go down. Your mock service needs a way to simulate failures so you can test error handling in your app.
- In the POST /api/invoices handler, find this line:
const { amount, lease_id, tenant_email } = req.body;
- Replace it with a destructuring that includes simulate_error, and add the error check immediately after:
const { amount, lease_id, tenant_email, simulate_error } = req.body;
// Simulate external service being down
if (simulate_error) {
res.status(503).json({ error: "Service temporarily unavailable. Please retry later." });
return;
}
What is a 503 status code?
HTTP 503 means "Service Unavailable." It tells the caller that the server is temporarily overloaded or down. When your app receives this, it knows to retry the request later rather than treating it as a permanent failure.
- Save server.ts.
- Rebuild and restart the mock payment service container by running this command from the project root:
docker compose up -d --no-deps --build mock-payment-service
What does this command do?
- --build rebuilds the container image with your new code.
- --no-deps skips restarting services that mock-payment-service depends on (none in this case, but good practice).
- -d runs the container in the background.
- Verify the health endpoint works by running:
curl http://localhost:4000/health
You should see {"status":"ok","service":"mock-payment-service"} in your terminal.
- Test creating an invoice with proper authentication by running:
curl -X POST http://localhost:4000/api/invoices \
-H "Content-Type: application/json" \
-H "Authorization: Bearer test_api_key_12345" \
-d '{"amount": 150000, "lease_id": "test-lease-1", "tenant_email": "tenant@example.com"}'
You should see a JSON response with an invoice ID starting with inv_ and a status of pending.
- Test the error simulation by running:
curl -X POST http://localhost:4000/api/invoices \
-H "Content-Type: application/json" \
-H "Authorization: Bearer test_api_key_12345" \
-d '{"simulate_error": true}'
You should see a 503 response: {"error":"Service temporarily unavailable. Please retry later."}.
- Test that authentication rejection works by running a request without the API key:
curl -X POST http://localhost:4000/api/invoices \
-H "Content-Type: application/json" \
-d '{"amount": 150000}'
You should see a 401 response: {"error":"Missing or invalid Authorization header. Expected: Bearer <API_KEY>"}.
Getting a connection refused error?
Make sure the container is running by checking docker compose ps. The mock-payment-service should show a state of "Up."
If the container failed to start, check the build logs with docker compose logs mock-payment-service. A common issue is a TypeScript compilation error from a missing semicolon or bracket.
help me debug why my mock payment service container won't start
✔️ Awesome, I've got everything!
Great. Double check that you've saved mock-payment-service/src/server.ts and that all three curl tests returned the expected responses.
ⓧ I'd like to double check the full code
import express from "express";
import crypto from "crypto";
import { v4 as uuidv4 } from "uuid";
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 4000;
const API_KEY = process.env.API_KEY || "test_api_key_12345";
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "whsec_mock_secret_abcdef";
const WEBHOOK_URL = process.env.WEBHOOK_URL || "http://next-app:3000/api/webhooks/payments";
const invoices: Map<string, { id: string; amount: number; status: string; lease_id: string; tenant_email: string; created_at: string }> = new Map();
function authenticateRequest(req: express.Request, res: express.Response, next: express.NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing or invalid Authorization header. Expected: Bearer <API_KEY>" });
return;
}
const token = authHeader.split(" ")[1];
if (token !== API_KEY) {
res.status(403).json({ error: "Invalid API key" });
return;
}
next();
}
function computeSignature(payload: string): string {
return crypto.createHmac("sha256", WEBHOOK_SECRET).update(payload).digest("hex");
}
async function sendWebhook(eventType: string, data: object): Promise<void> {
const event = {
id: `evt_${uuidv4()}`,
type: eventType,
created_at: new Date().toISOString(),
data,
};
const body = JSON.stringify(event);
const signature = computeSignature(body);
console.log(`[Webhook] Sending ${eventType} to ${WEBHOOK_URL}`);
console.log(`[Webhook] Signature: ${signature}`);
try {
const response = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-Event-Id": event.id,
},
body,
});
console.log(`[Webhook] Response status: ${response.status}`);
} catch (error) {
console.error(`[Webhook] Failed to deliver:`, error);
}
}
app.post("/api/invoices", authenticateRequest, (req, res) => {
const { amount, lease_id, tenant_email, simulate_error } = req.body;
if (simulate_error) {
res.status(503).json({ error: "Service temporarily unavailable. Please retry later." });
return;
}
if (!amount || !lease_id || !tenant_email) {
res.status(400).json({ error: "Missing required fields", required: ["amount", "lease_id", "tenant_email"] });
return;
}
const invoice = {
id: `inv_${uuidv4()}`,
amount,
status: "pending",
lease_id,
tenant_email,
created_at: new Date().toISOString(),
};
invoices.set(invoice.id, invoice);
console.log(`[API] Created invoice ${invoice.id} for $${amount / 100}`);
setTimeout(async () => {
invoice.status = "paid";
invoices.set(invoice.id, invoice);
console.log(`[Payment] Invoice ${invoice.id} marked as paid`);
await sendWebhook("invoice.paid", {
invoice_id: invoice.id,
lease_id: invoice.lease_id,
amount: invoice.amount,
status: "paid",
paid_at: new Date().toISOString(),
});
}, 5000);
res.status(201).json({
id: invoice.id,
amount: invoice.amount,
status: invoice.status,
lease_id: invoice.lease_id,
tenant_email: invoice.tenant_email,
created_at: invoice.created_at,
});
});
app.get("/api/invoices/:id", authenticateRequest, (req, res) => {
const invoice = invoices.get(req.params.id);
if (!invoice) {
res.status(404).json({ error: "Invoice not found" });
return;
}
res.json(invoice);
});
app.get("/health", (_req, res) => {
res.json({ status: "ok", service: "mock-payment-service" });
});
app.listen(PORT, () => {
console.log(`Mock Payment Service running on port ${PORT}`);
console.log(`Webhook URL: ${WEBHOOK_URL}`);
console.log(`API Key: ${API_KEY.substring(0, 8)}...`);
});
Your mock payment service is fully operational. Next up, you'll create the database schema in Hasura so there's real data for the payment service to work with.
Create the Database Schema in Hasura
Your mock payment service is ready to accept API calls and send webhooks. Now you need the database tables that will store tenants, leases, and invoices.
In this step, you'll use the Hasura Console to create tables, define relationships between them, and configure an Event Trigger that automatically fires when a new lease is inserted.
In this step, get ready to:
- Create the tenants and leases tables with sample data.
- Create the invoices table and set up GraphQL relationships.
- Create a Hasura Event Trigger that fires on new lease inserts.
Create the tenants and leases tables
Every lease in your app belongs to a tenant. You'll create the tenants table first, then the leases table that references it via a foreign key.
- Open your browser and navigate to http://localhost:8080.
- Enter myadminsecret in the admin secret field to sign in.
- Click the Data tab in the top navigation bar.
- Click Create Table.
Configure the tenants table with these settings:
- Set the Table Name to tenants.
- Add a column id with type UUID, default gen_random_uuid(). Leave nullable unchecked.
- Add a column name with type Text. Leave nullable unchecked.
- Add a column email with type Text. Leave nullable unchecked.
- Set the Primary Key to id.
- Click Add Table.
Now create the leases table. Click Create Table again and configure it:
- Set the Table Name to leases.
- Add a column id with type UUID, default gen_random_uuid().
- Add a column tenant_id with type UUID. Leave nullable unchecked.
- Add a column property_address with type Text. Leave nullable unchecked.
- Add a column monthly_rent with type Integer. Leave nullable unchecked.
- Add a column status with type Text, default active.
- Add a column created_at with type Timestamp, default now().
- Set the Primary Key to id.
- In the Foreign Keys section, add a foreign key from tenant_id referencing tenants.id.
- Click Add Table.
Why store rent as an integer?
The monthly_rent column stores amounts in cents (e.g., 150000 = $1,500.00). This avoids floating-point rounding issues that plague money calculations.
This is the same approach Stripe uses for all monetary amounts in their API.
Now insert some sample tenant data so you have records to test with later.
- Click the tenants table in the left sidebar.
- Click the Insert Row tab.
- Leave id blank (it auto-generates). Enter Alice Johnson for name and alice@example.com for email.
- Click Save.
- Insert a second row with Bob Smith for name and bob@example.com for email.
- Click Save.
- Click the Browse Rows tab to confirm both tenants appear.
Don't see your tables?
Make sure you clicked Add Table after configuring each table. If a table is missing, check the Data tab sidebar for existing tables. You may need to scroll down or expand the public schema.
help me troubleshoot why my Hasura tables aren't appearing
Create the invoices table and set up relationships
The invoices table tracks each payment request sent to the external service. It connects back to both the lease and the tenant, and stores the external invoice ID so you can match webhook responses later.
- Click Create Table from the Data tab.
- Set the Table Name to invoices.
- Add a column id with type UUID, default gen_random_uuid().
- Add a column lease_id with type UUID.
- Add a column tenant_id with type UUID.
- Add a column amount with type Integer.
- Add a column status with type Text, default pending.
- Add a column external_invoice_id with type Text.
- Add a column webhook_received_at with type Timestamp. Check nullable for this column.
- Add a column created_at with type Timestamp, default now().
- Set the Primary Key to id.
- In the Foreign Keys section, add a foreign key from lease_id referencing leases.id.
- Add a second foreign key from tenant_id referencing tenants.id.
- Click Add Table.
Now set up the GraphQL relationships so you can query leases with their tenant and invoices in a single request.
- Click the leases table in the left sidebar.
- Click the Relationships tab.
- You should see suggested relationships based on the foreign keys. Click Add on the suggested object relationship to tenants.
- Name the relationship tenant and click Save.
- Click Add on the suggested array relationship from invoices.
- Name the relationship invoices and click Save.
What are object vs array relationships?
An object relationship means each lease has one tenant. When you query a lease, you get a single nested tenant object.
An array relationship means each lease can have many invoices. When you query a lease, you get an array of nested invoice objects. These relationships let you fetch all related data in a single GraphQL query.
- Click the API tab in the top navigation to open the GraphQL playground.
- Verify the relationships work by checking that the leases query shows tenant and invoices as nested fields in the explorer sidebar.
Don't see suggested relationships?
Suggested relationships only appear when foreign key constraints exist. Double-check that you added the foreign keys when creating the leases and invoices tables. You can add them after the fact by clicking the Modify tab on the table and scrolling to the Foreign Keys section.
help me set up foreign keys and relationships in Hasura
Create the Hasura Event Trigger
The final piece of the database layer is an Event Trigger. When a new lease row is inserted, Hasura will automatically send a POST request to your Next.js app. Your app will then call the mock payment service to create an invoice.
- Click the Events tab in the top navigation bar.
- Click the Create button.
- Enter lease_created as the trigger name.
- Select the public schema and the leases table.
- Under Trigger Operations, select Insert.
- Enter the webhook URL: http://next-app:3000/api/events/lease-created.
Why use a Docker service name in the URL?
The Hasura container needs to reach the Next.js container. Inside a Docker Compose network, containers reference each other by service name. That's why the URL uses next-app instead of localhost.
If you used localhost:3000, the Hasura container would try to reach port 3000 on itself, which would fail.
- Expand Advanced Settings.
- In the Headers section, add a header with Key set to x-hasura-event-secret, Type set to static, and Value set to my_event_secret_123.
- Click Create Event Trigger.
What does this Event Trigger do?
This is Hasura calling YOUR app's API when something changes in the database. Every time a new lease row is inserted, Hasura sends the full row data as a JSON payload to your webhook endpoint.
The x-hasura-event-secret header acts as a shared secret. Your Next.js handler will check this header to confirm the request genuinely came from Hasura and not from a random attacker.
You should see the lease_created trigger listed under the Events tab. It won't fire yet because you haven't built the webhook handler. That comes in the next step.
Seeing an error when creating the trigger?
Make sure the leases table exists before creating the trigger. The webhook URL does not need to be reachable yet. Hasura will show failed delivery attempts in the Events tab logs, but the trigger itself will still be created.
help me troubleshoot creating a Hasura Event Trigger
Your database is fully configured. Next up, you'll build the Next.js route handlers that respond to this Event Trigger and call the mock payment service's REST API.
Call an External REST API from Next.js
Your database schema is ready, and the lease_created Event Trigger is configured to fire whenever a new lease row is inserted. The trigger points to an endpoint on your Next.js app that doesn't exist yet.
In this step, you'll write the code that lives behind that endpoint. Your app will receive the event, call the mock payment service's REST API to create an invoice, and write the result back to the database. This is how you talk to any external REST API, whether it's Stripe, SendGrid, or Twilio.
In this step, get ready to:
- Build a typed REST API client that authenticates with the payment service.
- Create the Event Trigger handler that orchestrates the full flow.
- Create a Hasura GraphQL helper and test the entire integration chain.
Create the REST API client
Every external service you integrate with follows the same pattern: construct a request with the right headers, send it, check the status code, and handle errors gracefully. You'll wrap this pattern in a reusable client so the rest of your app never touches raw fetch() calls to the payment service.
- Create the lib directory inside property-app by running this command from the property-app folder:
mkdir -p lib
- Create a new file called payment-client.ts inside the lib folder.
- Add the type definitions and custom error class by pasting this code:
// REST API client for the Mock Payment Service
// This is the same pattern you'd use for Stripe, SendGrid, Twilio, etc.
interface CreateInvoiceParams {
amount: number;
lease_id: string;
tenant_email: string;
}
interface InvoiceResponse {
id: string;
amount: number;
status: string;
lease_id: string;
tenant_email: string;
created_at: string;
}
interface ApiError {
error: string;
required?: string[];
}
class PaymentServiceError extends Error {
public statusCode: number;
public responseBody: ApiError;
constructor(statusCode: number, body: ApiError) {
super(`Payment Service Error (${statusCode}): ${body.error}`);
this.name = "PaymentServiceError";
this.statusCode = statusCode;
this.responseBody = body;
}
}
What does this code do?
CreateInvoiceParams and InvoiceResponse define the exact shape of data going to and coming from the payment API. TypeScript will catch any typos or missing fields at compile time.
PaymentServiceError is a custom error class that stores both the HTTP status code and the parsed error body. This lets calling code distinguish a 503 (service down, retry later) from a 400 (bad request, fix your data) without parsing strings.
- Below the PaymentServiceError class, add the two exported functions that actually call the payment service:
export async function createInvoice(params: CreateInvoiceParams): Promise<InvoiceResponse> {
const baseUrl = process.env.PAYMENT_SERVICE_URL;
const apiKey = process.env.PAYMENT_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("Missing PAYMENT_SERVICE_URL or PAYMENT_API_KEY environment variables");
}
const response = await fetch(`${baseUrl}/api/invoices`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(params),
});
const body = await response.json();
if (!response.ok) {
throw new PaymentServiceError(response.status, body as ApiError);
}
return body as InvoiceResponse;
}
export async function getInvoice(invoiceId: string): Promise<InvoiceResponse> {
const baseUrl = process.env.PAYMENT_SERVICE_URL;
const apiKey = process.env.PAYMENT_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("Missing PAYMENT_SERVICE_URL or PAYMENT_API_KEY environment variables");
}
const response = await fetch(`${baseUrl}/api/invoices/${invoiceId}`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const body = await response.json();
if (!response.ok) {
throw new PaymentServiceError(response.status, body as ApiError);
}
return body as InvoiceResponse;
}
export { PaymentServiceError };
What does this code do?
createInvoice() sends a POST request to the mock payment service's /api/invoices endpoint. It includes the Authorization: Bearer header that the service requires for authentication.
getInvoice() retrieves an invoice by ID using a GET request. Both functions follow the same defensive pattern: check env vars exist, make the request, parse the response, and throw a typed error if the status code indicates failure.
The final export { PaymentServiceError } line re-exports the class so other files can use instanceof checks to catch payment-specific errors.
- Save lib/payment-client.ts.
Why Bearer token authentication?
The Authorization: Bearer <token> pattern is the most common way to authenticate with REST APIs. Stripe, OpenAI, GitHub, and thousands of other services all use it. The token proves your app is allowed to make requests on behalf of your account.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
// REST API client for the Mock Payment Service
// This is the same pattern you'd use for Stripe, SendGrid, Twilio, etc.
interface CreateInvoiceParams {
amount: number;
lease_id: string;
tenant_email: string;
}
interface InvoiceResponse {
id: string;
amount: number;
status: string;
lease_id: string;
tenant_email: string;
created_at: string;
}
interface ApiError {
error: string;
required?: string[];
}
class PaymentServiceError extends Error {
public statusCode: number;
public responseBody: ApiError;
constructor(statusCode: number, body: ApiError) {
super(`Payment Service Error (${statusCode}): ${body.error}`);
this.name = "PaymentServiceError";
this.statusCode = statusCode;
this.responseBody = body;
}
}
export async function createInvoice(params: CreateInvoiceParams): Promise<InvoiceResponse> {
const baseUrl = process.env.PAYMENT_SERVICE_URL;
const apiKey = process.env.PAYMENT_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("Missing PAYMENT_SERVICE_URL or PAYMENT_API_KEY environment variables");
}
const response = await fetch(`${baseUrl}/api/invoices`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(params),
});
const body = await response.json();
if (!response.ok) {
throw new PaymentServiceError(response.status, body as ApiError);
}
return body as InvoiceResponse;
}
export async function getInvoice(invoiceId: string): Promise<InvoiceResponse> {
const baseUrl = process.env.PAYMENT_SERVICE_URL;
const apiKey = process.env.PAYMENT_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("Missing PAYMENT_SERVICE_URL or PAYMENT_API_KEY environment variables");
}
const response = await fetch(`${baseUrl}/api/invoices/${invoiceId}`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const body = await response.json();
if (!response.ok) {
throw new PaymentServiceError(response.status, body as ApiError);
}
return body as InvoiceResponse;
}
export { PaymentServiceError };
Create the Event Trigger handler
When a lease is inserted into the database, Hasura fires the lease_created Event Trigger. That trigger sends a POST request to your Next.js app at /api/events/lease-created. You need to create the route handler that lives at that URL.
What is the full flow?
- A new lease row is inserted into the database.
- Hasura detects the INSERT and sends the new row data to your webhook URL.
- Your handler validates the secret header, looks up the tenant email, calls the payment service, and stores the invoice ID back in Hasura.
- Create the nested directory structure for this route by running:
mkdir -p app/api/events/lease-created
- Create a new file called route.ts inside app/api/events/lease-created.
- Paste the following route handler code:
import { NextResponse } from "next/server";
import { createInvoice, PaymentServiceError } from "@/lib/payment-client";
import { hasuraMutation } from "@/lib/hasura-client";
// This route is called by Hasura Event Trigger when a new lease is inserted
export async function POST(request: Request): Promise<NextResponse> {
// Step 1: Verify the event secret header
const eventSecret = request.headers.get("x-hasura-event-secret");
if (eventSecret !== process.env.EVENT_SECRET) {
console.error("[Event] Invalid event secret");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Step 2: Parse the Hasura event payload
const payload = await request.json();
const newLease = payload.event.data.new;
console.log(`[Event] New lease created: ${newLease.id}`);
console.log(`[Event] Tenant: ${newLease.tenant_id}, Rent: $${newLease.monthly_rent / 100}`);
// Step 3: Look up tenant email from Hasura
const tenantData = await hasuraMutation<{ tenants_by_pk: { email: string } }>(
`query GetTenant($id: uuid!) {
tenants_by_pk(id: $id) {
email
}
}`,
{ id: newLease.tenant_id }
);
const tenantEmail = tenantData.tenants_by_pk?.email;
if (!tenantEmail) {
console.error(`[Event] Tenant ${newLease.tenant_id} not found`);
return NextResponse.json({ error: "Tenant not found" }, { status: 400 });
}
// Step 4: Call the external payment service REST API
try {
const invoice = await createInvoice({
amount: newLease.monthly_rent,
lease_id: newLease.id,
tenant_email: tenantEmail,
});
console.log(`[Event] Invoice created: ${invoice.id}`);
// Step 5: Store the invoice record in our database
await hasuraMutation(
`mutation InsertInvoice($lease_id: uuid!, $tenant_id: uuid!, $amount: Int!, $external_invoice_id: String!) {
insert_invoices_one(object: {
lease_id: $lease_id,
tenant_id: $tenant_id,
amount: $amount,
status: "pending",
external_invoice_id: $external_invoice_id
}) {
id
}
}`,
{
lease_id: newLease.id,
tenant_id: newLease.tenant_id,
amount: newLease.monthly_rent,
external_invoice_id: invoice.id,
}
);
console.log(`[Event] Invoice stored in database`);
return NextResponse.json({ success: true, invoice_id: invoice.id });
} catch (error) {
if (error instanceof PaymentServiceError) {
console.error(`[Event] Payment service error: ${error.message}`);
// Return 500 so Hasura will retry the event
return NextResponse.json({ error: error.message }, { status: 500 });
}
throw error;
}
}
What does this code do?
This is a Next.js Route Handler. It exports a POST function that handles incoming HTTP POST requests at /api/events/lease-created.
- It first verifies the x-hasura-event-secret header to ensure only Hasura can call this endpoint.
- It extracts the new lease data from payload.event.data.new (the structure Hasura sends).
- It queries Hasura for the tenant's email, then calls the payment service to create an invoice.
- Finally, it stores the external invoice ID back in the invoices table so you have a record linking your data to the payment provider.
- If the payment service returns an error, the handler returns 500 so Hasura will automatically retry the event.
- Save app/api/events/lease-created/route.ts.
Seeing import errors in your editor?
The file imports from @/lib/hasura-client which you haven't created yet. That's expected. You'll create it in the next substep. The container build will resolve all imports together.
help me understand why my imports show errors before all files exist
Create the Hasura client and test the flow
The route handler depends on a helper function that sends GraphQL queries to Hasura. This is a lightweight wrapper around fetch() that adds the admin secret header and handles error parsing.
- Create a new file called hasura-client.ts inside property-app/lib.
- Paste the following code:
// GraphQL client for Hasura mutations (server-side only)
interface GraphQLResponse<T> {
data?: T;
errors?: Array<{ message: string }>;
}
export async function hasuraMutation<T>(
query: string,
variables: Record<string, unknown>
): Promise<T> {
const endpoint = process.env.HASURA_GRAPHQL_URL;
const adminSecret = process.env.HASURA_ADMIN_SECRET;
if (!endpoint || !adminSecret) {
throw new Error("Missing HASURA_GRAPHQL_URL or HASURA_ADMIN_SECRET");
}
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-hasura-admin-secret": adminSecret,
},
body: JSON.stringify({ query, variables }),
});
const result: GraphQLResponse<T> = await response.json();
if (result.errors && result.errors.length > 0) {
throw new Error(`Hasura error: ${result.errors[0].message}`);
}
if (!result.data) {
throw new Error("No data returned from Hasura");
}
return result.data;
}
What does this code do?
hasuraMutation() is a generic helper that sends any GraphQL query or mutation to your Hasura instance. It reads HASURA_GRAPHQL_URL and HASURA_ADMIN_SECRET from environment variables (set in docker-compose.yml).
The TypeScript generic <T> lets you type the response shape at each call site. If Hasura returns any errors, the function throws with the first error message so you don't silently swallow failures.
- Save lib/hasura-client.ts.
All three files are in place. Time to rebuild the container and test the full integration chain.
- Rebuild the Next.js container by running this command from the project root (the folder containing docker-compose.yml):
docker compose up --build next-app -d
- Wait about 30 seconds for the container to finish building and starting.
- Open the Docker logs so you can watch the integration flow in real time by running:
docker compose logs -f next-app mock-payment-service
- Switch back to the Hasura Console at http://localhost:8080.
- Click the API tab in the top navigation.
- Paste the following mutation into the GraphiQL editor to insert a test lease:
mutation {
insert_leases_one(object: {
tenant_id: "PASTE_A_TENANT_UUID_HERE",
property_address: "42 Integration Lane",
monthly_rent: 150000
}) {
id
status
}
}
Finding a tenant UUID
Click the database icon in the Hasura sidebar, open the tenants table, and copy the id value from any row. Paste it in place of PASTE_A_TENANT_UUID_HERE in the mutation above.
- Click the play button to run the mutation.
- Switch back to your terminal running the Docker logs.
You should see log lines from both services showing the full chain: the Event Trigger fires, your Next.js handler calls the payment service, and the invoice is stored in the database.
What should I see in the logs?
Look for these log entries in order:
- next-app: [Event] New lease created: <uuid> confirming Hasura called your endpoint.
- mock-payment-service: [API] Created invoice inv_... confirming the REST API call succeeded.
- next-app: [Event] Invoice stored in database confirming the result was written back.
Not seeing any logs after running the mutation?
Make sure the next-app container finished building before you ran the mutation. Check docker compose ps and confirm the status shows "running" for all four services.
If you see a 401 error in the logs, verify that the EVENT_SECRET value in docker-compose.yml matches on both the hasura and next-app services.
help me debug why my Hasura Event Trigger isn't reaching my Next.js route handler
Your app now calls an external REST API automatically whenever a database row changes. Next up, you'll close the loop by building a webhook endpoint that receives the payment confirmation back from the service.
Receive Webhooks and Verify Signatures
In the previous step, your app learned how to call an external service. Now it's time to build the reverse direction.
When the mock payment service finishes processing a payment, it sends a POST request back to your app with the result. This incoming request is called a webhook. In this step, you'll build a secure webhook endpoint that verifies cryptographic signatures before trusting any incoming data.
In this step, get ready to:
- Build a signature verification function using HMAC-SHA256 and timing-safe comparison.
- Create a webhook endpoint that reads raw request bodies and rejects tampered payloads.
- Test the complete integration loop from lease creation to payment confirmation.
Create the signature verification function
Without verification, anyone on the internet could POST a fake "payment succeeded" event to your webhook endpoint. Signature verification proves the request genuinely came from the payment service.
The pattern works like a wax seal on a letter. The sender stamps the message with a secret only you and the sender share. If the seal is broken or missing, you reject it.
- Create a new file at property-app/lib/verify-signature.ts and add the following code:
import crypto from "crypto";
/**
* Verifies an HMAC-SHA256 webhook signature.
*
* This is the same pattern used by Stripe, GitHub, Shopify, and most webhook providers.
* The service computes HMAC-SHA256(secret, rawBody) and sends it in a header.
* We recompute the same HMAC and compare using timing-safe comparison.
*/
export function verifyWebhookSignature(
rawBody: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// Use timing-safe comparison to prevent timing attacks
const sigBuffer = Buffer.from(signature, "hex");
const expectedBuffer = Buffer.from(expectedSignature, "hex");
if (sigBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(sigBuffer, expectedBuffer);
}
What does this code do?
This function takes three inputs: the raw request body (as a string), the signature from the request header, and your webhook secret.
- crypto.createHmac("sha256", secret).update(rawBody).digest("hex") recomputes what the signature should be, using the same secret the sender used.
- Buffer.from(signature, "hex") converts both signatures into Buffers so they can be compared byte-by-byte.
- crypto.timingSafeEqual() compares the buffers in constant time. A regular === comparison leaks timing information that attackers can exploit to guess valid signatures one character at a time.
- Save verify-signature.ts.
Why use request.text() instead of request.json()?
Signature verification requires the exact bytes the sender used to compute the HMAC. If you parse the body with request.json() first, the JSON parser may reorder keys or change whitespace. That changes the bytes, which breaks the signature match.
You must always read the raw body first with request.text(), verify the signature against those exact bytes, and only then parse the JSON.
Not sure where to create the file?
The file goes inside property-app/lib/ alongside payment-client.ts and hasura-client.ts that you created earlier.
help me create the verify-signature.ts file in the right location
Create the webhook endpoint
Now you need a route that the payment service can POST to. This endpoint reads the raw body, verifies the signature, and then updates your database only if the signature is valid.
- Create the directory property-app/app/api/webhooks/payments/ and add a new file called route.ts with the following code:
import { NextResponse } from "next/server";
import { verifyWebhookSignature } from "@/lib/verify-signature";
import { hasuraMutation } from "@/lib/hasura-client";
// This route receives webhooks from the payment service
export async function POST(request: Request): Promise<NextResponse> {
// Step 1: Read the RAW body (not parsed JSON!)
// This is critical: signature verification requires the exact bytes sent
const rawBody = await request.text();
// Step 2: Get the signature header
const signature = request.headers.get("x-webhook-signature");
if (!signature) {
console.error("[Webhook] Missing signature header");
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
// Step 3: Verify the signature
const secret = process.env.WEBHOOK_SECRET;
if (!secret) {
console.error("[Webhook] WEBHOOK_SECRET not configured");
return NextResponse.json({ error: "Server configuration error" }, { status: 500 });
}
const isValid = verifyWebhookSignature(rawBody, signature, secret);
if (!isValid) {
console.error("[Webhook] Invalid signature - request rejected");
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
console.log("[Webhook] Signature verified successfully");
// Step 4: NOW it's safe to parse the JSON
const event = JSON.parse(rawBody);
console.log(`[Webhook] Event type: ${event.type}, ID: ${event.id}`);
// Step 5: Handle the event based on type
if (event.type === "invoice.paid") {
const { invoice_id, lease_id, paid_at } = event.data;
console.log(`[Webhook] Invoice ${invoice_id} paid for lease ${lease_id}`);
// Update the invoice status in our database
await hasuraMutation(
`mutation UpdateInvoiceStatus($external_invoice_id: String!, $paid_at: timestamptz!) {
update_invoices(
where: { external_invoice_id: { _eq: $external_invoice_id } },
_set: { status: "paid", webhook_received_at: $paid_at }
) {
affected_rows
}
}`,
{
external_invoice_id: invoice_id,
paid_at: paid_at,
}
);
console.log(`[Webhook] Database updated - invoice marked as paid`);
}
// Step 6: Always return 200 quickly
// External services interpret non-2xx as failure and will retry
return NextResponse.json({ received: true });
}
What does this webhook handler do?
- await request.text() reads the raw body as a string. This preserves the exact bytes needed for signature verification.
- verifyWebhookSignature() checks that the request is authentic. If it fails, we reject immediately with a 401.
- JSON.parse(rawBody) only runs after the signature is verified. At this point we trust the data.
- The hasuraMutation call updates the invoice's status to "paid" and sets webhook_received_at so you know exactly when payment was confirmed.
- Returning 200 immediately is critical. External services interpret non-2xx responses as delivery failures and will retry, potentially causing duplicate processing.
- Save route.ts.
Seeing a module not found error?
Make sure verify-signature.ts is saved in property-app/lib/. The import path @/lib/verify-signature uses the @/* alias defined in tsconfig.json, which maps to the project root.
help me debug why my verify-signature import is failing
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on to testing.
ⓧ I'd like to double check the full code
Here is the complete verify-signature.ts:
import crypto from "crypto";
/**
* Verifies an HMAC-SHA256 webhook signature.
*
* This is the same pattern used by Stripe, GitHub, Shopify, and most webhook providers.
* The service computes HMAC-SHA256(secret, rawBody) and sends it in a header.
* We recompute the same HMAC and compare using timing-safe comparison.
*/
export function verifyWebhookSignature(
rawBody: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// Use timing-safe comparison to prevent timing attacks
const sigBuffer = Buffer.from(signature, "hex");
const expectedBuffer = Buffer.from(expectedSignature, "hex");
if (sigBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(sigBuffer, expectedBuffer);
}
And here is the complete route.ts for the webhook endpoint:
import { NextResponse } from "next/server";
import { verifyWebhookSignature } from "@/lib/verify-signature";
import { hasuraMutation } from "@/lib/hasura-client";
// This route receives webhooks from the payment service
export async function POST(request: Request): Promise<NextResponse> {
// Step 1: Read the RAW body (not parsed JSON!)
// This is critical: signature verification requires the exact bytes sent
const rawBody = await request.text();
// Step 2: Get the signature header
const signature = request.headers.get("x-webhook-signature");
if (!signature) {
console.error("[Webhook] Missing signature header");
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
// Step 3: Verify the signature
const secret = process.env.WEBHOOK_SECRET;
if (!secret) {
console.error("[Webhook] WEBHOOK_SECRET not configured");
return NextResponse.json({ error: "Server configuration error" }, { status: 500 });
}
const isValid = verifyWebhookSignature(rawBody, signature, secret);
if (!isValid) {
console.error("[Webhook] Invalid signature - request rejected");
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
console.log("[Webhook] Signature verified successfully");
// Step 4: NOW it's safe to parse the JSON
const event = JSON.parse(rawBody);
console.log(`[Webhook] Event type: ${event.type}, ID: ${event.id}`);
// Step 5: Handle the event based on type
if (event.type === "invoice.paid") {
const { invoice_id, lease_id, paid_at } = event.data;
console.log(`[Webhook] Invoice ${invoice_id} paid for lease ${lease_id}`);
// Update the invoice status in our database
await hasuraMutation(
`mutation UpdateInvoiceStatus($external_invoice_id: String!, $paid_at: timestamptz!) {
update_invoices(
where: { external_invoice_id: { _eq: $external_invoice_id } },
_set: { status: "paid", webhook_received_at: $paid_at }
) {
affected_rows
}
}`,
{
external_invoice_id: invoice_id,
paid_at: paid_at,
}
);
console.log(`[Webhook] Database updated - invoice marked as paid`);
}
// Step 6: Always return 200 quickly
// External services interpret non-2xx as failure and will retry
return NextResponse.json({ received: true });
}
Test the full webhook flow
Both files are ready. Time to rebuild the Next.js container and watch the entire integration loop fire in sequence.
- Rebuild the next-app container to pick up your new files by running:
docker compose up --build next-app -d
- Wait for the container to finish building (about 30 seconds).
- Open a second terminal and start watching the container logs by running:
docker compose logs -f next-app mock-payment-service
Why watch the logs?
The console.log statements in both services print each step of the integration chain as it happens. You'll see the event trigger fire, the API call go out, and the webhook come back in real-time.
- Switch to the Hasura Console at localhost:8080.
- Click the **Data** tab in the top navigation.
- Select the leases table from the sidebar.
- Click **Insert Row**.
- Enter a tenant_id value (copy one from your tenants table).
- Enter a property_address like 742 Evergreen Terrace.
- Enter a monthly_rent value like 150000 (this is in cents, so $1500).
- Click **Save**.
Watch your terminal logs. Within a few seconds you should see the Event Trigger fire, the invoice get created in the payment service, and a log line confirming the API call succeeded.
- Wait approximately 5 seconds for the mock payment service to "process" the payment and send the webhook.
You should see log lines showing the webhook being sent, the signature being verified, and the database being updated.
- In the Hasura Console, click the invoices table in the sidebar.
- Click **Browse Rows**.
- Verify that your invoice row now shows status as paid and webhook_received_at has a timestamp.
What just happened?
You triggered the complete integration loop:
- You inserted a lease into the database.
- Hasura's Event Trigger detected the INSERT and called your lease-created handler.
- Your handler called the payment service's REST API to create an invoice.
- After 5 seconds, the payment service sent a signed webhook to your /api/webhooks/payments endpoint.
- Your endpoint verified the signature, parsed the payload, and updated the invoice status in the database.
Now confirm that your endpoint properly rejects invalid signatures.
- Send a fake webhook with a bogus signature by running:
curl -X POST http://localhost:3000/api/webhooks/payments \
-H "Content-Type: application/json" \
-H "x-webhook-signature: fake_invalid_signature_here" \
-d '{"type":"invoice.paid","data":{"invoice_id":"inv_fake"}}'
- You should see a response containing {"error":"Invalid signature"} with a 401 status.
Why does this matter in production?
Without signature verification, a malicious actor could send a POST to your webhook endpoint claiming that every invoice has been paid. Your app would happily mark everything as paid and your business would give away services for free. The HMAC signature proves the message is authentic.
Invoice status still showing pending?
- Check that the next-app container rebuilt successfully. Run docker compose ps and confirm all four containers are running.
- Look at the mock-payment-service logs for a line like [Webhook] Response status: 200. If you see a non-200 status, the webhook was rejected.
- Verify that WEBHOOK_SECRET in docker-compose.yml matches between the mock-payment-service and next-app services. Both must be whsec_mock_secret_abcdef.
my webhook isn't working and the invoice stays pending after 5 seconds
Your webhook endpoint is bulletproof. It only processes events that pass cryptographic verification. Next up, you'll build the frontend so users can create leases and watch the full payment loop happen from the browser.
Build the Frontend and Test End-to-End
Your backend is fully wired. The Event Trigger fires when a lease is inserted, calls the payment service, and the webhook handler updates the database when payment confirms.
Now you need the final piece: a frontend that lets a property manager create leases and watch invoices move from "pending" to "paid" in real-time. In this step, you'll build a Next.js server component that queries your Hasura GraphQL endpoint, plus a client component form that triggers the entire integration chain with one click.
In this step, get ready to:
- Create a dashboard page that displays leases and invoice statuses.
- Build a form component that inserts new leases via GraphQL mutation.
- Run the full end-to-end integration and watch the status flip from pending to paid.
Create the dashboard page
Every Next.js app needs a root layout that wraps all pages. This is the shell that provides the <html> and <body> tags.
- Create a new file at property-app/app/layout.tsx and add the following code:
export const metadata = {
title: "Property Management - Payment Integration",
description: "REST APIs, Webhooks, and Hasura Event Triggers",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
What does this code do?
The metadata export sets the page title and description for the browser tab. The RootLayout component wraps every page in your app with the required HTML structure.
- Save layout.tsx.
Next, you'll create the main page that fetches lease and tenant data from Hasura, then renders a dashboard showing each lease with its invoice status.
- Create a new file at property-app/app/page.tsx and add the following code:
import CreateLeaseForm from "./components/CreateLeaseForm";
interface Tenant {
id: string;
name: string;
email: string;
}
interface Invoice {
id: string;
amount: number;
status: string;
external_invoice_id: string;
webhook_received_at: string | null;
created_at: string;
}
interface Lease {
id: string;
property_address: string;
monthly_rent: number;
status: string;
created_at: string;
tenant: Tenant;
invoices: Invoice[];
}
// Fetch all leases with their tenant and invoice data from Hasura
async function getLeases(): Promise<Lease[]> {
const endpoint = process.env.NEXT_PUBLIC_HASURA_URL || "http://hasura:8080/v1/graphql";
const adminSecret = process.env.NEXT_PUBLIC_HASURA_ADMIN_SECRET || "myadminsecret";
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-hasura-admin-secret": adminSecret,
},
body: JSON.stringify({
query: `
query GetLeases {
leases(order_by: { created_at: desc }) {
id
property_address
monthly_rent
status
created_at
tenant {
id
name
email
}
invoices(order_by: { created_at: desc }) {
id
amount
status
external_invoice_id
webhook_received_at
created_at
}
}
}
`,
}),
cache: "no-store",
});
const result = await response.json();
return result.data?.leases || [];
}
// Fetch all tenants for the lease creation form
async function getTenants(): Promise<Tenant[]> {
const endpoint = process.env.NEXT_PUBLIC_HASURA_URL || "http://hasura:8080/v1/graphql";
const adminSecret = process.env.NEXT_PUBLIC_HASURA_ADMIN_SECRET || "myadminsecret";
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-hasura-admin-secret": adminSecret,
},
body: JSON.stringify({
query: `query GetTenants { tenants { id name email } }`,
}),
cache: "no-store",
});
const result = await response.json();
return result.data?.tenants || [];
}
export default async function HomePage() {
const leases = await getLeases();
const tenants = await getTenants();
return (
<main style={{ maxWidth: "900px", margin: "0 auto", padding: "2rem", fontFamily: "system-ui" }}>
<h1>Property Management - Payment Integration</h1>
<p style={{ color: "#666" }}>
Create a lease below. The system will automatically create an invoice via the external payment service,
then receive a webhook when payment is confirmed.
</p>
<section style={{ marginTop: "2rem", padding: "1.5rem", border: "1px solid #ddd", borderRadius: "8px" }}>
<h2>Create New Lease</h2>
<CreateLeaseForm tenants={tenants} />
</section>
<section style={{ marginTop: "2rem" }}>
<h2>Leases & Invoices</h2>
{leases.length === 0 ? (
<p style={{ color: "#999" }}>No leases yet. Create one above!</p>
) : (
leases.map((lease) => (
<div key={lease.id} style={{ border: "1px solid #eee", borderRadius: "8px", padding: "1rem", marginBottom: "1rem" }}>
<h3>{lease.property_address}</h3>
<p>
Tenant: {lease.tenant.name} ({lease.tenant.email})<br />
Monthly Rent: ${(lease.monthly_rent / 100).toFixed(2)}<br />
Lease Status: <strong>{lease.status}</strong>
</p>
{lease.invoices.length > 0 && (
<div style={{ marginTop: "0.5rem", paddingLeft: "1rem", borderLeft: "3px solid #eee" }}>
<h4>Invoices:</h4>
{lease.invoices.map((inv) => (
<div key={inv.id} style={{ marginBottom: "0.5rem" }}>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "4px",
fontSize: "12px",
fontWeight: "bold",
backgroundColor: inv.status === "paid" ? "#d4edda" : "#fff3cd",
color: inv.status === "paid" ? "#155724" : "#856404",
}}
>
{inv.status.toUpperCase()}
</span>{" "}
${(inv.amount / 100).toFixed(2)} — External ID: {inv.external_invoice_id}
{inv.webhook_received_at && (
<span style={{ color: "#666", fontSize: "12px" }}>
{" "}(paid at {new Date(inv.webhook_received_at).toLocaleTimeString()})
</span>
)}
</div>
))}
</div>
)}
</div>
))
)}
</section>
</main>
);
}
What does this code do?
- getLeases() sends a GraphQL query to Hasura that fetches all leases along with their related tenant and invoice data. The cache: "no-store" option ensures every page load fetches fresh data.
- getTenants() fetches the list of available tenants so the form can show them in a dropdown.
- HomePage is an async server component. It calls both data functions on the server before rendering, so no client-side loading spinners are needed.
- Each invoice displays a colored badge (green for paid, yellow for pending) so you can instantly see when the webhook has updated the status.
- Save page.tsx.
Seeing a TypeScript error about CreateLeaseForm?
The import at the top of page.tsx references ./components/CreateLeaseForm which does not exist yet. That is expected. You will create this file in the next substep.
help me understand why my import is showing an error
✔️ Awesome, I've got everything!
Great. Make sure both layout.tsx and page.tsx are saved.
ⓧ I'd like to double check the full code
Here is the full property-app/app/layout.tsx:
export const metadata = {
title: "Property Management - Payment Integration",
description: "REST APIs, Webhooks, and Hasura Event Triggers",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Here is the full property-app/app/page.tsx:
import CreateLeaseForm from "./components/CreateLeaseForm";
interface Tenant {
id: string;
name: string;
email: string;
}
interface Invoice {
id: string;
amount: number;
status: string;
external_invoice_id: string;
webhook_received_at: string | null;
created_at: string;
}
interface Lease {
id: string;
property_address: string;
monthly_rent: number;
status: string;
created_at: string;
tenant: Tenant;
invoices: Invoice[];
}
async function getLeases(): Promise<Lease[]> {
const endpoint = process.env.NEXT_PUBLIC_HASURA_URL || "http://hasura:8080/v1/graphql";
const adminSecret = process.env.NEXT_PUBLIC_HASURA_ADMIN_SECRET || "myadminsecret";
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-hasura-admin-secret": adminSecret,
},
body: JSON.stringify({
query: `
query GetLeases {
leases(order_by: { created_at: desc }) {
id
property_address
monthly_rent
status
created_at
tenant {
id
name
email
}
invoices(order_by: { created_at: desc }) {
id
amount
status
external_invoice_id
webhook_received_at
created_at
}
}
}
`,
}),
cache: "no-store",
});
const result = await response.json();
return result.data?.leases || [];
}
async function getTenants(): Promise<Tenant[]> {
const endpoint = process.env.NEXT_PUBLIC_HASURA_URL || "http://hasura:8080/v1/graphql";
const adminSecret = process.env.NEXT_PUBLIC_HASURA_ADMIN_SECRET || "myadminsecret";
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-hasura-admin-secret": adminSecret,
},
body: JSON.stringify({
query: `query GetTenants { tenants { id name email } }`,
}),
cache: "no-store",
});
const result = await response.json();
return result.data?.tenants || [];
}
export default async function HomePage() {
const leases = await getLeases();
const tenants = await getTenants();
return (
<main style={{ maxWidth: "900px", margin: "0 auto", padding: "2rem", fontFamily: "system-ui" }}>
<h1>Property Management - Payment Integration</h1>
<p style={{ color: "#666" }}>
Create a lease below. The system will automatically create an invoice via the external payment service,
then receive a webhook when payment is confirmed.
</p>
<section style={{ marginTop: "2rem", padding: "1.5rem", border: "1px solid #ddd", borderRadius: "8px" }}>
<h2>Create New Lease</h2>
<CreateLeaseForm tenants={tenants} />
</section>
<section style={{ marginTop: "2rem" }}>
<h2>Leases & Invoices</h2>
{leases.length === 0 ? (
<p style={{ color: "#999" }}>No leases yet. Create one above!</p>
) : (
leases.map((lease) => (
<div key={lease.id} style={{ border: "1px solid #eee", borderRadius: "8px", padding: "1rem", marginBottom: "1rem" }}>
<h3>{lease.property_address}</h3>
<p>
Tenant: {lease.tenant.name} ({lease.tenant.email})<br />
Monthly Rent: ${(lease.monthly_rent / 100).toFixed(2)}<br />
Lease Status: <strong>{lease.status}</strong>
</p>
{lease.invoices.length > 0 && (
<div style={{ marginTop: "0.5rem", paddingLeft: "1rem", borderLeft: "3px solid #eee" }}>
<h4>Invoices:</h4>
{lease.invoices.map((inv) => (
<div key={inv.id} style={{ marginBottom: "0.5rem" }}>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "4px",
fontSize: "12px",
fontWeight: "bold",
backgroundColor: inv.status === "paid" ? "#d4edda" : "#fff3cd",
color: inv.status === "paid" ? "#155724" : "#856404",
}}
>
{inv.status.toUpperCase()}
</span>{" "}
${(inv.amount / 100).toFixed(2)} — External ID: {inv.external_invoice_id}
{inv.webhook_received_at && (
<span style={{ color: "#666", fontSize: "12px" }}>
{" "}(paid at {new Date(inv.webhook_received_at).toLocaleTimeString()})
</span>
)}
</div>
))}
</div>
)}
</div>
))
)}
</section>
</main>
);
}
Create the lease creation form
The dashboard page imports a CreateLeaseForm component that does not exist yet. This is a client component because it needs interactivity: form state, loading indicators, and a post-submit auto-refresh.
- Create the property-app/app/components/ directory.
- Create a new file at property-app/app/components/CreateLeaseForm.tsx and add the following code:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface Tenant {
id: string;
name: string;
email: string;
}
export default function CreateLeaseForm({ tenants }: { tenants: Tenant[] }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setMessage("");
const formData = new FormData(e.currentTarget);
const tenantId = formData.get("tenant_id") as string;
const address = formData.get("property_address") as string;
const rent = parseInt(formData.get("monthly_rent") as string, 10) * 100; // Convert dollars to cents
const endpoint = process.env.NEXT_PUBLIC_HASURA_URL || "http://localhost:8080/v1/graphql";
const adminSecret = process.env.NEXT_PUBLIC_HASURA_ADMIN_SECRET || "myadminsecret";
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-hasura-admin-secret": adminSecret,
},
body: JSON.stringify({
query: `
mutation CreateLease($tenant_id: uuid!, $property_address: String!, $monthly_rent: Int!) {
insert_leases_one(object: {
tenant_id: $tenant_id,
property_address: $property_address,
monthly_rent: $monthly_rent
}) {
id
}
}
`,
variables: {
tenant_id: tenantId,
property_address: address,
monthly_rent: rent,
},
}),
});
const result = await response.json();
if (result.errors) {
setMessage(`Error: ${result.errors[0].message}`);
} else {
setMessage("Lease created! Invoice will be generated automatically. Refresh in ~5 seconds to see payment confirmation.");
// Auto-refresh after delay to show webhook result
setTimeout(() => router.refresh(), 7000);
}
} catch (error) {
setMessage(`Error: ${error instanceof Error ? error.message : "Unknown error"}`);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="tenant_id" style={{ display: "block", marginBottom: "4px", fontWeight: "bold" }}>
Tenant:
</label>
<select id="tenant_id" name="tenant_id" required style={{ padding: "8px", width: "100%", fontSize: "14px" }}>
<option value="">Select a tenant...</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.email})
</option>
))}
</select>
</div>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="property_address" style={{ display: "block", marginBottom: "4px", fontWeight: "bold" }}>
Property Address:
</label>
<input
id="property_address"
name="property_address"
type="text"
required
placeholder="123 Main St, Apt 4B"
style={{ padding: "8px", width: "100%", fontSize: "14px" }}
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="monthly_rent" style={{ display: "block", marginBottom: "4px", fontWeight: "bold" }}>
Monthly Rent ($):
</label>
<input
id="monthly_rent"
name="monthly_rent"
type="number"
required
min="1"
placeholder="1500"
style={{ padding: "8px", width: "100%", fontSize: "14px" }}
/>
</div>
<button
type="submit"
disabled={loading}
style={{
padding: "10px 20px",
fontSize: "14px",
fontWeight: "bold",
backgroundColor: loading ? "#ccc" : "#0070f3",
color: "white",
border: "none",
borderRadius: "6px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "Creating..." : "Create Lease"}
</button>
{message && (
<p style={{ marginTop: "1rem", padding: "10px", backgroundColor: "#f0f0f0", borderRadius: "4px" }}>
{message}
</p>
)}
</form>
);
}
What does this code do?
- "use client" marks this as a client component. It runs in the browser so it can manage form state and respond to user interactions.
- handleSubmit sends a CreateLease GraphQL mutation directly to Hasura. When the lease row is inserted, the lease_created Event Trigger fires automatically.
- After a successful insert, setTimeout(() => router.refresh(), 7000) waits 7 seconds then re-fetches the server component data. This gives the mock payment service time to "process" the payment (5 seconds) and send the webhook back.
- The rent field multiplies by 100 to convert dollars to cents, matching the format the payment service expects.
- Save CreateLeaseForm.tsx.
Form not showing tenant options?
Make sure you inserted sample tenant rows in the Hasura Console back in Step 3. The getTenants() function on the server fetches these rows and passes them as props to the form.
help me debug why my tenant dropdown is empty
✔️ Awesome, I've got everything!
Great. Make sure CreateLeaseForm.tsx is saved in the property-app/app/components/ directory.
ⓧ I'd like to double check the full code
Here is the full property-app/app/components/CreateLeaseForm.tsx:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface Tenant {
id: string;
name: string;
email: string;
}
export default function CreateLeaseForm({ tenants }: { tenants: Tenant[] }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setMessage("");
const formData = new FormData(e.currentTarget);
const tenantId = formData.get("tenant_id") as string;
const address = formData.get("property_address") as string;
const rent = parseInt(formData.get("monthly_rent") as string, 10) * 100; // Convert dollars to cents
const endpoint = process.env.NEXT_PUBLIC_HASURA_URL || "http://localhost:8080/v1/graphql";
const adminSecret = process.env.NEXT_PUBLIC_HASURA_ADMIN_SECRET || "myadminsecret";
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-hasura-admin-secret": adminSecret,
},
body: JSON.stringify({
query: `
mutation CreateLease($tenant_id: uuid!, $property_address: String!, $monthly_rent: Int!) {
insert_leases_one(object: {
tenant_id: $tenant_id,
property_address: $property_address,
monthly_rent: $monthly_rent
}) {
id
}
}
`,
variables: {
tenant_id: tenantId,
property_address: address,
monthly_rent: rent,
},
}),
});
const result = await response.json();
if (result.errors) {
setMessage(`Error: ${result.errors[0].message}`);
} else {
setMessage("Lease created! Invoice will be generated automatically. Refresh in ~5 seconds to see payment confirmation.");
// Auto-refresh after delay to show webhook result
setTimeout(() => router.refresh(), 7000);
}
} catch (error) {
setMessage(`Error: ${error instanceof Error ? error.message : "Unknown error"}`);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="tenant_id" style={{ display: "block", marginBottom: "4px", fontWeight: "bold" }}>
Tenant:
</label>
<select id="tenant_id" name="tenant_id" required style={{ padding: "8px", width: "100%", fontSize: "14px" }}>
<option value="">Select a tenant...</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.email})
</option>
))}
</select>
</div>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="property_address" style={{ display: "block", marginBottom: "4px", fontWeight: "bold" }}>
Property Address:
</label>
<input
id="property_address"
name="property_address"
type="text"
required
placeholder="123 Main St, Apt 4B"
style={{ padding: "8px", width: "100%", fontSize: "14px" }}
/>
</div>
<div style={{ marginBottom: "1rem" }}>
<label htmlFor="monthly_rent" style={{ display: "block", marginBottom: "4px", fontWeight: "bold" }}>
Monthly Rent ($):
</label>
<input
id="monthly_rent"
name="monthly_rent"
type="number"
required
min="1"
placeholder="1500"
style={{ padding: "8px", width: "100%", fontSize: "14px" }}
/>
</div>
<button
type="submit"
disabled={loading}
style={{
padding: "10px 20px",
fontSize: "14px",
fontWeight: "bold",
backgroundColor: loading ? "#ccc" : "#0070f3",
color: "white",
border: "none",
borderRadius: "6px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "Creating..." : "Create Lease"}
</button>
{message && (
<p style={{ marginTop: "1rem", padding: "10px", backgroundColor: "#f0f0f0", borderRadius: "4px" }}>
{message}
</p>
)}
</form>
);
}
Run the complete end-to-end test
All three frontend files are in place. Now you need to rebuild the Docker containers so the Next.js app picks up the new pages, then test the full integration chain from UI to database and back.
- Rebuild and restart all containers by running this command:
docker compose up --build
Why rebuild?
Your Next.js app runs inside a Docker container. The Dockerfile copies your source files and runs npm run build during the image build step. New files like page.tsx and CreateLeaseForm.tsx only become available after rebuilding the container image.
- Wait for all four services to start. You should see log lines from postgres, hasura, mock-payment-service, and next-app in the terminal output.
- Open your browser and navigate to http://localhost:3000.
You should see the Property Management dashboard with a "Create New Lease" form at the top and an empty "Leases & Invoices" section below.
- Select a tenant from the dropdown.
- Enter a property address (for example, 123 Main St, Apt 4B).
- Enter a monthly rent amount (for example, 1500).
- Click **Create Lease**.
Watch the terminal where Docker Compose is running. You should see logs appearing in this order:
- The next-app container logs show the Event Trigger handler receiving the new lease.
- The mock-payment-service container logs show an invoice being created.
- After about 5 seconds, the mock service logs show the payment being marked as paid and the webhook being sent.
- The next-app container logs show the webhook being received and the signature verified.
- Wait about 7 seconds for the page to auto-refresh (or manually refresh your browser).
The lease you just created should now appear with a green "PAID" badge on its invoice. The full chain completed: form submit triggered a database insert, which triggered an Event Trigger, which called the payment API, which sent a webhook back, which updated the invoice status.
How does this translate to production?
In production, you would replace http://mock-payment-service:4000 with https://api.stripe.com and the patterns are identical. The REST client, webhook signature verification, and Event Trigger architecture all transfer directly.
Docker service names become real domain names. The Authorization: Bearer header carries a real Stripe API key. The webhook secret comes from Stripe's dashboard instead of your docker-compose.yml.
Page shows an error or is blank?
- Check the Docker Compose terminal for build errors. A typo in page.tsx or CreateLeaseForm.tsx will cause npm run build to fail inside the container.
- Confirm that CreateLeaseForm.tsx is inside property-app/app/components/ (not directly in property-app/app/).
- Verify that your Hasura tables still have the relationships set up from Step 3. The GraphQL query joins leases with tenant and invoices through those relationships.
help me debug why my Next.js frontend is not rendering
Invoice stays pending and never flips to paid?
- Check the mock-payment-service logs for a webhook delivery error. If it shows a connection refused, the WEBHOOK_URL environment variable may be misconfigured.
- Verify the WEBHOOK_SECRET value matches between the mock-payment-service and next-app services in docker-compose.yml. A mismatch causes signature verification to fail.
- Open http://localhost:8080 (Hasura Console) and check the invoices table directly. If the invoice row exists but status is still pending, the webhook handler did not update it.
help me figure out why my webhook is not updating the invoice status to paid
Secret mission
Add Webhook Retry Logic and a Failed Events Dashboard
In production, webhooks fail. Networks go down, services restart, and packets get lost. Can you build the resilience layer that real payment providers like Stripe use? Add exponential backoff retries, idempotency checks to prevent double-processing, and a monitoring dashboard to track every delivery attempt.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Four Docker containers: postgres, hasura, mock-payment-service, and next-app.
- A Docker volume (pgdata) storing your PostgreSQL database.
- Project files on your Desktop (docker-compose.yml, mock-payment-service/, property-app/).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the integration loop.
- Your four Docker containers continue running and using memory.
- The PostgreSQL volume retains all your lease, invoice, and webhook event data.
- You can access the Hasura Console at localhost:8080, the Next.js app at localhost:3000, and the mock service at localhost:4000.
Pause - I'll come back to this later
Shut down the running containers to free up memory while keeping your database data intact for next time.
- Open the terminal where your containers are running.
- Stop all containers by running this command from your project folder (the one containing docker-compose.yml):
docker compose down
What does this keep?
This stops and removes all four containers but preserves the pgdata volume. When you run docker compose up --build again, your tenants, leases, invoices, and webhook events will still be there.
Delete - I don't want to use this again
Remove all project resources completely and start fresh.
- Stop all containers and remove the database volume by running this command from your project folder:
docker compose down -v
- Remove the Docker images built for this project by running:
docker rmi $(docker images --filter "label=com.docker.compose.project" -q) 2>/dev/null
- Navigate up one level from your project folder by running:
cd ..
- Delete the project folder entirely. On macOS/Linux, run:
rm -rf property-management
On Windows, run:
rmdir /s /q property-management
What stays on your machine?
Docker itself remains installed. The base images (postgres:16, hasura/graphql-engine:v2.40.0, node:20-alpine) remain in your Docker image cache. You can remove them with docker image prune -a if you want to reclaim disk space.
Nice Work!
Nice Work!
You just built a complete external service integration from scratch. Your property management app now creates invoices through a REST API, receives cryptographically signed webhooks, and updates the database automatically through event-driven architecture.
You've learned how to:
- Built both sides of a REST API integration by creating a mock payment service with Express and calling it from Next.js route handlers using proper Bearer token authentication, typed responses, and structured error handling.
- Built a secure webhook endpoint that verifies HMAC-SHA256 signatures using timing-safe comparison and rejects tampered requests.
- Connected Hasura Event Triggers to create a full event-driven loop where a database insert automatically triggers an external API call, which sends a webhook back, which updates the database and refreshes the UI.
- Secret Mission: Added webhook retry logic with exponential backoff, idempotency checks to prevent double-processing, and a failed events monitoring dashboard with replay capability.
Ready to quiz yourself?