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.
Create an account at seedhape.com/sign-up — it's free, no credit card needed.
Set your UPI ID in Dashboard → Settings → Business Profile. This is where customer payments will land.
Generate an API key in Settings → API Keys → New Key. Copy it immediately — it won't be shown again.
Install the SeedhaPe Android app on the phone where your UPI notifications arrive. Enter your API key, grant notification access.
Create an order from your backend using the API or SDK. Get back a upiUri and QR code.
Show the payment UI to your customer using our hosted page, SDK modal, or React components.
Receive the webhook at your endpoint — SeedhaPe fires order.verified within 5 seconds of payment. Fulfill the order.
Account Setup
Configure your merchant profile and generate API credentials.
Create your account
- 1Visit seedhape.com/sign-up and create a free account.
- 2After sign-in, open Dashboard → Settings.
- 3Fill in your Business Name — this appears on the hosted payment page.
- 4Set your UPI ID (e.g. merchant@ybl or 9876543210@paytm). Customer payments land directly here.
- 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
- 1In Settings, scroll to API Keys and click New Key.
- 2A key is generated in the format sp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
- 3Copy it immediately — it is shown only once.
- 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.
- 1In Settings, enter your Webhook URL (e.g. https://api.yoursite.com/webhooks/seedhape).
- 2Create a random Webhook Secret (min 16 chars) and enter it in the field.
- 3Store the same secret in your server environment as SEEDHAPE_WEBHOOK_SECRET.
- 4Click Test to verify your endpoint receives the test event.
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
- 1Download and install the SeedhaPe merchant app on your Android device.
- 2Open the app and enter your API key (sp_live_...) when prompted.
- 3Tap Connect. The app verifies your key and registers the device.
- 4When the system prompts for Notification Access, tap Allow — this is required for the app to read UPI notifications.
- 5Allow the app's own persistent notification — Android uses it to keep the background service alive.
- 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.
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: Bearer sp_live_YOUR_KEYPOST /v1/orders — Create an order
Creates a new payment order and returns a UPI URI and QR code.
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
amountnumberrequiredAmount in paise (1 rupee = 100 paise). ₹499 = 49900.
descriptionstringShort description shown on the payment page and in UPI apps. Max 100 chars.
externalOrderIdstringYour own order ID for deduplication. Returned in webhooks as externalOrderId. Use this to link SeedhaPe orders to your database rows.
expectedSenderNamestringThe 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.
customerEmailstringCustomer email — stored on the order, echoed in webhooks, not shown to payer.
customerPhonestringCustomer phone number. Same as email — stored and echoed only.
expiresInMinutesnumberOrder 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
{
"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 "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
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/sdkInitialization
// 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
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 URLShow 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.
// 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.
// 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
// 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 | REJECTEDReact 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/sdkSeedhaPeProvider
Required for all three components — PaymentButton, 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 (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 (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 (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>requiredCalled 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.
baseUrlstringOverride 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.
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>
);
}amountnumberrequiredAmount in paise (₹1 = 100 paise).
descriptionstringOrder description shown on payment page.
expectedSenderNamestringPayer's UPI-registered name. Strongly recommended — improves matching accuracy.
customerEmailstringCustomer email, stored on the order.
customerPhonestringCustomer phone number.
metadataRecord<string, unknown>Arbitrary data echoed in webhook payloads.
onSuccess(result: PaymentResult) => voidCalled when payment is verified. result.status is VERIFIED or RESOLVED. The webhook has already fired at this point.
onExpired(orderId: string) => voidCalled when the order expires without payment.
classNamestringCustom CSS class. When provided, default button styles are removed entirely.
childrenReactNodeButton 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.
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.
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 — 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.
Webhook Setup
SeedhaPe notifies your backend of every payment event via signed HTTP POST requests.
Events reference
Sample payload — order.verified
// 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 (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
// 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 OKimmediately 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.externalOrderIdto 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.
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
expectedSenderNamematches 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:
// 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:
# 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 }// 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
/v1/pay/:orderId/expectationpublicBody: { "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.disputedwebhook fires - You review the screenshot in the Disputes tab (dashboard or Android app)
- Approve → status → RESOLVED,
order.resolvedfires - Reject → status → REJECTED,
order.resolvedfires
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:
# 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-..."
}// 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
/v1/pay/:orderId/screenshotpublicBody: multipart/form-data — field name screenshot, image file.
Accepts when status is PENDING, DISPUTED, or EXPIRED.
Returns: { ok, message, screenshotUrl }
// 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:
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.
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:
- 1Find all pending orders for this merchant with the same amount created within the last 15 minutes.
- 2If exactly one order matches the amount AND the sender name partially matches
expectedSenderName, verify it. - 3If exactly one order matches amount alone (no name conflict), verify it.
- 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.resolvedwebhook. - You can add a resolution note (e.g. UTR from bank statement) for your records.
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.
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.