Complete endpoint reference with TypeScript examples. All examples use the handypay helper from the Quick Start. Tooling can download the OpenAPI 3.1 contract.
Base URL
https://api.handypay.me/api/v1API version: 2025-01-01 (returned in X-API-Version header on every response).
Authentication
All requests require a Bearer token in the Authorization header.
Authorization: Bearer hp_live_your_api_key_here- Keys prefixed with
hp_live_are for production. - Keys prefixed with
hp_test_are for sandbox/testing. - Generate and manage keys from the Merchant Portal.
Test Mode is fully isolated
Every hp_test_ request uses a dedicated test account. Test products, customers, payments, subscriptions, and webhook endpoints never appear in live business activity. View them in the Test Mode workspace.
curl https://api.handypay.me/api/v1/test-payments \
-H "Authorization: Bearer hp_test_your_api_key_here"Rate Limits
1,000 requests per hour per API key. Exceeding the limit returns 429 with a Retry-After header.
Response Format
Every response is wrapped in a standard envelope.
Success
{
"success": true,
"data": { ... },
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}Error
{
"success": false,
"error": {
"code": "validation_error",
"message": "Name is required"
},
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}Pagination
All list endpoints use cursor-based pagination.
| Field | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Items per page (1–100, default 10) |
| starting_after | string | No | ID of the last item from previous page |
Response includes has_more: true when additional pages exist.
Products
Create and manage products for one-time purchases.
| Method | Path | Description |
|---|---|---|
| POST | /v1/products | Create a product |
| GET | /v1/products | List products |
| GET | /v1/products/:id | Get a product |
| PUT | /v1/products/:id | Update a product |
| DELETE | /v1/products/:id | Archive a product |
Create a product
const product = await handypay("/products", {
method: "POST",
body: JSON.stringify({
name: "Premium Plan",
description: "Access to all features",
price: {
amount: 2999,
currency: "usd",
},
}),
});
console.log(product.id); // "prod_abc123"cURL example
curl -X POST https://api.handypay.me/api/v1/products \
-H "Authorization: Bearer hp_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Premium Plan",
"description": "Access to all features",
"price": {
"amount": 2999,
"currency": "usd"
}
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Product name |
| description | string | No | Product description |
| images | string[] | No | Up to 8 image URLs |
| metadata | object | No | Custom key-value pairs (up to 50 keys) |
| active | boolean | No | Default true |
| url | string | No | Product page URL on your site |
| shippable | boolean | No | Whether product requires shipping |
| unit_label | string | No | Per-unit label (e.g. "seat", "license") |
| statement_descriptor | string | No | Bank statement text (max 22 chars) |
| tax_code | string | No | Stripe Tax code |
| price.amount | number | No | Price in smallest currency unit (cents) |
| price.currency | string | No | ISO 4217 currency code (e.g. "usd", "jmd") |
| price.tax_behavior | string | No | inclusive, exclusive, or unspecified |
Customers
Manage customer records for repeat purchases and subscriptions.
| Method | Path | Description |
|---|---|---|
| POST | /v1/customers | Create a customer |
| GET | /v1/customers | List customers |
| GET | /v1/customers/:id | Get a customer |
| PUT | /v1/customers/:id | Update a customer |
| DELETE | /v1/customers/:id | Delete a customer |
Create a customer
const customer = await handypay("/customers", {
method: "POST",
body: JSON.stringify({
email: "customer@example.com",
name: "Jane Doe",
}),
});
console.log(customer.id); // "cus_abc123"cURL example
curl -X POST https://api.handypay.me/api/v1/customers \
-H "Authorization: Bearer hp_live_..." \
-H "Content-Type: application/json" \
-d '{
"email": "customer@example.com",
"name": "Jane Doe"
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
| string | Yes | Customer email | |
| name | string | No | Customer name |
| phone | string | No | Customer phone number |
| metadata | object | No | Custom key-value pairs |
Payment Sessions
Create hosted checkout sessions for one-time payments. Standard HandyPay pricing applies: 4.9% + US$0.40 per transaction on the free plan, or 4.2% + US$0.40 on Pro. There is no extra API or platform fee on top.
| Method | Path | Description |
|---|---|---|
| POST | /v1/payment-sessions | Create a payment session |
| GET | /v1/payment-sessions/:id | Get session status |
| GET | /v1/test-payments | List test payments (hp_test_ only) |
Create a payment session (with existing price)
const session = await handypay("/payment-sessions", {
method: "POST",
body: JSON.stringify({
line_items: [{ price_id: "price_abc123", quantity: 1 }],
success_url: "https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://yoursite.com/cancel",
}),
});
// Redirect customer to checkout
window.location.href = session.url;cURL example
curl -X POST https://api.handypay.me/api/v1/payment-sessions \
-H "Authorization: Bearer hp_live_..." \
-H "Content-Type: application/json" \
-d '{
"line_items": [{ "price_id": "price_abc123", "quantity": 1 }],
"success_url": "https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": "https://yoursite.com/cancel"
}'Create a payment session (custom amount)
const session = await handypay("/payment-sessions", {
method: "POST",
body: JSON.stringify({
line_items: [{
amount: 5000,
currency: "usd",
name: "Custom Order",
quantity: 1,
}],
success_url: "https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://yoursite.com/cancel",
}),
});cURL example
curl -X POST https://api.handypay.me/api/v1/payment-sessions \
-H "Authorization: Bearer hp_live_..." \
-H "Content-Type: application/json" \
-d '{
"line_items": [{
"amount": 5000,
"currency": "usd",
"name": "Custom Order",
"quantity": 1
}],
"success_url": "https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": "https://yoursite.com/cancel"
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
| line_items | array | Yes | At least one line item |
| success_url | string | Yes | Redirect URL after successful payment |
| cancel_url | string | Yes | Redirect URL if customer cancels |
| customer_id | string | No | Existing customer ID |
| customer_email | string | No | Pre-fill email (if no customer_id) |
| pass_fees_to_customer | boolean | No | Add processing and service fees to the customer total |
| metadata | object | No | Custom key-value pairs |
| collect_shipping_address | boolean | No | Collect a shipping address |
| billing_address_collection | string | No | auto or required |
| shipping_countries | string[] | No | Allowed ISO-2 destination countries |
| shipping_options | array | No | Shipping labels and amounts in the smallest currency unit |
Line item fields
| Field | Type | Required | Description |
|---|---|---|---|
| price_id | string | No | Existing Stripe Price ID |
| amount | number | No | Custom amount in cents |
| currency | string | No | Required with amount |
| name | string | No | Required with amount |
| quantity | number | Yes | Quantity |
Provide either price_id or amount+currency+name per line item.
{CHECKOUT_SESSION_ID} in the success URL. Query that ID with the same merchant and the same live/test key mode that created it. The session remains queryable after completion, but your signed webhook should still drive invoice fulfillment.Embedded Payments
Create a PaymentIntent from your server when you want to render Stripe Elements on your own checkout page. Your HandyPay API key stays server-side; send only the returned publishable key, connected account ID, and short-lived client secret to the browser.
| Method | Path | Description |
|---|---|---|
| POST | /v1/payment-intents | Create an embedded PaymentIntent |
const intent = await handypay("/payment-intents", {
method: "POST",
body: JSON.stringify({
amount: 5000,
currency: "ttd",
description: "Order #1042",
customer_email: "buyer@example.com",
pass_fees_to_customer: true,
metadata: { order_id: "1042" },
}),
});
// Pass these values to Stripe.js/Elements. Never pass HANDYPAY_API_KEY.
return {
clientSecret: intent.client_secret,
publishableKey: intent.publishable_key,
stripeAccount: intent.stripe_account,
};| Field | Type | Required | Description |
|---|---|---|---|
| amount | number | Yes | Positive integer in the smallest currency unit |
| currency | string | Yes | ISO 4217 three-letter currency code |
| description | string | No | Payment description |
| customer_email | string | No | Customer email for receipts and reconciliation |
| pass_fees_to_customer | boolean | No | Gross up the amount so the customer covers fees |
| metadata | object | No | Your order or invoice identifiers |
Refunds
Refund a payment owned by the authenticated merchant. HandyPay verifies payment ownership and available balance before creating the reversal. Omit amount for a full refund.
| Method | Path | Description |
|---|---|---|
| POST | /v1/refunds | Create a full or partial refund |
const refund = await handypay("/refunds", {
method: "POST",
body: JSON.stringify({
session_id: "cs_live_...",
amount: 2500,
reason: "requested_by_customer",
}),
});
console.log(refund.id, refund.status);| Field | Type | Required | Description |
|---|---|---|---|
| session_id | string | Conditional | Checkout Session ID (cs_...). Use this or payment_intent |
| payment_intent | string | Conditional | PaymentIntent ID (pi_...). Use this or session_id |
| amount | number | No | Partial refund amount in the smallest currency unit |
| reason | string | No | duplicate, fraudulent, or requested_by_customer |
A disputed, fully refunded, cross-merchant, or insufficient-balance payment is rejected without creating a refund.
Disputes
Review chargebacks for the connected merchant and provide evidence before the due date.
| Method | Path | Description |
|---|---|---|
| GET | /v1/disputes | List disputes |
| GET | /v1/disputes/:id | Get a dispute and its evidence status |
| POST | /v1/disputes/:id/evidence | Update or submit evidence |
const dispute = await handypay("/disputes/dp_123/evidence", {
method: "POST",
body: JSON.stringify({
evidence: {
customer_email_address: "buyer@example.com",
product_description: "Annual software subscription",
customer_communication: "https://files.example.com/evidence/1042.pdf",
},
submit: false,
}),
});| Field | Type | Required | Description |
|---|---|---|---|
| evidence | object | No | Stripe dispute evidence fields as string values |
| submit | boolean | No | Set true only when evidence is complete and ready for review |
Subscriptions
Create recurring products and manage subscriptions.
Subscription Products
| Method | Path | Description |
|---|---|---|
| POST | /v1/subscription-products | Create a subscription product |
| GET | /v1/subscription-products | List subscription products |
Subscription Sessions & Management
| Method | Path | Description |
|---|---|---|
| POST | /v1/subscription-sessions | Create subscription checkout |
| GET | /v1/subscriptions | List active subscriptions |
| PATCH | /v1/subscriptions/:id/quantity | Change seats with explicit proration |
| POST | /v1/subscriptions/:id/cancel | Cancel at end of billing period |
Supported billing intervals
Create a subscription product
const subProduct = await handypay("/subscription-products", {
method: "POST",
body: JSON.stringify({
name: "Pro Plan",
description: "Monthly pro access",
amount: 1999,
currency: "usd",
interval: "monthly",
trial_period_days: 14,
}),
});
console.log(subProduct.price.id); // Use this price_id for subscription sessionscURL example
curl -X POST https://api.handypay.me/api/v1/subscription-products \
-H "Authorization: Bearer hp_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Pro Plan",
"description": "Monthly pro access",
"amount": 1999,
"currency": "usd",
"interval": "monthly",
"trial_period_days": 14
}'Request body
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Product name |
| description | string | No | Product description |
| amount | number | Yes | Price in smallest currency unit |
| currency | string | Yes | ISO 4217 currency code |
| interval | string | Yes | One of the supported intervals |
| trial_period_days | number | No | Free trial duration in days |
| metadata | object | No | Custom key-value pairs |
Create checkout with multiple seats
const session = await handypay("/subscription-sessions", {
method: "POST",
body: JSON.stringify({
price_id: subProduct.price.id,
quantity: 5,
customer_email: "buyer@example.com",
success_url: "https://example.com/success",
cancel_url: "https://example.com/plans",
}),
});Change seats on an active subscription
Quantity must be an integer from 1 to 1,000. Choose how the billing adjustment is handled instead of relying on an implicit default.
const updated = await handypay("/subscriptions/sub_123/quantity", {
method: "PATCH",
body: JSON.stringify({
quantity: 8,
proration_behavior: "create_prorations",
}),
});| Field | Type | Required | Description |
|---|---|---|---|
| quantity | number | Yes | New seat count from 1 to 1,000 |
| proration_behavior | string | No | create_prorations, always_invoice, or none |
| item_id | string | No | Specific subscription item when a subscription has multiple products |
Webhooks
Receive real-time event notifications via HTTP POST to your endpoints.
| Method | Path | Description |
|---|---|---|
| POST | /v1/webhook-endpoints | Register an endpoint |
| GET | /v1/webhook-endpoints | List endpoints |
| DELETE | /v1/webhook-endpoints/:id | Deactivate an endpoint |
- Endpoints must use HTTPS.
- Webhook endpoints registered with an
hp_test_key receive test events only. Live and test destinations are stored separately. - Every active endpoint subscribed to an event receives an independent delivery signed with that endpoint's own secret. Do not reuse one endpoint's secret for another.
- Return a 2xx response within 10 seconds. Store each event
idbefore side effects so duplicate deliveries are safe. - After 10 consecutive delivery failures, an endpoint is automatically deactivated.
Supported event types
- payment_intent.succeeded
- payment_intent.payment_failed
- checkout.session.completed
- checkout.session.expired
- checkout.session.async_payment_succeeded
- checkout.session.async_payment_failed
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- charge.refunded
- charge.dispute.created
- charge.dispute.closed
payment_intent.payment_failed event identifies the PaymentIntent in data.id; use your metadata (for example order_id) to reconcile it. Use checkout.session.expired for session expiry and the async events for delayed payment methods.Verifying webhook signatures
Each delivery includes an X-HandyPay-Signature header in the format sha256={hex}. Verify by computing HMAC-SHA256 of the raw request body using your endpoint's signing secret:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySignature(
payload: string | Buffer,
secret: string,
signature: string
): boolean {
const prefix = "sha256=";
if (!signature.startsWith(prefix)) return false;
const hex = signature.slice(prefix.length);
if (!/^[0-9a-f]{64}$/i.test(hex)) return false;
const expected = createHmac("sha256", secret).update(payload).digest();
const received = Buffer.from(hex, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}Next.js API Route handler
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
export const runtime = "nodejs";
const SECRET = process.env.HANDYPAY_WEBHOOK_SECRET;
if (!SECRET) throw new Error("HANDYPAY_WEBHOOK_SECRET is not configured");
function hasValidSignature(payload: string, signature: string): boolean {
const prefix = "sha256=";
if (!signature.startsWith(prefix)) return false;
const hex = signature.slice(prefix.length);
if (!/^[0-9a-f]{64}$/i.test(hex)) return false;
const expected = createHmac("sha256", SECRET).update(payload).digest();
const received = Buffer.from(hex, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("x-handypay-signature") ?? "";
if (!hasValidSignature(body, signature)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
let event: { id: string; type: string; data: unknown };
try {
event = JSON.parse(body);
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
// Persist event.id before side effects so redeliveries can be ignored safely.
switch (event.type) {
case "checkout.session.completed":
// Fulfill an immediate payment.
break;
case "checkout.session.async_payment_succeeded":
// Fulfill a delayed payment.
break;
case "charge.refunded":
// Mark the matching order as refunded.
break;
}
return NextResponse.json({ received: true });
}Express.js handler
// Register this route BEFORE app.use(express.json()).
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const router = express.Router();
const SECRET = process.env.HANDYPAY_WEBHOOK_SECRET;
if (!SECRET) throw new Error("HANDYPAY_WEBHOOK_SECRET is not configured");
function hasValidSignature(payload: Buffer, signature: string): boolean {
const prefix = "sha256=";
if (!signature.startsWith(prefix)) return false;
const hex = signature.slice(prefix.length);
if (!/^[0-9a-f]{64}$/i.test(hex)) return false;
const expected = createHmac("sha256", SECRET).update(payload).digest();
const received = Buffer.from(hex, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
router.post(
"/handypay",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = String(req.headers["x-handypay-signature"] ?? "");
if (!hasValidSignature(req.body, signature)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString("utf8"));
// Persist event.id before side effects so redeliveries are idempotent.
console.log("Verified HandyPay event", event.id, event.type);
return res.json({ received: true });
}
);
export default router;Webhook payload format
{
"id": "evt_abc123",
"type": "payment_intent.succeeded",
"created": 1706745600,
"data": { ... }
}Account
Read the connected merchant's charge and payout readiness plus the default payout bank account. Bank and routing numbers are masked; only their last four digits are returned.
| Method | Path | Description |
|---|---|---|
| GET | /v1/account | Get connected account and masked payout details |
const account = await handypay("/account");
console.log({
chargesEnabled: account.chargesEnabled,
payoutsEnabled: account.payoutsEnabled,
bank: account.bankAccount?.bankName,
last4: account.bankAccount?.last4,
});Error Codes
| Code | HTTP | Description |
|---|---|---|
| unauthorized | 401 | Missing or invalid API key |
| key_revoked | 401 | API key has been revoked |
| key_expired | 401 | API key has expired |
| rate_limit_exceeded | 429 | Too many requests |
| validation_error | 400 | Request body validation failed |
| invalid_url | 400 | Invalid success_url or cancel_url |
| invalid_interval | 400 | Unsupported billing interval |
| product_not_found | 404 | Product does not exist |
| customer_not_found | 404 | Customer does not exist |
| session_not_found | 404 | Checkout session does not exist |
| payment_not_found | 404 | Payment is missing or is not owned by this merchant |
| subscription_not_found | 404 | Subscription does not exist |
| refund_not_allowed | 400 | The payment is disputed and cannot be refunded |
| already_refunded | 400 | The payment is already fully refunded |
| refund_amount_too_large | 400 | Amount exceeds the remaining refundable balance |
| insufficient_balance | 400 | Available balance cannot cover the refund |
| balance_verification_failed | 400 | Balance ownership or availability could not be verified |
| endpoint_not_found | 404 | Unknown API endpoint |
| stripe_error | 502 | Stripe API returned an error |
| internal_error | 500 | Unexpected server error |
| payload_too_large | 413 | Request body exceeds 1MB |
| prohibited_content | 400 | Content violates acceptable use policy |
| webhook_url_must_be_https | 400 | Webhook URL must use HTTPS |
| key_creation_rate_exceeded | 429 | Too many keys created in time window |
| max_keys_reached | 400 | Maximum active API keys reached (25) |
Security
- Keep
hp_live_andhp_test_keys on your server. Never place them in browser JavaScript, mobile apps, URLs, logs, or source control. - All responses include
X-Content-Type-Options: nosniff,X-Frame-Options: DENY, andStrict-Transport-Securityheaders. - Request body limit: 1 MiB, including streamed or chunked requests.
- Webhook endpoints must use HTTPS.
- Verify webhook signatures against the exact raw bytes and compare digests in constant time.
- Product names and descriptions are screened against a prohibited content blocklist.
- 5+ content violations in 24 hours will suspend your API keys.
API Key Limits
- Max 3 keys created per 10-minute window.
- Max 10 keys created per 1-hour window.
- Max 25 active keys per merchant.