In this tutorial we’re going to build a simple but feature complete Next JS application that lets your users view and take action against incoming payment requests that they have initiated using their payment alias. This tutorial is written from an issuer’s point of view and is relevant for any company that manages an account or wallet balance on behalf of their customers. There’s no database or other “production ready” things, so we can stay focused on Paylias. We expect this to take about 30 minutes if you’re following along, otherwise it’s a quick read so let get to it!

Setup

In our Next JS app that lets issuers surface pending tasks in the UI, and approve or reject them, we’ll use:
  1. A singleton in-memory DB
  2. ShadCN UI components
  3. Lucide icons
Scaffold your project
# 1. Scaffold Next.js App Router with TypeScript
npx create-next-app@latest paylias-issuer --typescript --app
cd paylias-issuer

# 2. Install dependencies
npm install uuid @types/uuid lucide-react

# 3. Initialize ShadCN UI (Tailwind + components)
npx shadcn@latest init
In-memory DB means data resets on restart. For production, swap in a real database.

Start the app

# If you haven't already cd into the app directory
cd paylias-issuer
# start the app
npm run dev
You should be able to open up http://localhost:3000 (or depending on your settings it should open up automatically) and see the default Next JS screen.

Folder structure

Next, you’re going to want to create a folder struture similar to this layout.
folder structure
paylias-issuer/
├── app/
│   ├── api/
│   │   ├── webhook/
│   │   │   ├── payment-admission/
│   │   │   │   ├── created/route.ts
│   │   │   │   └── updated/route.ts
│   │   │   ├── payment-admission-task/
│   │   │   │   ├── created/route.ts
│   │   │   │   └── updated/route.ts
│   │   │   └── …other webhooks…
│   │   └── tasks/
│   │       ├── route.ts        ← GET all
│   │       └── [token]/route.ts ← GET/PUT single
│   └── page.tsx                ← Main UI
├── components/
│   ├── NavBar.tsx
│   └── task/
│       ├── TaskList.tsx
│       ├── TaskItem.tsx
│       ├── StatusBadge.tsx
│       └── TaskModal.tsx
├── database/
│   └── memory-db.ts
├── repository/
│   └── taskRepository.ts
├── types/
│   └── task.ts
├── lib/
│   ├── utils.ts               ← `cn()` helper
│   └── signature.ts           ← webhook signature verification
├── package.json
└── tsconfig.json
The main thing to get right here are the files under the api/webhook folder. We’re going to opt for creating a separate route for each webhook event to keep a clear separation of concerns and since we’re using Next JS’s app based routing system, that means we need to create the following files
app/api/webhook/payment/created/route.ts
app/api/webhook/payment/updated/route.ts

app/api/webhook/payment-submission/created/route.ts
app/api/webhook/payment-submission/updated/route.ts

app/api/webhook/payment-admission/created/route.ts
app/api/webhook/payment-admission/updated/route.ts

app/api/webhook/payment-admission-task/created/route.ts
app/api/webhook/payment-admission-task/updated/route.ts

app/api/webhook/payment-exception/created/route.ts
app/api/webhook/payment-transaction/created/route.ts
Once these files have been created, add this placeholder code in each file for now. Later on in this guide, we’ll update the ones relevant for an issuer.
Webhook POST handler
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    // Parse the webhook payload
    const body = await request.json();

    // Log the webhook payload for debugging
    console.log('Payment created webhook received:', body);

    // Here you would typically:
    // 1. Validate the webhook signature
    // 2. Process the payment created event
    // 3. Update your database accordingly

    // For now, just return a successful response
    return NextResponse.json(
      { message: 'Payment created webhook processed successfully' },
      { status: 200 }
    );
  } catch (error) {
    console.error('Error processing payment created webhook:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Signature Verification

Finally, we’ll add a helper function to validate the webhook signature from incoming events sent by Paylias. Open up the lib/signature.ts file and add in the following code.
signature.ts
import crypto from 'crypto';

export function verifyPayloadSignature(
  secret: string,
  payload: Buffer | string,
  signature: string
): boolean {
  const expected = crypto
    .createHmac('sha512', secret)
    .update(payload)
    .digest('hex');

  const sigBuf = Buffer.from(signature, 'hex');
  const expectedBuf = Buffer.from(expected, 'hex');

  if (sigBuf.length !== expectedBuf.length) return false;
  return crypto.timingSafeEqual(sigBuf, expectedBuf);
}

Data Layer

Next up, we’ll define our basic data models to handle our internal representation of an incoming Payment Request, create a simple in-memory database and a repository layer that will let us perform our CRUD operations. Create a file under types called task.ts if you haven’t already done so and paste this inside
task.ts
export enum TaskStatus {
  VERIFYING = "verifying",
  PENDING = "pending",
  ACCEPTED = "accepted",
  REJECTED = "rejected",
}

export interface Task {
  token: string;
  // payment_id from Paylias
  payment_id: string;
  // admission_id from Paylias
  admission_id: string;
  // task_id from Paylias
  task_id: string;
  amount: string;
  currency: string;
  status: TaskStatus;
  exception: string;
}
Our task model will hold properties that are both internal to our issuer wallet app as well as properties from Paylias that identify an incoming Payment. In a production app, you can model this through database relations if required, but for now, we’ll focus on simplicity to get a working app integrated with Paylias. Next, we’ll define our in memory database as a simple class that manages a Map. Open up the database/memory-db.ts file (or create one if you haven’t) and add the following code inside
memory-db.ts
import { Task } from "../types/task";

class MemoryDatabase {
  private static instance: MemoryDatabase;

  private constructor() {}

  public static getInstance(): MemoryDatabase {
    if (!MemoryDatabase.instance) {
      MemoryDatabase.instance = new MemoryDatabase();
    }
    return MemoryDatabase.instance;
  }

  public getTasks(): Map<string, Task> {
    return taskStore;
  }

  public addTask(task: Task): void {
    taskStore.set(task.payment_id, task);
  }

  public getTask(paymentId: string): Task | undefined {
    return taskStore.get(paymentId);
  }

  public updateTask(paymentId: string, updates: Partial<Task>): Task | null {
    const existing = taskStore.get(paymentId);
    if (!existing) {
      return null;
    }
    const updated = { ...existing, ...updates };
    taskStore.set(paymentId, updated);
    return updated;
  }

  public deleteTask(paymentId: string): boolean {
    return taskStore.delete(paymentId);
  }

  public getAllTasks(): Task[] {
    return Array.from(taskStore.values());
  }

  public clear(): void {
    taskStore.clear();
  }
}

export default MemoryDatabase;
If you save your app, you may notice some build errors because the taskStore variable is not defined. Because we have chosen to use an in-memory database, in Next, (whether in dev with HMR or in production on Vercel’s serverless functions), the data in this module can be torn down and re-evaluated at any time, so tasks gets reset. In order to avoid this and truly get one bucket of memory to live across all imports (& HMR reloads) in a single Node process, you need to hang it on the Node “global”. So create a new file inside the database folder called global-db.ts and add this code
global-db.ts
import { Task } from "../types/task";

// 1) Tell TypeScript about your new global...
declare global {
  var _TASK_STORE: Map<string, Task> | undefined;
}

// 2) Initialize it once (or reuse the existing one)
export const taskStore: Map<string, Task> =
  global._TASK_STORE ?? new Map<string, Task>();

// 3) Ensure it’s always on global
if (!global._TASK_STORE) {
  global._TASK_STORE = taskStore;
}
And add an import to your memory-db.ts file to fix the compile errors.
memory-db.ts
import { Task } from "../types/task";
import { taskStore } from "@/database/global-db";
As a last step for the data layer, we’ll define a simple TaskRepository that will allow us to manipulate tasks in the memory-db. So go ahead and open up the file repository/taskRepository.ts and add the following code inside.
taskRepository.ts
import { v4 as uuidv4 } from "uuid";
import { Task, TaskStatus } from "@/types/task";
import MemoryDatabase from "@/database/memory-db";

export class TaskRepository {
  private db: MemoryDatabase;

  constructor() {
    this.db = MemoryDatabase.getInstance();
  }

  public createTask(data: Omit<Task, "token" | "exception">): Task {
    const task: Task = {
      ...data,
      token: uuidv4(),
      status: TaskStatus.VERIFYING,
      exception: "",
    };
    this.db.addTask(task);
    return task;
  }

  public findByPaymentId(paymentId: string): Task | undefined {
    return this.db.getTask(paymentId);
  }

  public findByToken(token: string): Task | undefined {
    const tasks = this.db.getAllTasks();
    return tasks.find((t) => t.token === token);
  }

  public updateTask(
    paymentId: string,
    updates: Partial<Omit<Task, "payment_id" | "token">>,
  ): Task | null {
    return this.db.updateTask(paymentId, updates as Partial<Task>);
  }

  public getAllTasks(): Task[] {
    return this.db.getAllTasks();
  }

  public deleteTask(paymentId: string): boolean {
    return this.db.deleteTask(paymentId);
  }
}

export default TaskRepository;

Webhook implementation

Now that we’ve set up the data layer, we’ll look into implementing the relevant webhooks required to build out our issuer app! If you haven’t already, we recommend going through the Webhook guides to get an understanding of what webhooks are and how they work. Once you’ve completed the guides, you can start implementing the webhooks for your issuer app. Since we’re opting to create an individual endpoint for each combination of record_type and event_type, the first step we have to complete is to create webhook subscriptions on Paylias for each endpoint. You can follow the Webhook Implementation guide to create the endpoint subscriptions on Paylias.
Since Paylias cannot deliver webhooks to localhost, we’re going to first have to expose our app through a tunnel. We recommend using ngrok. You may need to create a free account and install it on your system before proceeding further.

Side step

You can run this command in another terminal window to expose your app through ngrok to the internet.
# Replace <PORT> with the port your app is running on
ngrok http <PORT>
You should see an output similar to this in your terminal
Session Status                online
Account                       Ziyad Parekh (Plan: Free)
Update                        update available (version 3.25.0, Ctrl-U to update)
Version                       3.23.3
Region                        United States (us)
Latency                       280ms
Web Interface                 http://127.0.0.1:4040
Forwarding                    https://f0fd330d752f.ngrok-free.app -> http://localhost:3000
Copy the URL that ngrok has assigned to your local app and use that as the base url when creating webhook subscriptions for Paylias. For example, for each of your webhook endpoints defined in the app, the full URL that you need to use when creating the webhook subscriptions will be as follows
https://f0fd330d752f.ngrok-free.app/api/webhook/payment/created
https://f0fd330d752f.ngrok-free.app/api/webhook/payment/updated

https://f0fd330d752f.ngrok-free.app/api/webhook/payment-submission/created
https://f0fd330d752f.ngrok-free.app/api/webhook/payment-submission/updated

https://f0fd330d752f.ngrok-free.app/api/webhook/payment-admission/created
https://f0fd330d752f.ngrok-free.app/api/webhook/payment-admission/updated

https://f0fd330d752f.ngrok-free.app/api/webhook/payment-admission-task/created
https://f0fd330d752f.ngrok-free.app/api/webhook/payment-admission-task/updated

https://f0fd330d752f.ngrok-free.app/api/webhook/payment-exception/created
https://f0fd330d752f.ngrok-free.app/api/webhook/payment-transaction/created
Using these endpoints, you can create webhook subscriptions using either the dashboard or the Create Webhook API.
Make sure you subscribe to the right combination of record_type and event_type for each endpoint on your app

Implementation

Now, we’ll implement only the four Paylias webhooks an Issuer needs
1

`payment-admission:created`

When Paylias notifies us that a new payment admission has been created, we will
  • Parse the payload to extract the relevant information
  • Validate the signature in the header
  • Create a new Task record in your database with the amount and currency in the payment and status PENDING
  • Add a placeholder to trigger any risk-check logic
  • Send a response back to Paylias to acknowledge receipt of the webhook
{
  "token":"pay_adm_d1rnv8k20or466p1vdl0",
  "version":"1.0.0",
  "event_type":"EK_Created",
  "record_type":"RT_PaymentAdmissions",
  "endpoint":"https://.../payment-admission/created",
  "data": {
    "token":"pay_adm_d1rnv8k20or466p1vdl0",
    "payment_id":"6644397b-acf3-456a-80d0-26ed2f9e84ef",
    "status":4,
    "admission_time":1752661922,
    "relationships": {
      "payment": {
        "payment_id":"6644397b-acf3-456a-80d0-26ed2f9e84ef",
        "reference":"INV001",
        "payment_type":1,
        "initiated_at":1752661918,
        "expires_at":1752665518,
        "amount":{"currency":"USD","total":"25000"},
        "debtor_party":{/*…*/},
        "beneficiary_party":{/*…*/},
        "device":{/*…*/},
        "location":{/*…*/}
      }
    }
  },
  "created_at":{"seconds":1752661923,"nanos":289599000}
}
2

`payment-admission-task:created

When Paylias notifies us that a new payment admission task has been created, we will
  • Parse the payload to extract the relevant information
  • Validate the signature in the header
  • Look up the original task
  • Update the task to either PENDING or REJECTED
  • Send a response back to Paylias to acknowledge receipt of the webhook
{
  "token": "pat_d1ro6p420or466p1ved0",
  "version": "1.0.0",
  "event_type": "EK_Updated",
  "record_type": "RT_PaymentAdmissionTasks",
  "endpoint": "https://.../payment-admission-task/created",
  "data": {
    "token": "pat_d1ro6p420or466p1ved0",
    "payment_id": "fa6c3c55-a5ec-455d-afcf-3f89d35bca0a",
    "admission_id": "pay_adm_d1ro6p420or466p1vec0",
    "created_on": 1752662884,
    "modified_on": 1752662907,
    "name": "customer_authentication",
    "workflow": "customer_authentication",
    "assignee": 1,
    "status": 1
  },
  "created_at": {
    "seconds": 1752662908,
    "nanos": 580380000
  }
}
3

payment-exception:created

If Paylias sends an event notifying us that there was an exception during the payment processing flow, we’ll update the task to REJECTED and save the error message.
{
  "token": "pay_exc_d1rjk2c20or466p1vcn0",
  "version": "1.0.0",
  "event_type": "EK_Created",
  "record_type": "RT_Error",
  "endpoint": "https://.../payment-exception/created",
  "data": {
    "token": "pay_exc_d1rjk2c20or466p1vcn0",
    "payment_id": "0245c72e-3644-4bd3-a86a-c453477cac95",
    "record_type": "RT_PaymentAdmissionTasks",
    "exception_code": "T22",
    "exception_message": "invalid payment admission task: failed"
  },
  "created_at": {
    "seconds": 1752644105,
    "nanos": 881304000
  }
}
4

payment-transaction:created

The final step in the payment journey is marking the payment complete. When you receive this event, the payment is considered as final and any deductions in the user’s balance and ledger updates should be made on your platform (preferably asynchronously so you can respond back to Paylias quickly). The Transaction Resource guide explains this in more details.
{
  "token": "txn_d1ro6v420or466p1veeg",
  "version": "1.0.0",
  "event_type": "EK_Created",
  "record_type": "RT_Transactions",
  "endpoint": "https://585d2031870a.ngrok-free.app/api/webhook/payment-transaction/created",
  "data": {
    "token": "txn_d1ro6v420or466p1veeg",
    "transaction_id": "d1ro6us20or486mvtda0",
    "payment_id": "fa6c3c55-a5ec-455d-afcf-3f89d35bca0a",
    "status": 1,
    "transaction_lines": [
      {
        "token": "line_d1ro6v420or466p1vef0",
        "transaction_id": "txn_d1ro6v420or466p1veeg",
        "partner_id": "part_d1qdifs20or1uciuv43g",
        "direction": 2,
        "transaction_type": 1,
        "amount": {
          "currency": "USD",
          "total": "25000"
        }
      },
      {
        "token": "line_d1ro6v420or466p1veg0",
        "transaction_id": "txn_d1ro6v420or466p1veeg",
        "partner_id": "part_d1qe2ms20or1uciuv4hg",
        "direction": 1,
        "transaction_type": 1,
        "amount": {
          "currency": "USD",
          "total": "25000"
        }
      }
    ],
    "approval_code": "7ISH9B",
    "initiated_on": 1752662908,
    "created_on": 1752662908,
    "updated_on": 1752662908,
    "relationships": {
      "payment": {
        "payment_id": "fa6c3c55-a5ec-455d-afcf-3f89d35bca0a",
        "reference": "INV001",
        "payment_type": 1,
        "initiated_at": 1752662880,
        "expires_at": 1752666480,
        "amount": {
          "currency": "USD",
          "total": "25000"
        },
        "debtor_party": {
          "payment_address": "ziyad@safepay",
          "first_name": "Jane",
          "last_name": "Smith",
          "email": "jane@example.com",
          "phone": "+1987654321",
          "type": "Payee_Individual",
          "billing": {
            "country": "US"
          }
        },
        "beneficiary_party": {
          "payment_address": "skins@coinbase",
          "first_name": "Primary",
          "last_name": "Skincare",
          "email": "john@example.com",
          "phone": "+1234567890",
          "type": "Payee_Individual",
          "billing": {
            "country": "US"
          }
        },
        "device": {
          "browser": "Chrome"
        },
        "location": {
          "ip_address": "192.168.1.1"
        }
      }
    }
  },
  "created_at": {
    "seconds": 1752662908,
    "nanos": 638403000
  }
}

Front-End UI

Now that we’ve set up majority of the application at the backend, its time to move on to the frontend so that our users can see and respond to their payment requests. The general functionality we’re going to develop on the frontend will include
  • Displaying a list of all Tasks in a column view
  • Polling for all tasks in the background and updating the list
  • Taking action on PENDING tasks to allow our user to either accept or reject them.
This is a fairly simple UI/UX implementation meant for demostration purposes only. In your production application you would most likely use push notifications as new tasks come in to your system and have a separate notification page or tray to display any pending tasks to your user.
Lets get to it!

Install shad-cn components

First off, we’ll install the relevant Shad Cn components needed to build our UI. Open up a terminal window and cd into the root directory of your app. Once there copy-paste this command
npx shadcn-ui@latest add badge button card dialog label select

Create components

Once these have been installed, add the following components under the components directory.
"use client";

import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import React from "react";

interface NavBarProps {
  /** SVG logo or any element to render on the left */
  logo?: React.ReactNode;
  /** If no logo is provided, a title can also be displayed */
  title?: string;
}

export function NavBar({ logo }: NavBarProps) {
  return (
    <header
      className={cn(
        "w-full",
        "bg-white dark:bg-zinc-900",
        "border-b border-zinc-200 dark:border-zinc-700",
      )}
    >
      <div
        className={cn(
          "max-w-7xl mx-auto",
          "px-4 py-2",
          "flex items-center justify-between",
        )}
      >
        <div className="flex items-center">{logo ? logo : title}</div>
      </div>
    </header>
  );
}

export default NavBar;

Fetch All Tasks

Now that we’ve created the basic components needed to display a Task, we’re going to create an endpoint that will return us a list of tasks for us to render in a list. Open up app/api/tasks/route.ts and add in the following code
route.ts
// app/api/tasks/route.ts
import { NextResponse } from "next/server";
import { TaskRepository } from "@/repository/taskRepository.ts";
import { TaskStatus } from "@/types/task";

export async function GET() {
  const repository = new TaskRepository();
  const all = repository.getAllTasks();
  // filter out any tasks that have VERIFYING status
  const filtered = all.filter((task) => task.status !== TaskStatus.VERIFYING);
  // reverse the list so that the newest tasks show up on top.
  return NextResponse.json(filtered.reverse());
}
We’re going to use this endpoint in the following TasksList component to poll for all tasks every 5 seconds. Lets create the TasksList component now. Create a new file under components called TasksLists.tsx and add in the following code
TasksList.tsx
"use client";

import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Task } from "@/types/task";
import { TaskItem } from "@/components/Task";

export function TaskList({}) {
  const router = useRouter();
  const [tasks, setTasks] = useState<Task[]>([]);

  useEffect(() => {
    const interval = setInterval(async () => {
      try {
        const res = await fetch("http://localhost:3000/api/tasks", {
          method: "GET",
        });
        if (!res.ok) {
          throw new Error("Failed to fetch tasks");
        }
        const tasks = await res.json();
        setTasks(tasks);
      } catch (err) {
        console.error("Polling failed:", err);
      }
    }, 5000);

    return () => clearInterval(interval);
  }, [router]);

  if (tasks.length === 0) {
    return (
      <Card className="border border-orange-200 dark:border-orange-700 border-dashed">
        <CardContent className="flex flex-col items-center justify-center py-8">
          <AlertTriangle className="w-8 h-8 text-orange-500" />
          <p className="mt-4 text-sm text-muted-foreground text-center">
            No tasks found that require any action.
          </p>
        </CardContent>
      </Card>
    );
  }

  return (
    <div className="space-y-2 flex flex-col gap-2">
      {tasks.map((task) => (
        <TaskItem
          key={task.token}
          task={task}
          onClickAction={() =>
            router.push(`/update-task?taskId=${task.token}`)
          }
        />
      ))}
    </div>
  );
}
Lets put all of this together now. Open up app/page.tsx and replace the existing code with the following
page.tsx
import Navbar from "@/components/NavBar";
import { TaskList } from "@/components/TaskList";

export default async function Home() {
  return (
    <div className="min-h-screen bg-gray-50">
      <Navbar title={"Safepay"} />
      <div className="max-w-2xl mx-auto">
        <TaskList />
      </div>
    </div>
  );
}
When you refresh your browser, you should see your Navbar and under that a message that says “No tasks found that require any action.” Since we don’t have any tasks in our database, there’s nothing to display at the moment but that will change once we start receiving incoming payment requests. But lets assume our webhooks will work as expected and implement the functionality required to take action on a PENDING task.

Updating a task

Updating the task is a two step process. When our user clicks on a task they want to action on, we’ll open up a dialog that will first fetch the task along with its details from our database. Then, depending on whether they want to approve or reject the payment request, we’ll update the task accordingly and respond to Paylias with our decision. To make this work, lets first start with implementing the two endpoints we’re going to need. Open up the /api/tasks/[task-id]route.ts file and add in the code below to implement both the GET and PUT endpoints.
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { TaskRepository } from "@/repository/taskRepository.ts";
import { TaskStatus } from "@/types/task";

export async function GET(
  req: NextRequest,
  { params }: { params: { "task-id": string } },
) {
  const repository = new TaskRepository();
  const { "task-id": token } = await params;

  const task = repository.findByToken(token);
  if (!task) {
    return NextResponse.json({ error: "Not found" }, { status: 404 })
  }
  return NextResponse.json(task);
}

export async function PUT(
  req: NextRequest,
  { params }: { params: { "task-id": string } },
) {
  const repository = new TaskRepository();
  const body = await req.json();
  const { "task-id": token } = await params;
  const status = body.status as TaskStatus;
  if (![TaskStatus.ACCEPTED, TaskStatus.REJECTED].includes(status)) {
    return NextResponse.json({ error: "Invalid status" }, { status: 400 });
  }

  const task = repository.findByToken(token);
  if (!task) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  const payload = {
    status: status === TaskStatus.ACCEPTED ? "completed" : "failed",
    status_reason:
      status === TaskStatus.ACCEPTED ? "accepted" : "insufficient_funds"
      // use a generic rejection reason for now but change this based on your use case
  };

  const response = await fetch(
    `${process.env.PAYLIAS_BASE_URL}/csp/payments/${task.payment_id}/admissions/${task.admission_id}/tasks/${task.task_id}`,
    {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
        "X-Paylias-Api-Key": process.env.PAYLIAS_API_SECRET || "",
        "X-Org-ID": process.env.PAYLIAS_ORG_ID || "",
        "X-Partner-ID": process.env.PAYLIAS_PARTNER_ID || "",
        "Idempotency-Key": uuidv4(),
      },
      body: JSON.stringify(payload),
    },
  );

  return NextResponse.json(response);
}

In our implementation of the PUT endpoint we’re also calling the Update Admission Task endpoint. You’ll notice that we’re not updating the task in our database yet. If we reject the task, Paylias will trigger an Exception event which our /api/webhook/payment-exception/created endpoint will catch. This is where our the task will be updated in our system. Similarly, if we approve the task, Paylias will trigger a Transaction event which our /api/webhook/payment-transaction/created endpoint will catch. This is where we will mark the task as COMPLETED. The final step in this guide is to implement the UI for updating a task. We’ll accomplish this using the <Dialog> component from Shad Cn along with implementing parallel and intercepting routes from Next JS. Create a new file under the components directory called UpdateTask.tsx and add in the following code
UpdateTask.tsx
"use client";

import React, { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2, CheckCircle, AlertTriangle } from "lucide-react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Task, TaskStatus } from "@/types/task";

export function TaskModal({}) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const taskId = searchParams.get("taskId");
  const [task, setTask] = useState<Task | null>(null);

  useEffect(() => {
    fetch(`/api/tasks/${taskId}`)
      .then((res) => res.json())
      .then((data: Task) => {
        if (data.hasOwnProperty("error")) {
          setTask(null);
          return;
        }
        setTask(data);
      });
  }, [taskId]);

  const renderIcon = () => {
    if (task?.status === TaskStatus.PENDING) {
      return <Loader2 className="animate-spin w-16 h-16 text-zinc-500" />;
    }
    if (task?.status === TaskStatus.REJECTED) {
      return <AlertTriangle className="w-16 h-16 text-red-500" />;
    }
    if (task?.status === TaskStatus.ACCEPTED) {
      return <CheckCircle className="w-16 h-16 text-green-500" />;
    }
    return null;
  };

  const renderLabel = () => {
    switch (task?.status) {
      case TaskStatus.PENDING:
        return "Task is pending";
      case TaskStatus.ACCEPTED:
        return "Task has been accepted";
      case TaskStatus.REJECTED:
        return "Task has been rejected";
      default:
        return "";
    }
  };

  const updateTask = useCallback(
    async (status: string) => {
      const response = await fetch(`/api/tasks/${taskId}`, {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ status }),
      });
      if (!response.ok) {
        console.error("Failed to update task status");
        return;
      }
      router.back();
    },
    [taskId, router],
  );

  const isPending = task?.status === TaskStatus.PENDING;

  return (
    <Dialog open onOpenChange={() => router.back()}>
      <DialogContent title="Confirm">
        {task ? (
          <>
            <DialogHeader>
              <DialogTitle>Task Status</DialogTitle>
              <DialogDescription className="flex flex-col items-center space-y-4 pt-4">
                {renderIcon()}
                <span className="text-lg font-medium text-center">
                  {renderLabel()}
                </span>
              </DialogDescription>
            </DialogHeader>
            <DialogFooter>
              <div className="w-full grid grid-cols-2 gap-2">
                <Button
                  variant="destructive"
                  className="w-full"
                  onClick={() => updateTask(TaskStatus.REJECTED)}
                  disabled={!isPending}
                >
                  Reject
                </Button>
                <Button
                  className="w-full"
                  onClick={() => updateTask(TaskStatus.ACCEPTED)}
                  disabled={!isPending}
                >
                  Approve
                </Button>
              </div>
            </DialogFooter>
          </>
        ) : (
          <DialogHeader>
            <DialogTitle>No Task Found</DialogTitle>
          </DialogHeader>
        )}
      </DialogContent>
    </Dialog>
  );
}

export default TaskModal;
Now that our client component is ready, we’ll wire it up to the rest of our app so its accessible through routing. Create the following folder structure under your app directory.
├── app/
│   ├── @modal/
│   │   ├── (.)update-task/
│   │   │   ├── page.tsx
│   │   ├── default.tsx
│   ├── update-task/
│   │   ├── page.tsx
Now add the following code to the relevant files
import TaskModal from "@/components/UpdateTask";

export default function Page() {
  return <TaskModal />;
}

Conclusion

That’s it! You’ve just implemented money-movement as an issuer for your customers. We’re grateful for giving Paylias a try and we hope this tutorial gives you a solid start to build great user experiences. There’s a lot more you can do like Creating namespaces, Issuing aliases and, if your use-case supports accepting payments, Integrating Paylias as an Acquirer. Be sure to check out our Api Reference and reach out at ziyad@paylias.xyz if you need any help!