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 and Brand plans, 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.
Which line-item form should I use?
Use price_id line items to sell products from your HandyPay catalog, and amount + currency + name line items for amounts computed at request time. Both forms work for every HandyPay account: HandyPay decides where each session is hosted so card payments always work, with the same fees and payout flow as hosted payment links. Before August 29, 2026, accounts paid by cross-border payout could see "No valid payment method types for this Checkout Session" on price_id sessions; that is fixed, and no request changes are needed.
{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 |
Choose the right session form
Both forms work for every HandyPay account: HandyPay decides where the checkout is hosted so card payments always work, exactly like hosted subscription links. Use a price_id to sell a subscription product from your catalog; this form also supports trials, fee passing, and saved customers. Use the inline form (amount_cents + currency + interval) when the amount or schedule is computed at request time. Before August 29, 2026, accounts paid by cross-border payout could see "No valid payment method types for this Checkout Session" on price_id sessions; that is fixed, and no request changes are needed.
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 a saved price
const session = await handypay("/subscription-sessions", {
method: "POST",
body: JSON.stringify({
price_id: subProduct.price.id,
quantity: 5,
customer_email: signedInUser.email, // Prefills the subscriber's email
trial_period_days: 14,
success_url: "https://example.com/success",
cancel_url: "https://example.com/plans",
}),
});
// Redirect the customer to session.urltrial_period_days, pass_fees_to_customer, and customer_id apply only to price_id sessions.
Prefill the subscriber's email
For API-created sessions, send customer_email from your server. Checkout opens with the address already filled, and the same address is used for receipts. If you provide customer_id, HandyPay uses that existing customer instead. Keep your HandyPay API key on the server.
Reusable hosted subscription links can also prefill email without an API request. Add the email query parameter with URLSearchParams before redirecting the customer:
const checkoutUrl = new URL("YOUR_HANDYPAY_SUBSCRIPTION_LINK");
// URLSearchParams safely encodes the email address.
checkoutUrl.searchParams.set("email", signedInUser.email);
window.location.assign(checkoutUrl.toString());Prefer server-created sessions when possible. Query parameters can appear in browser history and logs, so only add an email address the customer has already provided to your application.
Create a subscription checkout (inline pricing)
// Inline pricing - use when the amount or schedule is computed at
// request time instead of coming from a saved subscription product.
const session = await handypay("/subscription-sessions", {
method: "POST",
body: JSON.stringify({
amount_cents: 1999,
currency: "ttd",
interval: "month", // day | week | month | year
interval_count: 1, // e.g. 3 with "month" = quarterly
name: "Pro Plan",
quantity: 1,
customer_email: signedInUser.email, // Prefills the subscriber's email
success_url: "https://example.com/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://example.com/plans",
}),
});Request body (inline form)
| Field | Type | Required | Description |
|---|---|---|---|
| amount_cents | number | Yes | Recurring price in the smallest currency unit |
| currency | string | Yes | ISO 4217 code. USD, TTD, JMD, XCD, and other supported settlement currencies |
| interval | string | Yes | day, week, month, or year |
| interval_count | number | No | Billing every N intervals, 1-52 (default 1) |
| name | string | No | Product name shown at checkout |
| quantity | number | No | Seats from 1 to 1,000 (default 1) |
| customer_email | string | No | Pre-fill the customer email |
| success_url | string | Yes | Redirect URL after checkout |
| cancel_url | string | Yes | Redirect URL if the customer cancels |
| metadata | object | No | Custom key-value pairs, echoed on webhooks |
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.