API Reference

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/v1
bash

API 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
javascript
  • 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.

List test payments
curl https://api.handypay.me/api/v1/test-payments \
  -H "Authorization: Bearer hp_test_your_api_key_here"
bash

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

JSON
{
  "success": true,
  "data": { ... },
  "request_id": "550e8400-e29b-41d4-a716-446655440000"
}
json

Error

JSON
{
  "success": false,
  "error": {
    "code": "validation_error",
    "message": "Name is required"
  },
  "request_id": "550e8400-e29b-41d4-a716-446655440000"
}
json

Pagination

All list endpoints use cursor-based pagination.

FieldTypeRequiredDescription
limitnumberNoItems per page (1–100, default 10)
starting_afterstringNoID 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.

MethodPathDescription
POST/v1/productsCreate a product
GET/v1/productsList products
GET/v1/products/:idGet a product
PUT/v1/products/:idUpdate a product
DELETE/v1/products/:idArchive a product

Create a product

TypeScript
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"
typescript
cURL example
cURL
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"
    }
  }'
bash

Request body

FieldTypeRequiredDescription
namestringYesProduct name
descriptionstringNoProduct description
imagesstring[]NoUp to 8 image URLs
metadataobjectNoCustom key-value pairs (up to 50 keys)
activebooleanNoDefault true
urlstringNoProduct page URL on your site
shippablebooleanNoWhether product requires shipping
unit_labelstringNoPer-unit label (e.g. "seat", "license")
statement_descriptorstringNoBank statement text (max 22 chars)
tax_codestringNoStripe Tax code
price.amountnumberNoPrice in smallest currency unit (cents)
price.currencystringNoISO 4217 currency code (e.g. "usd", "jmd")
price.tax_behaviorstringNoinclusive, exclusive, or unspecified

Customers

Manage customer records for repeat purchases and subscriptions.

MethodPathDescription
POST/v1/customersCreate a customer
GET/v1/customersList customers
GET/v1/customers/:idGet a customer
PUT/v1/customers/:idUpdate a customer
DELETE/v1/customers/:idDelete a customer

Create a customer

TypeScript
const customer = await handypay("/customers", {
  method: "POST",
  body: JSON.stringify({
    email: "[email protected]",
    name: "Jane Doe",
  }),
});

console.log(customer.id); // "cus_abc123"
typescript
cURL example
cURL
curl -X POST https://api.handypay.me/api/v1/customers \
  -H "Authorization: Bearer hp_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "name": "Jane Doe"
  }'
bash

Request body

FieldTypeRequiredDescription
emailstringYesCustomer email
namestringNoCustomer name
phonestringNoCustomer phone number
metadataobjectNoCustom 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.

MethodPathDescription
POST/v1/payment-sessionsCreate a payment session
GET/v1/payment-sessions/:idGet session status
GET/v1/test-paymentsList test payments (hp_test_ only)

Create a payment session (with existing price)

TypeScript
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;
typescript
cURL example
cURL
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"
  }'
bash

Create a payment session (custom amount)

TypeScript
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",
  }),
});
typescript
cURL example
cURL
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"
  }'
bash

Request body

FieldTypeRequiredDescription
line_itemsarrayYesAt least one line item
success_urlstringYesRedirect URL after successful payment
cancel_urlstringYesRedirect URL if customer cancels
customer_idstringNoExisting customer ID
customer_emailstringNoPre-fill email (if no customer_id)
pass_fees_to_customerbooleanNoAdd processing and service fees to the customer total
metadataobjectNoCustom key-value pairs
collect_shipping_addressbooleanNoCollect a shipping address
billing_address_collectionstringNoauto or required
shipping_countriesstring[]NoAllowed ISO-2 destination countries
shipping_optionsarrayNoShipping labels and amounts in the smallest currency unit

Line item fields

FieldTypeRequiredDescription
price_idstringNoExisting Stripe Price ID
amountnumberNoCustom amount in cents
currencystringNoRequired with amount
namestringNoRequired with amount
quantitynumberYesQuantity

Provide either price_id or amount+currency+name per line item.

HandyPay substitutes {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.

MethodPathDescription
POST/v1/payment-intentsCreate an embedded PaymentIntent
Server-side TypeScript
const intent = await handypay("/payment-intents", {
  method: "POST",
  body: JSON.stringify({
    amount: 5000,
    currency: "ttd",
    description: "Order #1042",
    customer_email: "[email protected]",
    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,
};
typescript
FieldTypeRequiredDescription
amountnumberYesPositive integer in the smallest currency unit
currencystringYesISO 4217 three-letter currency code
descriptionstringNoPayment description
customer_emailstringNoCustomer email for receipts and reconciliation
pass_fees_to_customerbooleanNoGross up the amount so the customer covers fees
metadataobjectNoYour 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.

MethodPathDescription
POST/v1/refundsCreate a full or partial refund
TypeScript
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);
typescript
FieldTypeRequiredDescription
session_idstringConditionalCheckout Session ID (cs_...). Use this or payment_intent
payment_intentstringConditionalPaymentIntent ID (pi_...). Use this or session_id
amountnumberNoPartial refund amount in the smallest currency unit
reasonstringNoduplicate, 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.

MethodPathDescription
GET/v1/disputesList disputes
GET/v1/disputes/:idGet a dispute and its evidence status
POST/v1/disputes/:id/evidenceUpdate or submit evidence
TypeScript
const dispute = await handypay("/disputes/dp_123/evidence", {
  method: "POST",
  body: JSON.stringify({
    evidence: {
      customer_email_address: "[email protected]",
      product_description: "Annual software subscription",
      customer_communication: "https://files.example.com/evidence/1042.pdf",
    },
    submit: false,
  }),
});
typescript
FieldTypeRequiredDescription
evidenceobjectNoStripe dispute evidence fields as string values
submitbooleanNoSet true only when evidence is complete and ready for review

Subscriptions

Create recurring products and manage subscriptions.

Subscription Products

MethodPathDescription
POST/v1/subscription-productsCreate a subscription product
GET/v1/subscription-productsList subscription products

Subscription Sessions & Management

MethodPathDescription
POST/v1/subscription-sessionsCreate subscription checkout
GET/v1/subscriptionsList active subscriptions
PATCH/v1/subscriptions/:id/quantityChange seats with explicit proration
POST/v1/subscriptions/:id/cancelCancel at end of billing period

Supported billing intervals

weeklybi-weeklymonthlybi-monthlyquarterlysemi-annualannual

Create a subscription product

TypeScript
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 sessions
typescript
cURL example
cURL
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
  }'
bash

Request body

FieldTypeRequiredDescription
namestringYesProduct name
descriptionstringNoProduct description
amountnumberYesPrice in smallest currency unit
currencystringYesISO 4217 currency code
intervalstringYesOne of the supported intervals
trial_period_daysnumberNoFree trial duration in days
metadataobjectNoCustom key-value pairs

Create checkout with multiple seats

TypeScript
const session = await handypay("/subscription-sessions", {
  method: "POST",
  body: JSON.stringify({
    price_id: subProduct.price.id,
    quantity: 5,
    customer_email: "[email protected]",
    success_url: "https://example.com/success",
    cancel_url: "https://example.com/plans",
  }),
});
typescript

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.

TypeScript
const updated = await handypay("/subscriptions/sub_123/quantity", {
  method: "PATCH",
  body: JSON.stringify({
    quantity: 8,
    proration_behavior: "create_prorations",
  }),
});
typescript
FieldTypeRequiredDescription
quantitynumberYesNew seat count from 1 to 1,000
proration_behaviorstringNocreate_prorations, always_invoice, or none
item_idstringNoSpecific subscription item when a subscription has multiple products

Webhooks

Receive real-time event notifications via HTTP POST to your endpoints.

MethodPathDescription
POST/v1/webhook-endpointsRegister an endpoint
GET/v1/webhook-endpointsList endpoints
DELETE/v1/webhook-endpoints/:idDeactivate 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 id before 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
Treat signed webhooks as the payment source of truth—not the browser success redirect. A 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:

TypeScript
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);
}
typescript

Next.js API Route handler

app/api/webhooks/handypay/route.ts
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 });
}
typescript
Express.js handler
routes/webhooks.ts
// 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;
typescript

Webhook payload format

JSON
{
  "id": "evt_abc123",
  "type": "payment_intent.succeeded",
  "created": 1706745600,
  "data": { ... }
}
json

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.

MethodPathDescription
GET/v1/accountGet connected account and masked payout details
TypeScript
const account = await handypay("/account");

console.log({
  chargesEnabled: account.chargesEnabled,
  payoutsEnabled: account.payoutsEnabled,
  bank: account.bankAccount?.bankName,
  last4: account.bankAccount?.last4,
});
typescript

Error Codes

CodeHTTPDescription
unauthorized401Missing or invalid API key
key_revoked401API key has been revoked
key_expired401API key has expired
rate_limit_exceeded429Too many requests
validation_error400Request body validation failed
invalid_url400Invalid success_url or cancel_url
invalid_interval400Unsupported billing interval
product_not_found404Product does not exist
customer_not_found404Customer does not exist
session_not_found404Checkout session does not exist
payment_not_found404Payment is missing or is not owned by this merchant
subscription_not_found404Subscription does not exist
refund_not_allowed400The payment is disputed and cannot be refunded
already_refunded400The payment is already fully refunded
refund_amount_too_large400Amount exceeds the remaining refundable balance
insufficient_balance400Available balance cannot cover the refund
balance_verification_failed400Balance ownership or availability could not be verified
endpoint_not_found404Unknown API endpoint
stripe_error502Stripe API returned an error
internal_error500Unexpected server error
payload_too_large413Request body exceeds 1MB
prohibited_content400Content violates acceptable use policy
webhook_url_must_be_https400Webhook URL must use HTTPS
key_creation_rate_exceeded429Too many keys created in time window
max_keys_reached400Maximum active API keys reached (25)

Security

  • Keep hp_live_ and hp_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, and Strict-Transport-Security headers.
  • 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.