Documentation

Merchant Integration Guide

Everything you need to accept UPI payments on your webapp — from account creation to your first verified payment in under 10 minutes.

5-Minute Quickstart

Follow these steps to go from zero to a live verified payment on your webapp.

1

Create an account at seedhape.com/sign-up — it's free, no credit card needed.

2

Set your UPI ID in Dashboard → Settings → Business Profile. This is where customer payments will land.

3

Generate an API key in Settings → API Keys → New Key. Copy it immediately — it won't be shown again.

4

Install the SeedhaPe Android app on the phone where your UPI notifications arrive. Enter your API key, grant notification access.

5

Create an order from your backend using the API or SDK. Get back a upiUri and QR code.

6

Show the payment UI to your customer using our hosted page, SDK modal, or React components.

7

Receive the webhook at your endpoint — SeedhaPe fires order.verified within 5 seconds of payment. Fulfill the order.


Section 1

Account Setup

Configure your merchant profile and generate API credentials.

Create your account

  1. 1Visit seedhape.com/sign-up and create a free account.
  2. 2After sign-in, open Dashboard → Settings.
  3. 3Fill in your Business Name — this appears on the hosted payment page.
  4. 4Set your UPI ID (e.g. merchant@ybl or 9876543210@paytm). Customer payments land directly here.
  5. 5Click Save Changes.
📌

Important: Your UPI ID must match the one registered with your bank. Payments go directly to this UPI ID — SeedhaPe never touches the money.

Generate an API Key

  1. 1In Settings, scroll to API Keys and click New Key.
  2. 2A key is generated in the format sp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
  3. 3Copy it immediately — it is shown only once.
  4. 4Store it in your server environment as SEEDHAPE_API_KEY. Never commit it to source control.
⚠️

Warning: API keys are unrecoverable after creation. If lost, delete the key from the dashboard and generate a new one. Your old key will immediately stop working.

Set up your Webhook URL

SeedhaPe posts payment events to your webhook endpoint. Configure it now even if your endpoint isn't live yet — you can test it from the dashboard.

  1. 1In Settings, enter your Webhook URL (e.g. https://api.yoursite.com/webhooks/seedhape).
  2. 2Create a random Webhook Secret (min 16 chars) and enter it in the field.
  3. 3Store the same secret in your server environment as SEEDHAPE_WEBHOOK_SECRET.
  4. 4Click Test to verify your endpoint receives the test event.

Section 2

Android App Setup

The Android app is the brain of SeedhaPe — it listens to UPI notifications and triggers payment verification.

📌

Important: The Android app is required for payment verification to work. It must run on the phone that receives UPI payment notifications — typically the merchant's business phone.

Installation & Setup

  1. 1Download and install the SeedhaPe merchant app on your Android device.
  2. 2Open the app and enter your API key (sp_live_...) when prompted.
  3. 3Tap Connect. The app verifies your key and registers the device.
  4. 4When the system prompts for Notification Access, tap Allow — this is required for the app to read UPI notifications.
  5. 5Allow the app's own persistent notification — Android uses it to keep the background service alive.
  6. 6In your phone's Battery settings, set the SeedhaPe app to Unrestricted (or turn off battery optimization for it). This prevents Android from killing the service.

Keeping the device online

The app sends a heartbeat to SeedhaPe every ~50 seconds. If the heartbeat stops for too long, your merchant account is marked OFFLINE. While offline:

  • New order creation is blocked — customers will see an error.
  • Existing pending orders will not be verified until you come back online.
  • Ensure the phone has a stable internet connection (WiFi or mobile data).
  • Do not force-stop the app or revoke notification permission.
💡

Tip: Check merchant online status in real-time on your Dashboard home page. A green dot means your device is connected and heartbeating normally.


Section 3

REST API Reference

Call the API directly from any language using plain HTTP.

Authentication

All API requests use Bearer token authentication. Include your API key in every request:

Authorization header
Authorization: Bearer sp_live_YOUR_KEY

POST /v1/orders — Create an order

Creates a new payment order and returns a UPI URI and QR code.

curl
curl -X POST "https://seedhape.onrender.com/v1/orders" \
  -H "Authorization: Bearer sp_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 49900,
    "description": "Pro plan subscription",
    "externalOrderId": "your_order_123",
    "expectedSenderName": "Rahul Sharma",
    "expiresInMinutes": 15
  }'

Request body parameters

amountnumberrequired

Amount in paise (1 rupee = 100 paise). ₹499 = 49900.

descriptionstring

Short description shown on the payment page and in UPI apps. Max 100 chars.

externalOrderIdstring

Your own order ID for deduplication. Returned in webhooks as externalOrderId. Use this to link SeedhaPe orders to your database rows.

expectedSenderNamestring

The payer's name exactly as it appears in their UPI app (e.g. "Rahul Sharma"). Strongly recommended — used as fallback matching when the UPI transaction note doesn't contain the order ID. Significantly reduces disputes.

customerEmailstring

Customer email — stored on the order, echoed in webhooks, not shown to payer.

customerPhonestring

Customer phone number. Same as email — stored and echoed only.

expiresInMinutesnumber

Order TTL in minutes. Default: 30. After expiry, the order moves toEXPIRED and the webhook fires.

metadataRecord<string, unknown>

Arbitrary JSON. Echoed verbatim in all webhook payloads. Use this to store your internal order ID, user ID, plan key, etc.

Response

200 OK
{
  "id": "sp_ord_ab12cd34ef56",
  "amount": 49900,
  "originalAmount": 49900,
  "currency": "INR",
  "description": "Pro plan subscription",
  "status": "CREATED",
  "upiUri": "upi://pay?pa=merchant@ybl&pn=My+Store&am=499.00&tn=sp_ord_ab12cd34ef56&cu=INR",
  "qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEU...",
  "expiresAt": "2026-03-15T12:34:56.000Z",
  "createdAt": "2026-03-15T12:19:56.000Z"
}

GET /v1/orders/:id/status — Poll order status

curl
curl "https://seedhape.onrender.com/v1/orders/sp_ord_ab12cd34ef56/status" \
  -H "Authorization: Bearer sp_live_YOUR_KEY"

Returns { orderId, status, amount, verifiedAt? }. Poll this from your server if you need status without a webhook.

Order statuses

CREATEDOrder created, awaiting payment
PENDINGPayment page opened by customer
VERIFIEDPayment confirmed ✓
DISPUTEDAmbiguous match — needs review
RESOLVEDDispute resolved by merchant
EXPIREDTTL elapsed without payment
REJECTEDDispute rejected by merchant

Section 4

JavaScript SDK (@seedhape/sdk)

Type-safe wrapper around the REST API. Works in Node.js, Deno, Bun, and modern browsers.

Installation

npm install @seedhape/sdk
# or
pnpm add @seedhape/sdk
yarn add @seedhape/sdk

Initialization

lib/seedhape.ts
// lib/seedhape.ts — server only, never import from the browser
import { SeedhaPe } from '@seedhape/sdk';

function getClient(): SeedhaPe {
  if (!process.env.SEEDHAPE_API_KEY) throw new Error('SEEDHAPE_API_KEY is not set');
  return new SeedhaPe({
    apiKey:  process.env.SEEDHAPE_API_KEY,
    baseUrl: process.env.SEEDHAPE_BASE_URL, // optional — omit for production default
  });
}

export async function createOrder(params: Parameters<SeedhaPe['createOrder']>[0]) {
  return getClient().createOrder(params);
}

export async function getOrderStatus(orderId: string) {
  return getClient().getOrderStatus(orderId);
}
⚠️

Warning: Keep your API key on the server side only. Never exposesp_live_ keys in client-side bundles. Use environment variables.

Create an order

server — create-order.ts
import { createOrder } from '@/lib/seedhape';

const order = await createOrder({
  amount: 49900,               // required — amount in paise (₹499 = 49900)
  description: 'Pro plan',     // shown to the payer
  externalOrderId: 'ord_123',  // your own order ID for deduplication
  expectedSenderName: 'Rahul Sharma', // ⭐ strongly recommended — see Payment Matching
  customerEmail: 'rahul@example.com',
  customerPhone: '+919876543210',
  expiresInMinutes: 15,        // default: 30 minutes
  metadata: { userId: 'usr_abc', planKey: 'PRO' }, // any JSON, echoed in webhooks
});

console.log(order.id);      // "sp_ord_ab12cd34ef56"
console.log(order.upiUri);  // deep-link for UPI apps
console.log(order.qrCode);  // base64 PNG data URL

Show the payment UI

Once you have an order ID, you have two options for showing the payment UI:

Option A — Hosted payment page (recommended)

Redirect your customer to the built-in payment page. Zero frontend work.

server
// Option A — redirect to seedhape-hosted payment page (easiest)
// Works for any framework (Next.js, Express, etc.)
const checkoutUrl = `https://yourdomain.com/pay/${order.id}`;
return redirect(checkoutUrl);

Option B — Browser SDK modal

Render an in-page modal without a redirect. Best for SPAs and native-feeling checkouts.

client — checkout.ts
// Option B — browser SDK modal (single-page apps)
// order.id was created on your server (see Step 2 above) and passed to the client.
// showPayment only calls public /v1/pay/* endpoints — no API key is ever sent.
import { SeedhaPe } from '@seedhape/sdk';

const sp = new SeedhaPe({}); // no apiKey needed for showPayment

const result = await sp.showPayment({
  orderId: order.id,
  onSuccess: (result) => {
    console.log('Verified!', result.orderId, result.amount);
    // Webhook has already fired — just update your UI
  },
  onExpired: (orderId) => console.log('Expired:', orderId),
  onClose: () => console.log('User closed modal'),
});
💡

Tip: With the browser modal (Option B), order creation must always happen on your server. Pass only the resulting orderId to the client — never ship sp_live_ keys to the browser.

Poll order status

server
// Poll status from your backend (server-side)
const status = await seedhape.getOrderStatus('sp_ord_ab12cd34ef56');
// { orderId, status, amount, verifiedAt? }

// OrderStatus values:
// CREATED | PENDING | VERIFIED | DISPUTED | RESOLVED | EXPIRED | REJECTED

Section 5

React Package (@seedhape/react)

Drop-in components and hooks for React and Next.js apps. Zero-config checkout in one component.

Installation

npm install @seedhape/react @seedhape/sdk

SeedhaPeProvider

Required for all three componentsPaymentButton, PaymentModal, and usePayment all call useSeedhaPeContext() internally and will throw if no provider is present. Wrap your app (or just the checkout subtree) once.

Pass an onCreateOrder callback that calls your server — your API key stays on the server and is never bundled into client-side JavaScript.

Next.js App Router (server actions)

app/layout.tsx
// app/layout.tsx  (Next.js App Router — recommended)
import { SeedhaPeProvider } from '@seedhape/react';
import { SeedhaPe } from '@seedhape/sdk';
import type { CreateOrderOptions } from '@seedhape/sdk';

// Runs on the SERVER only — API key never reaches the browser.
async function createOrder(opts: CreateOrderOptions) {
  'use server';
  const client = new SeedhaPe({
    apiKey:  process.env.SEEDHAPE_API_KEY!,
    baseUrl: process.env.SEEDHAPE_BASE_URL,   // omit to use the default production URL
  });
  return client.createOrder(opts);
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SeedhaPeProvider onCreateOrder={createOrder}>
          {children}
        </SeedhaPeProvider>
      </body>
    </html>
  );
}

Vite / Create React App (SPA + separate backend)

src/main.tsx
// src/main.tsx  (Vite / Create React App / any SPA)
// onCreateOrder proxies to your own backend — API key lives there, not here.
import { SeedhaPeProvider } from '@seedhape/react';
import type { CreateOrderOptions } from '@seedhape/sdk';

async function createOrder(opts: CreateOrderOptions) {
  const res = await fetch('/api/create-order', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(opts),
  });
  if (!res.ok) throw new Error('Order creation failed');
  return res.json();
}

export default function App() {
  return (
    <SeedhaPeProvider onCreateOrder={createOrder}>
      <Router />
    </SeedhaPeProvider>
  );
}

// ── Your backend (Express) ───────────────────────────────────────────────────
// import { SeedhaPe } from '@seedhape/sdk';
// const sp = new SeedhaPe({ apiKey: process.env.SEEDHAPE_API_KEY!, baseUrl: process.env.SEEDHAPE_BASE_URL });
// app.post('/api/create-order', express.json(), async (req, res) => {
//   const order = await sp.createOrder(req.body);
//   res.json(order);
// });

Remix

app/root.tsx
// app/root.tsx  (Remix)
import { SeedhaPeProvider } from '@seedhape/react';
import type { CreateOrderOptions } from '@seedhape/sdk';

async function createOrder(opts: CreateOrderOptions) {
  const res = await fetch('/api/create-order', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(opts),
  });
  if (!res.ok) throw new Error('Order creation failed');
  return res.json();
}

export default function App() {
  return (
    <html lang="en">
      <body>
        <SeedhaPeProvider onCreateOrder={createOrder}>
          <Outlet />
        </SeedhaPeProvider>
      </body>
    </html>
  );
}

// ── app/routes/api.create-order.ts ──────────────────────────────────────────
// import { json } from '@remix-run/node';
// import { SeedhaPe } from '@seedhape/sdk';
// const sp = new SeedhaPe({ apiKey: process.env.SEEDHAPE_API_KEY!, baseUrl: process.env.SEEDHAPE_BASE_URL });
// export async function action({ request }) {
//   const order = await sp.createOrder(await request.json());
//   return json(order);
// }
onCreateOrder(opts: CreateOrderOptions) => Promise<OrderData>required

Called when a payment is initiated. Implement this as a Next.js server action or a fetch to your own backend endpoint. Your SeedhaPe API key must only live here — never in client-side code.

baseUrlstring

Override the SeedhaPe API base URL. Defaults to https://seedhape.onrender.com. Set via SEEDHAPE_BASE_URL for self-hosted deployments.

PaymentButton

📌

Important: Requires <SeedhaPeProvider> to be present in the component tree. See SeedhaPeProvider setup above.

The simplest integration — a button that creates an order and opens the payment modal in one click. No manual order creation needed.

components/checkout.tsx
import { PaymentButton } from '@seedhape/react';

export default function CheckoutPage() {
  return (
    <PaymentButton
      amount={49900}                        // paise
      description="Pro subscription"
      expectedSenderName="Rahul Sharma"     // ⭐ recommended
      customerEmail="rahul@example.com"
      metadata={{ planKey: 'PRO' }}
      onSuccess={(result) => {
        console.log('Payment verified!', result);
        router.push('/dashboard?upgraded=true');
      }}
      onExpired={(orderId) => {
        console.log('Payment expired:', orderId);
      }}
      className="my-custom-btn-class"       // optional — disables default styles
    >
      Pay ₹499 →
    </PaymentButton>
  );
}
amountnumberrequired

Amount in paise (₹1 = 100 paise).

descriptionstring

Order description shown on payment page.

expectedSenderNamestring

Payer's UPI-registered name. Strongly recommended — improves matching accuracy.

customerEmailstring

Customer email, stored on the order.

customerPhonestring

Customer phone number.

metadataRecord<string, unknown>

Arbitrary data echoed in webhook payloads.

onSuccess(result: PaymentResult) => void

Called when payment is verified. result.status is VERIFIED or RESOLVED. The webhook has already fired at this point.

onExpired(orderId: string) => void

Called when the order expires without payment.

classNamestring

Custom CSS class. When provided, default button styles are removed entirely.

childrenReactNode

Button label. Defaults to "Pay Now".

PaymentModal (manual control)

📌

Important: Requires <SeedhaPeProvider> to be present in the component tree. See SeedhaPeProvider setup above.

Use PaymentModal directly when you need to create the order yourself (e.g. server-side) and control when the modal opens.

components/custom-checkout.tsx
import { useState } from 'react';
import { PaymentModal } from '@seedhape/react';

export default function CustomCheckout({ orderId }: { orderId: string }) {
  const [open, setOpen] = useState(true);

  return (
    <PaymentModal
      orderId={orderId}
      open={open}
      onClose={() => setOpen(false)}
      onSuccess={(result) => {
        setOpen(false);
        // result.status is "VERIFIED" or "RESOLVED"
        console.log('Amount verified:', result.amount);
      }}
      onExpired={(id) => {
        setOpen(false);
        console.warn('Order expired:', id);
      }}
    />
  );
}
💡

Tip: The modal has a built-in 2-step flow: Step 1 collects the payer's name, Step 2 shows the QR code. If you pass expectedSenderName when creating the order, Step 1 is pre-filled with that name — the customer can confirm or correct it before proceeding.


Section 6

Next.js Integration Guide

Complete server-side order creation + client modal pattern for Next.js App Router.

The recommended pattern for Next.js: create orders in a Server Action or API Route, return the orderId to the client, then open the modal or redirect to the hosted page.

app/api/checkout/route.ts
// app/api/checkout/route.ts  — server-side order creation
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { createOrder } from '@/lib/seedhape'; // your server utility

export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { amount, description } = await req.json();

  const order = await createOrder({
    amount,
    description,
    externalOrderId: `${userId}_${Date.now()}`,
    metadata: { userId },
  });

  // Return order ID — client renders /pay/:orderId or opens modal
  return NextResponse.json({ orderId: order.id });
}
💡

Tip: The client component calls /api/checkout, gets back the orderId, then renders <PaymentModal orderId={orderId} open />. The API key never leaves the server.


Section 7

Webhook Setup

SeedhaPe notifies your backend of every payment event via signed HTTP POST requests.

Events reference

order.verifiedPayment confirmed by the matching engine. Fulfill the order.
order.expiredOrder TTL elapsed without a matching payment.
order.disputedAmbiguous match — multiple orders share the same amount. Merchant must review.
order.resolvedMerchant resolved a dispute. Check data.status to know if approved or rejected.

Sample payload — order.verified

POST https://yoursite.com/webhooks/seedhape
// order.verified — payment confirmed
{
  "event": "order.verified",
  "timestamp": "2026-03-15T11:11:11.000Z",
  "data": {
    "orderId": "sp_ord_ab12cd34ef56",
    "externalOrderId": "your_order_123",
    "amount": 49900,
    "originalAmount": 49900,
    "currency": "INR",
    "status": "VERIFIED",
    "utr": "426111234567",
    "senderName": "Rahul Sharma",
    "upiApp": "Google Pay",
    "verifiedAt": "2026-03-15T11:11:09.000Z",
    "metadata": { "userId": "usr_abc", "planKey": "PRO" }
  }
}

Signature verification

Every webhook includes an X-SeedhaPe-Signature header containing sha256=<hex>. Always verify this before processing.

Next.js App Router handler

app/api/webhooks/seedhape/route.ts
// app/api/webhooks/seedhape/route.ts  (Next.js App Router)
import crypto from 'node:crypto';

const WEBHOOK_SECRET = process.env.SEEDHAPE_WEBHOOK_SECRET!;

function verify(rawBody: string, signature: string): boolean {
  if (!signature.startsWith('sha256=')) return false;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  );
}

export async function POST(req: Request) {
  const signature = req.headers.get('x-seedhape-signature') ?? '';
  const rawBody = await req.text();

  if (!verify(rawBody, signature)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const payload = JSON.parse(rawBody);

  switch (payload.event) {
    case 'order.verified':
      await db.orders.update({
        where: { seedhapeOrderId: payload.data.orderId },
        data: { status: 'PAID', paidAt: payload.data.verifiedAt },
      });
      await fulfillOrder(payload.data.orderId);
      break;

    case 'order.expired':
      await db.orders.update({
        where: { seedhapeOrderId: payload.data.orderId },
        data: { status: 'EXPIRED' },
      });
      break;

    case 'order.disputed':
      await notifyMerchant('Payment needs manual review', payload.data.orderId);
      break;

    case 'order.resolved':
      await db.orders.update({
        where: { seedhapeOrderId: payload.data.orderId },
        data: { status: payload.data.status === 'RESOLVED' ? 'PAID' : 'REJECTED' },
      });
      break;
  }

  return new Response('OK', { status: 200 });
}

Express / Hono / Fastify

routes/webhooks.ts
// Express / Hono / Fastify — same pattern, different syntax
import express from 'express';
import crypto from 'node:crypto';

const app = express();
app.use('/webhooks/seedhape', express.raw({ type: 'application/json' }));

app.post('/webhooks/seedhape', (req, res) => {
  const sig = req.headers['x-seedhape-signature'] as string;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.SEEDHAPE_WEBHOOK_SECRET!)
    .update(req.body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  // ... handle event
  res.sendStatus(200); // respond quickly, process async
});

Webhook best practices

  • Always verify the HMAC signature before processing any payload.
  • Return 200 OK immediately and process asynchronously (queue the job). SeedhaPe retries on non-2xx.
  • Make your handler idempotent — you may receive the same event more than once on retries.
  • Use data.externalOrderId to map webhook events to your own orders.
  • SeedhaPe retries failed deliveries 5 times with exponential backoff (max ~5 minutes total).
  • Monitor the Webhook Retry Log in your dashboard for failures.

Section 8

Disputes & Name Input

What happens when a payment can't be automatically matched — and how customers can raise disputes directly from the payment modal.

Name gate — the core of payment matching

expectedSenderName is not just a helpful hint — it is actively used by the matching engine in two ways:

  • Collision resolver: if two orders share the same amount in the 15-minute window, the engine picks the one whose expectedSenderName matches the sender from the UPI notification. Without a name, both orders are flagged as DISPUTED.
  • Fraud guard: even when an order is matched via its ID in the UPI transaction note, the engine still checks the sender name. If it doesn't match, the order is flagged as DISPUTED — preventing someone else from paying the same amount to steal a verified order.

There are two ways to provide the sender name:

name-gate-options.ts
// The matching engine uses expectedSenderName in TWO ways:
//
// 1. COLLISION RESOLVER — if multiple orders have the same amount in the 15-min
//    window, only the one whose expectedSenderName matches the notification
//    sender gets VERIFIED. Without a name, all colliding orders → DISPUTED.
//
// 2. FRAUD GUARD — even when matched via order ID in the transaction note,
//    if expectedSenderName is set and the actual sender doesn't match →
//    order is flagged as DISPUTED. Prevents someone else paying the same
//    amount to steal a verified order.
//
// Option A — merchant supplies name at order creation (recommended)
// The modal skips the name gate entirely.
const order = await seedhape.createOrder({
  amount: 49900,
  expectedSenderName: 'Rahul Sharma', // from your logged-in user profile
});

// Option B — customer enters name in the modal (no name known at creation)
// Don't pass expectedSenderName — the modal shows a name input step first,
// then calls POST /v1/pay/:orderId/expectation before showing the QR code.
const order = await seedhape.createOrder({
  amount: 49900,
  // no expectedSenderName → modal collects it from the customer
});
💡

Tip: If you have the customer's name in your session (e.g. from Clerk or your own auth), always pass expectedSenderName at order creation — the modal skips the name step entirely and goes straight to the QR code.

When expectedSenderName is not provided at creation, both the React PaymentModal and the vanilla JS SDK modal automatically show a name input step before the QR code. The customer enters their name exactly as shown in their UPI app, and the modal calls the expectation endpoint before proceeding. If you are building a custom UI, call it directly:

curl — set sender name
# POST /v1/pay/:orderId/expectation
# Public endpoint — no API key required
# Call this after creating the order, before the customer pays

curl -X POST "https://seedhape.onrender.com/v1/pay/sp_ord_ab12cd34ef56/expectation" \
  -H "Content-Type: application/json" \
  -d '{ "expectedSenderName": "Rahul Sharma" }'

# Response
{ "ok": true }
fetch — custom UI
// If building a custom payment UI without the SDK/React package,
// call this endpoint after showing the name input to your customer.

const res = await fetch(`https://seedhape.onrender.com/v1/pay/${orderId}/expectation`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ expectedSenderName: customerName }),
});

if (!res.ok) throw new Error('Failed to set sender name');

Endpoint reference

POST/v1/pay/:orderId/expectationpublic

Body: { "expectedSenderName": "string" }

Accepts when status is CREATED, PENDING, or DISPUTED.

Dispute flow

A dispute occurs when a payment is made but SeedhaPe cannot automatically match it — either because the order expired before the notification arrived, or because the notification didn't contain enough information to match.

  • Order expires (window passes) → status becomes EXPIRED
  • Notification arrives but can't be matched → status becomes DISPUTED
  • Customer sees "Link Expired" or "Under Review" in the modal
  • Customer uploads a screenshot of their UPI payment confirmation
  • Order status → DISPUTED, order.disputed webhook fires
  • You review the screenshot in the Disputes tab (dashboard or Android app)
  • Approve → status → RESOLVED, order.resolved fires
  • Reject → status → REJECTED, order.resolved fires
📌

Important: Screenshot upload is built into both the React PaymentModal and the vanilla JS SDK modal — no extra code needed on your end. The dispute is raised by the customer directly from the payment modal. If you are building a custom UI, call the screenshot endpoint directly:

curl — upload dispute screenshot
# POST /v1/pay/:orderId/screenshot
# Public endpoint — no API key required
# Accepted when order status is PENDING, DISPUTED, or EXPIRED

curl -X POST "https://seedhape.onrender.com/v1/pay/sp_ord_ab12cd34ef56/screenshot" \
  -F "screenshot=@/path/to/payment-screenshot.jpg"

# Response
{
  "ok": true,
  "message": "Screenshot submitted for review",
  "screenshotUrl": "https://public.blob.vercel-storage.com/screenshots/sp_ord_ab12cd34ef56-..."
}
fetch — custom dispute UI
// Custom dispute UI — handle file input and upload directly
async function submitDispute(orderId: string, file: File) {
  const form = new FormData();
  form.append('screenshot', file);          // field name must be "screenshot"

  const res = await fetch(`https://seedhape.onrender.com/v1/pay/${orderId}/screenshot`, {
    method: 'POST',
    body: form,
    // Do NOT set Content-Type manually — browser sets multipart boundary automatically
  });

  if (!res.ok) throw new Error('Upload failed');
  const { screenshotUrl } = await res.json();
  return screenshotUrl; // public URL of the uploaded image
}

Endpoint reference

POST/v1/pay/:orderId/screenshotpublic

Body: multipart/form-data — field name screenshot, image file.

Accepts when status is PENDING, DISPUTED, or EXPIRED.

Returns: { ok, message, screenshotUrl }

dispute-flow-overview.ts
// When order.status === 'EXPIRED' or 'DISPUTED', the modal automatically
// shows a screenshot upload UI — no extra code needed on your end.
//
// The customer:
//  1. Sees "Link Expired" or "Under Review" in the modal
//  2. Uploads a screenshot of their UPI payment confirmation
//  3. Clicks "Submit Dispute"
//
// You receive an order.disputed webhook:
{
  "event": "order.disputed",
  "data": { "orderId": "sp_ord_ab12cd34ef56", "status": "DISPUTED" }
}
//
// After you review and resolve in the Android app or dashboard:
// → Approve  → order.resolved fires, status = "RESOLVED"
// → Reject   → order.resolved fires, status = "REJECTED"

Handling dispute webhooks

Add these cases to your existing webhook handler:

webhook-handler.ts
case 'order.disputed':
  // Customer uploaded a screenshot — order needs manual review
  // Notify yourself (email, Slack, etc.) to check the Disputes tab
  await notifyTeam('Payment under review', payload.data.orderId);
  // Do NOT fulfill the order yet — wait for order.resolved
  break;

case 'order.resolved':
  if (payload.data.status === 'RESOLVED') {
    // You approved the dispute — fulfill the order
    await fulfillOrder(payload.data.orderId);
  } else {
    // You rejected it — inform the customer
    await markOrderRejected(payload.data.orderId);
  }
  break;
⚠️

Warning: Never fulfill an order on order.disputed — wait for order.resolved with status: "RESOLVED" before delivering goods or unlocking features.


Section 9

How Payment Matching Works

Understanding the matching engine helps you maximise auto-verification rates and minimise disputes.

Primary matching — transaction note

When a customer pays, their UPI app fills the transaction note (tn field) with the order ID (sp_ord_...). SeedhaPe reads this from the Android notification and matches it to the order directly. This is the most reliable path and requires no configuration.

Fallback matching — amount + sender name

Some UPI apps don't preserve the transaction note, or customers use a different app than expected. In this case SeedhaPe falls back to:

  1. 1Find all pending orders for this merchant with the same amount created within the last 15 minutes.
  2. 2If exactly one order matches the amount AND the sender name partially matches expectedSenderName, verify it.
  3. 3If exactly one order matches amount alone (no name conflict), verify it.
  4. 4If multiple orders match and names don't disambiguate → mark all as DISPUTED.
📌

Important: Always pass expectedSenderName when you know the payer's name (e.g. from your user profile). This is the single most effective way to prevent disputes. The name is matched partially (token overlap), so "Rahul" matches "Rahul Kumar Sharma".

Handling disputes

  • Disputed orders appear in your Dashboard → Disputes tab and in the mobile app.
  • Tap the order → Approve (if you confirm the payment arrived) or Reject.
  • Approving moves the order to RESOLVED and fires the order.resolved webhook.
  • You can add a resolution note (e.g. UTR from bank statement) for your records.

Section 10

Production Go-Live Checklist

Before you launch to real customers, verify each item below.

Use live API keys

Keys starting with sp_live_ only. Never use test keys in production.

UPI ID is correct

Double-check the UPI ID in Settings — wrong ID means payments go to the wrong account.

Android app is running

Confirm the dashboard shows your device as ONLINE with a recent heartbeat.

Battery optimization disabled

Set SeedhaPe to Unrestricted in Android battery settings.

Webhook URL is HTTPS

SeedhaPe only delivers to HTTPS endpoints in production.

Webhook signature verified

Your handler must validate X-SeedhaPe-Signature before processing.

Idempotent webhook handler

Handle duplicate events gracefully — network retries can cause repeats.

expectedSenderName set

Pass payer name on createOrder wherever possible to minimise disputes.

Webhook test succeeds

Use the Test button in Settings → Webhook URL to confirm delivery.

Dispute monitoring

Set up alerts for new disputes — they need merchant action within 24 hours.

Secrets in environment variables

SEEDHAPE_API_KEY and SEEDHAPE_WEBHOOK_SECRET must never be in source code.


Section 11

Troubleshooting

Common issues and how to resolve them.

Order creation fails with "Merchant offline"

Your Android device hasn't sent a heartbeat recently. Open the SeedhaPe app, check the connection status. Ensure notification permission is granted and battery optimization is disabled.

Payment was made but order stays PENDING

The notification wasn't captured. Check: (1) The payer used a supported UPI app. (2) Your phone received a payment notification — check your notification shade. (3) Notification listener permission is still active (Android sometimes resets it after updates).

Order became DISPUTED instead of VERIFIED

The matching engine found multiple orders with the same amount. Prevent this by: (1) Always passing expectedSenderName. (2) Using distinct amounts (even ₹1 difference). (3) Keeping order expiry short so stale orders don't pollute the window.

Webhook not received

Check: (1) Your URL is reachable from the internet (test with the dashboard Test button). (2) Your endpoint returns HTTP 200. (3) Check the Webhook Retry Log in your dashboard for error details. (4) Ensure your server isn't rejecting requests without a body parser.

Webhook signature verification failing

Read the raw request body as a string before parsing JSON. If you use express.json() middleware, the body is already parsed — use express.raw() for the webhook route instead. The HMAC is computed over the raw bytes.

API returns 401 Unauthorized

Check that you're passing the correct API key in the Authorization: Bearer header. Keys are environment-specific — ensure you're not using a test key against the production API.

QR code is not scanning

The qrCode field is a base64-encoded PNG data URL. Render it in an <img> tag directly: <img src={order.qrCode} />. Make sure you're not double-encoding it.

Ready to go live?

Create your account and accept your first UPI payment in under 10 minutes.