Recent changes
Changes that can break a working integration, newest first. If your integration was written before a date below, read that entry.
POST /v1/signup requires a phone field. A request without one is rejected — it no longer creates an account.
// before — now returns 400
{ "business_name": "Kola Foods Ltd", "email": "ops@kola.ng", "password": "..." }
// after
{ "business_name": "Kola Foods Ltd", "email": "ops@kola.ng",
"phone": "0801 234 5678", "country": "NG", "password": "..." }A local number is read against country; an international number (+2348012345678) is taken as written. The number is stored in E.164 and is unique across accounts: one already registered returns 409 phone_taken, the same shape as email_taken. A number with the wrong number of digits is refused rather than stored, so show the 400 message to whoever typed it — it names what to fix. There is deliberately no “is this phone available?” endpoint: phone numbers are guessable, and one would let anyone learn which numbers bank here.
This API has always rejected request fields it doesn’t recognise (400 invalid_request, unknown field "x") rather than ignoring them. The OpenAPI spec didn’t say so, so clients generated from it — including by AI coding tools — could build bodies we refuse. Every request schema now declares additionalProperties: false. Nothing about the server changed here, except that endpoints with an optional body now apply the same rule when a body is sent: a misspelled at_period_end on POST /v1/subscriptions/{id}/cancel used to be ignored, and silently cancelled at the wrong time. Sending no body at all still works. metadata keys are yours and are unaffected.
Introduction
The HarePay API lets your product accept cards, bank transfer, and crypto, settle to a bank account or crypto wallet, and run invoicing and subscriptions — through one REST API that returns JSON.
Base URL
https://api.harepay.click
All amounts are integers in the smallest unit — kobo or cents for fiat, base units for crypto (USDC has 6 decimals). There are no floating-point amounts anywhere in the API. ₦5,000 is 500000, not 5000 — this one does not error, it just charges a hundredth of what you meant.
Request bodies are strict. A field this API does not recognise is rejected, never ignored: one stray or misspelled key fails the whole request with 400 invalid_request and a message naming it. If you are porting a handler from another provider, drop its extra fields rather than passing them through. Two more that catch people out: amount is an object ({ "amount": 500000, "currency": "NGN" }), not a bare number, and every metadata value must be a string.
Authentication
Authenticate every request with a secret API key as a Bearer token. Keys come in test and live modes (sk_test_… / sk_live_…). Create them after signing up, in the dashboard. Session tokens from /v1/login work too (for first-party apps). Always call the API from your server — never expose a secret key in a browser or mobile client.
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxx
Test mode
The key decides the mode — sk_test_… is test, sk_live_… is live. There is one base URL for both; you never switch hosts. Every response includes "livemode": true | false.
To run your full suite against the live deployment safely, point it at the same https://api.harepay.click with a sk_test_ key. Test-mode calls route to an in-memory sandbox rail — real endpoints, signing and webhooks, but no real money moves and no rail credentials are used.
- Isolated data. A test key only ever sees test objects; a live key only sees live objects. Cross-mode reads return
404. - Verification gate. Live money movement and issuing
sk_live_keys require a verified account (KYB/KYC). Test always works, unverified. - Fees mirror live. Test fees are computed on the rail you name, so amounts match what live would charge.
- In the dashboard, switch modes with the Test / Live toggle in the header.
Idempotency
Money-moving POST requests accept an Idempotency-Key header. Retrying with the same key returns the original result instead of creating a second charge — safe against network failures.
Idempotency-Key: a-unique-id-per-operation
Quickstart
Create a ₦10,000 bank-transfer payment:
curl -X POST https://api.harepay.click/v1/payment_intents \
-H "Authorization: Bearer sk_test_xxx" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{
"amount": { "amount": 1000000, "currency": "NGN" },
"method": "bank_transfer"
}'The payment becomes succeeded once it settles — listen for the payment_intent.succeeded webhook. To accept crypto and settle in naira, send "method": "crypto" with a stablecoin amount ({ "currency": "USDC", "network": "TRON" }) and set "settle_as": { "currency": "NGN" }.
Try it live
Paste an API key (use the demo key sk_test_demo against a local server) and send real requests right here.
Tip: start the API and use the demo key sk_test_demo. Run Create payment, then check List payments and Get NGN balance.
SDKs
A typed TypeScript SDK ships in the repo at web/lib/harepay-sdk.ts — dependency-free, works in Node 18+ and browsers. For any other language, generate a client from the OpenAPI spec (e.g. openapi-generator-cli generate -i https://api.harepay.click/openapi.json -g python).
import { HarePay } from "./harepay-sdk";
const lodum = new HarePay({
apiKey: process.env.HAREPAY_SECRET_KEY!,
baseUrl: "https://api.harepay.click",
});
// Accept crypto, settle in naira
const intent = await lodum.payments.create({
amount: { amount: 100_000000, currency: "USDC", network: "TRON" },
settle_as: { amount: 0, currency: "NGN" },
method: "crypto",
});
const { available } = await lodum.balances.get("NGN");
// Verify an inbound webhook (works in Node 18+ and browsers)
const ok = await HarePay.verifyWebhook(
WEBHOOK_SECRET, timestamp, rawBody, signatureHeader,
);AI & agents
HarePay is built for AI-assisted integration and for agents that act on your behalf.
llms.txt
A concise, machine-readable map of the API in the llms.txt format — point any AI coding tool (Cursor, Claude, etc.) at it and it will understand auth, conventions and every endpoint.
https://api.harepay.click/llms.txt
MCP server
A Model Context Protocol server ships in the repo at mcp/ (zero dependencies, Node 18+). It lets an assistant inspect your account and — with explicit confirmation — move money. Read-only tools (get_balance, list_payments, system_status, …) are safe to use freely; write tools (create_payment_intent, create_payout, create_invoice) require confirm: true, send an idempotency key, and respect maker-checker payout approval thresholds.
Add it to your MCP client config:
{
"mcpServers": {
"harepay": {
"command": "node",
"args": ["/path/to/LodumPayment/mcp/index.mjs"],
"env": {
"HAREPAY_API_BASE": "https://api.harepay.click",
"HAREPAY_SECRET_KEY": "sk_test_..."
}
}
}
}Errors
Errors use standard HTTP status codes and a consistent JSON envelope.
{ "error": { "type": "insufficient_funds", "message": "..." } }Pagination
List endpoints return records newest first, ordered by created_at descending with the record id as tiebreak — a total, stable order, so the same record never lands on two pages. limit is 1–200 (default 50).
{
"object": "list",
"data": [ ... ],
"has_more": true,
"next_cursor": "pi_000042"
}To sweep a list — a nightly reconciliation, an export, a backfill — page with the cursor, not offset. A cursor resumes from a fixed record, so it is unaffected by anything written while you page. An offset is a moving window: every payment that arrives mid-sweep shifts it, and your job re-reports records it already processed.
GET /v1/payment_intents?limit=100 GET /v1/payment_intents?limit=100&starting_after=pi_000042 # next_cursor of the previous page # ...repeat until "has_more": false
An unknown starting_after returns 400 invalid_cursor rather than silently restarting at page one — a sweep that quietly looped would re-process everything forever. Keep filters identical across the pages of one sweep.
Date windows. created_after is inclusive and created_before is exclusive (each YYYY-MM-DD at UTC midnight, or RFC3339), so consecutive days tile exactly — no record on a boundary is counted twice or dropped.
GET /v1/payouts?created_after=2026-03-01&created_before=2026-03-02 GET /v1/payouts?created_after=2026-03-02&created_before=2026-03-03
Available on /v1/payment_intents and /v1/payouts. offset still works for a simple UI page-through and is ignored when starting_after is set; passing nothing behaves exactly as it did before cursors existed.
Payments
Create a payment intent. Returns a next_action for the payer.
List payment intents, newest first. Supports limit, starting_after, created_after and created_before — see Pagination.
Retrieve a payment intent.
Request a refund (full or partial). Returns 202 with a refund object in status pending: the amount leaves your available balance immediately into a HarePay clearing account, and HarePay disburses it to the customer once their receiving details are confirmed (per the refund policy). It then moves to completed, or rejected (funds returned to you). Track status on the intent’s refunds[] and the refund.pending/completed/rejected webhooks. Cumulative refunds are capped at the settled net-of-fee amount; the strict policy also rejects a refund beyond your current available balance.
method is bank_transfer, card, mobile_money (needs payer_phone, the wallet that receives the approval prompt), or crypto (crypto takes a stablecoin currency — USDC/USDT — and a network). You don’t pick a provider — HarePay routes to the right one from the method (and to the sandbox rail in test mode). Call GET /v1/payment_options for the supported currencies and networks. Any metadata object you send is stored and echoed back unchanged on the resource and on every webhook for that payment, so you can reconcile to your own order.
Fees & who pays them. Every payment returns amount (what the customer paid), fee (the platform fee), and net (what settles to you). By default you absorb the fee (net = amount − fee). Set your account to charge the customer instead (Dashboard → Settings → Processing fee, or fee_bearer on PATCH /v1/me), or override per charge with { "fee_bearer": "customer" } on the payment — the fee is then added on top and you receive the full amount.
Two errors worth handling. 403 capability_disabled — an admin restricted that endpoint group for your account in this mode; request re-activation from your dashboard. 503 method_paused — the platform paused that payment method for maintenance or compliance; offer another method or retry later (payments already in flight still settle).
Response — store id and treat the charge as pending until a webhook confirms it:
{
"id": "pi_000001",
"object": "payment_intent",
"status": "requires_action", // → processing → succeeded | payment_failed
"livemode": false,
"amount": { "amount": 1000000, "currency": "NGN", "display": "NGN 10000.00" },
"method": "bank_transfer",
"metadata": { "order_id": "order-1001" },
"next_action": {
"type": "bank_transfer", // "redirect" | "bank_transfer" | "crypto_deposit" | "none"
"account_no": "1234567890", // virtual account for bank_transfer
"account_name": "Acme Stores Ltd",// recipient name to show the payer
"bank_name": "Providus Bank",
"url": "https://..." // hosted page when type = "redirect" (card)
}
}For bank_transfer show the payer next_action.account_no + bank_name; for a card (type: "redirect") send them to next_action.url.
Failed payments carry a reason. When status is failed, the resource includes failure_reason — a clean, human-readable explanation of why (the provider’s decline reason, or what went wrong at initiation). Show it to your team or use it to decide whether to retry; it’s also on the payment_intent.payment_failed webhook’s data.object. Absent when the customer simply abandoned the payment.
Hosted checkout
Prefer not to build the payer UI? Create a checkout session, share its url, and the payer picks a method and pays on a HarePay-hosted page. HarePay routes the provider by method. The session flips to paid when the payment settles.
Create a session — amount, optional methods, description, success_url, metadata. Returns the hosted url.
List your sessions.
Cancel one of your own sessions server-to-server (authenticated). The link can no longer be paid. Idempotent; refuses only an already-paid session (409). Same shape as POST /v1/subscriptions/{id}/cancel.
curl -X POST https://api.harepay.click/v1/checkout_sessions \
-H "Authorization: Bearer sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "amount": { "amount": 250000, "currency": "NGN" },
"methods": ["bank_transfer","card"],
"description": "Order #1234",
"success_url": "https://yourstore.com/order/complete",
"metadata": { "order_id": "1234" } }'
# → { "id": "cs_…", "url": "https://www.harepay.click/checkout/cs_…", "status": "open", … }Choosing methods. Omit methods and the link follows your account-level checkout_methods setting (Dashboard → Settings, or PATCH /v1/me) — change the setting and every such link updates instantly. Pass an explicit methods array to narrow a specific link further; methods you’ve disabled account-wide are rejected.
Return URLs. Set success_url and the payer is redirected there after paying; cancel_url is where a payer who backs out is sent. HarePay appends ?checkout_id=<id> to both so your page can look the order up. Without a success_url, the hosted page just shows a completion screen. Treat the webhook — not the redirect — as the source of truth for fulfillment.
Cancellation. To cancel from your backend, use the authenticated POST /v1/checkout_sessions/{id}/cancel (your secret key, owner- and mode-scoped — the same shape as subscription cancel). The hosted page’s payer-facing button calls the public POST /public/checkout/{id}/cancel instead, since a browser can’t hold a secret key. Either way the session moves to canceled so the link can no longer be paid — a deliberate cancel, distinct from a silent expired. Both are idempotent and refused (409) only once a session is paid. If real money still lands after a cancel (e.g. a bank transfer already in flight), the settlement webhook captures it and flips the session to paid — money is never dropped.
The linked payment carries metadata.checkout_id, so a payment_intent.succeeded webhook reconciles the order. Public endpoints power the hosted page (no API key — the session id is the capability): GET /public/checkout/{id}, POST /public/checkout/{id}/pay (the payer supplies their email, required for the receipt) and POST /public/checkout/{id}/cancel. Each session records the payer_email and payer_ip, and a lapsed link returns 410. Payers are auto-enrolled as customers (matched by email per mode, no duplicates) and the payment links to them via customer_id — your customer pool builds itself from checkout traffic.
Crypto checkout. crypto works on any session: the payer picks a stablecoin + network from the session’s crypto_assets and pays ({ method:"crypto", currency, network }). The stablecoin amount is the session’s value at 1:1 par for USD-priced sessions, or a live forward FX quote for other currencies (e.g. NGN), plus your platform FX margin — so the payer bears the spread and you’re made whole. It settles into your USD balance.
Mobile money. Add "mobile_money" to methods and the payer enters their wallet number — we push an approval prompt to their phone and they confirm with their wallet PIN ({ method:"mobile_money", phone } on the pay call, or payer_phone when creating a payment directly). Live collection is available for GHS; test mode can demo it in any currency.
Inline (popup) checkout
Rather than redirecting the customer away, pop the same checkout as an overlay on your own page with harepay.js. The checkout renders in an iframe on the HarePay origin — no payment details ever touch your DOM — and calls you back when it’s done. Your server still creates the session exactly as above (your secret key never belongs in a web page); the browser only ever sees the session url.
<script src="https://www.harepay.click/js/v1/harepay.js"></script>
<button id="pay">Pay ₦2,500</button>
<script>
document.getElementById("pay").onclick = async () => {
// Your backend creates the session (POST /v1/checkout_sessions) and
// returns its url — see the curl above.
const { url } = await fetch("/create-checkout", { method: "POST" }).then(r => r.json());
HarePay.checkout({
sessionUrl: url,
onSuccess: (e) => { // e.checkout_id, e.payment_id
// Show your "thanks" state; confirm fulfillment from the webhook.
window.location.href = "/order/complete?checkout_id=" + e.checkout_id;
},
onClose: () => { /* payer dismissed the popup without paying */ },
});
};
</script>Callbacks. onSuccess fires once when the payment completes (the overlay shows the ✓ briefly, then closes itself — pass autoClose: false to keep it up and call .close() yourself). onClose fires when the payer dismisses the popup without completing — ✕, Esc, clicking the backdrop, or cancelling the checkout. The two are mutually exclusive. As always, treat the payment_intent.succeeded webhook — not the browser callback — as the source of truth for fulfillment.
Card payments in the popup. Methods that hand off to a provider page (card, USSD) open that page in a new tab from the popup — provider pages refuse to render inside frames. The popup stays open, polling, and completes as soon as the payment lands. Bank transfer, crypto and mobile money run entirely inside the overlay.
Statements & exports
Pull your records for accounting: a statement of account as a branded PDF or CSV, plus flat CSV dumps of payments and payouts. All three are scoped to your account and the request’s mode — a sk_test_ key can never export live money — and arrive as downloads.
Statement of account. from/to (YYYY-MM-DD, to inclusive), optional currency, and format=pdf for the document (default CSV).
Every payment in the range, all statuses, with amount, fee, settled, refunded, payer and failure reason.
Every payout in the range, with amount, fee, destination and outcome.
curl -L https://api.harepay.click/v1/statement?from=2026-08-01&to=2026-08-31&format=pdf \ -H "Authorization: Bearer sk_live_xxx" -o august-statement.pdf
What the statement reports. Lines are settled payments (in), completed payouts (out — amount plus fee) and completed refunds (out), oldest first, with a cumulative running net, then period totals and your current available balance. The running column is the net of those lines, not a bank-style opening/closing balance: adjustments such as dispute reserves and verification fees don’t appear as lines, and we’d rather say so than print a balance that quietly disagrees with your ledger. A refunded payment keeps its original inflow, with the refund shown separately.
CSV amounts are plain decimals — no currency symbols or thousands separators — so a spreadsheet treats them as numbers. The statement’s signed net column sums to the period’s net movement.
PayMe
Access is enabled per business after a quick compliance review — request it with POST /v1/payme/request (or from the dashboard's PayMe tab); endpoints below return 403 until approved. To test end-to-end, create a ledger with a test key and use that ledger's link — the public generic link has no test variant (verified merchants' links always take real payments). A durable, no-setup pay link: /payme/{handle} lets anyone pay any amount — no invoice, no amount set up front. Every payer gets their own ledger: a running payment history at /payme/{handle}/c/{slug}, with an optional owed target so you can track a balance down to zero.
Get your link — auto-provisions from your business name on the first call (a number is appended on a name collision). Returns handle, url, enabled, title, message.
Update the page with PUT /v1/payme — handle, title, message, enabled (all optional). The handle can only be renamed while you have zero ledgers — once a link is in circulation, renaming would break it, so it locks (409 handle_locked); a handle someone else already owns returns 409 handle_taken.
Set up a debtor’s ledger and get their personal link — name required, plus email and/or phone. Optional owed sets what they owe; note is merchant-private.
curl -X POST https://api.harepay.click/v1/payme/ledgers \
-H "Authorization: Bearer sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "name": "Adewale Ade",
"email": "adewale@example.com",
"owed": { "amount": 5000000, "currency": "NGN" },
"note": "October supply run" }'
# → { "id": "pml_…", "url": "https://www.harepay.click/payme/acme-shop/c/…", "owed": {...}, … }GET /v1/payme/ledgers lists them; GET /v1/payme/ledgers/{id} returns one with its full entries[] (refund-aware — a refunded entry drops back out of paid/remaining); PATCH /v1/payme/ledgers/{id} adjusts owed (send 0 to clear it), note, name, or archives it.
The public flow. A visitor on /payme/{handle} enters their name, email and/or phone, and an amount, and the page calls POST /public/payme/{handle}/pay — HarePay finds their ledger by contact (or creates one) and returns a hosted checkout_url. The ledger entry is appended once the payment settles. Denominate in NGN, USD, GHS or KES (per-currency minimums, e.g. ₦100 or $1) — each ledger is kept in one currency, and the same customer paying in a second currency gets a second ledger so totals stay exact.
Privacy. ledger_url is only ever returned the moment a ledger is newly created — a repeat payer who just matches an existing contact gets returning: true instead, never the link (this endpoint has no API key, so anyone could claim an email or phone). Possession of a ledger link is the access control — hand it to your customer once and they use it to check their history from then on. The public ledger page (GET /public/payme/l/{slug}) shows first-name only; contact details and your note never leave the API.
PayMe sits under the payments capability, like the rest of collections — an admin can restrict it per mode (403 capability_disabled). Ledgers themselves are mode-scoped: an sk_test_ key only lists and creates test-mode ledgers, sk_live_ only live ones. The public pay link has no key, so it infers mode from your account: it collects live once you’re verified, and in test mode until then — so you can share and try the flow before going live.
Payouts
Send money out to a bank account or crypto wallet, drawn from your available balance. For a bank payout, first confirm the account holder’s name: list banks, then resolve the account — never send to an unverified account.
List supported banks with their code (NIP) and name, for a bank picker.
Resolve { bank_code, account_no } → { account_name } via live NIP name-enquiry.
Disburse to a bank account or crypto wallet. 422 if available balance is insufficient.
List payouts, newest first — cursor and date-window parameters as in Pagination, so a payroll run reconciles without missing or double-reporting a transfer.
Retrieve a payout.
# 1. confirm the account
curl -X POST https://api.harepay.click/v1/bank/resolve \
-H "Authorization: Bearer sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "bank_code": "058", "account_no": "0123456789" }'
# → { "account_name": "JANE DOE", ... }
# 2. send the payout (use rail:"bank" — HarePay picks the bank provider)
curl -X POST https://api.harepay.click/v1/payouts \
-H "Authorization: Bearer sk_test_xxx" \
-H "Idempotency-Key: payout-77" -H "Content-Type: application/json" \
-d '{
"amount": { "amount": 485000, "currency": "NGN" },
"rail": "bank",
"destination": {
"kind": "bank",
"bank_code": "058",
"account_no": "0123456789",
"account_name": "JANE DOE"
}
}'Crypto payout: send rail:"crypto" with an amount in a stablecoin ({ "currency": "USDC", "network": "TRON" }) and destination: { "kind": "crypto", "crypto_chain": "TRON", "crypto_addr": "T…" }. HarePay resolves the provider server-side. On-chain transfers are irreversible — validate the address and network first.
Status flows scheduled → processing → paid, or failed / returned. A payout at or above your approval threshold is held as pending_approval (maker-checker) until an admin approves it — so a payout is not always instant. Track completion with the payout.paid / payout.failed webhooks, or poll GET /v1/payouts/{id}.
Auto-settlement. Prefer not to create payouts at all? Set a settlement schedule (PUT /v1/me/settlement with { "schedule": "daily" | "weekly" | "manual" }, or Dashboard → Settings) and HarePay sweeps your live available balance to your default saved payout destination in a fixed settlement window — daily from 09:00 WAT, weekly on Monday mornings — same fees, same approval rules, same payout.paid webhooks as a manual payout. Accounts marked trusted by HarePay settle instantly without the routine review hold.
Testing failures. In test mode every destination pays instantly — except the magic declining ones: send a test payout to bank account 0000000000 (or crypto address 0xFAIL) and the sandbox rail declines it after the debit, exactly like a real bank decline — the amount and fee bounce back to your test balance and payout.failed fires, so you can rehearse your failure webhook end to end.
Bank transfers settle asynchronously. The bank usually accepts a transfer before it lands, so a payout sits in processing for a moment. HarePay resolves it two ways — the bank's own status callback, and a background sweep that re-asks the bank — then fires payout.paid or payout.failed and emails you the outcome. A failed payout returns the amount and the fee to your available balance; failure_reason says why.
Your own reference is optional, free-form, and echoed back on the payout — it does not have to be unique, because HarePay sends its own unique reference to the bank. Use Idempotency-Key to make the request safe to retry.
Identity checks (KYC / KYB)
Verify a customer's identity against official registries and get the full record back instantly — bio-data, photo where available, and per-field name matches. Each live check is charged from your available balance at the fee in GET /v1/verification_options, whether the identity matches or not; a check that cannot complete is never charged.
Run a check: type is bvn / nin / cac / drivers_license.
Your check history.
Supported types, your fees, and each type’s required_fields / optional_fields.
- Person (
bvn,nin,drivers_license) —id_numberplusfirstnameandlastname. The registry matches the ID against the name you submit, so the question it answers is “does this person own this ID?”.middlename,dob,phone,emailandgenderare optional and narrow the match. - Business (
cac) — the RC/BN/IT number only; there is no person to name-match.
Consent attestation. Send "consent": true, or { "obtained": true, "reference": "signup-8821" } to record where your evidence is held. It never reaches the registry — it is stored on the check and echoed back, so “who authorised looking this person up?” is answerable per lookup.
A partial mismatch returns the record; a total mismatch does not. A name that partly disagrees (a typo, a married name, an initial) still returns the registry record — verified: false with the detail in field_matches — because that is the branch a human reviews. But when nothing you submitted matches, the personal data is withheld: you get withheld: true and data_withheld instead of data. The check still ran and is still billed — the registry charges per query — but a caller who can’t name the person doesn’t receive their bio-data. There is no ID-only lookup mode; the registry doesn’t offer one.
curl -X POST https://api.harepay.click/v1/verifications \
-H "Authorization: Bearer sk_test_xxx" \
-H "Idempotency-Key: check-42" -H "Content-Type: application/json" \
-d '{
"type": "nin",
"id_number": "70123456789",
"firstname": "Amina",
"lastname": "Bello",
"consent": { "obtained": true, "reference": "signup-8821" }
}'
# → { "verified": true, # found, and the name agrees
# "field_matches": { "firstname": true, "lastname": true },
# "data": { ...full record: names, dob, gender, phone, address, photo... },
# "fee": { "display": "NGN 40.00" } }
# A name that disagrees still returns the record — that is the review branch:
# → { "verified": false, "field_matches": { "firstname": true, "lastname": false },
# "data": { ...full record... } }
# Business lookup needs only the registration number:
curl -X POST https://api.harepay.click/v1/verifications \
-H "Authorization: Bearer sk_test_xxx" -H "Content-Type: application/json" \
-d '{ "type": "cac", "id_number": "RC123456", "consent": true }'Every completed check is billed the same, match or not — the registry charges per query.
Test mode is free and returns realistic fixtures: any ID verifies; an ID ending in 00 completes as not verified (no record found); an ID ending in 01 is a partial match (the record exists, the first name matches, the last name does not — verified false, record returned); and an ID ending in 02 is a total mismatch (the record exists but nothing matches — the payload is withheld). Build your failure and review paths against both. You must have the subject's consent, and the returned record is personal data — store it accordingly.
Balances
Available, pending, and reserved balance for an asset (e.g. /v1/balances/NGN).
There’s no separate top-up or deposit endpoint, and you don’t need one — your balance is funded by the payments you collect. To add funds deliberately (say, to cover identity-check fees before you have collection volume), create an ordinary payment with POST /v1/payment_intents for the amount you want and pay it through any method you offer. It settles into available like any other collection, net of the standard collection fee, and appears in your normal payment history. Watch payment_intent.succeeded (or balance.updated) to know it landed.
Balance webhooks. balance.updated fires on every movement — collections, payouts, refunds, identity-check fees, dispute reserves — carrying the new available/pending/reserved and a reason, so you don’t need a follow-up call. Set a floor with PUT /v1/me/low_balance_threshold { "threshold": 500000 } (minor units; 0 disables) and balance.low fires when available drops below it — once per crossing, re-armed when you top back up.
Stablecoins settle to a single USD balance. When you collect crypto (USDC or USDT, on any network), it settles 1:1 into your USD balance — not a separate per-coin or per-network balance. So read /v1/balances/USD for all stablecoin funds, and a crypto payout is funded from that same USD balance regardless of which coin/network you send out. Fiat stays per-currency (e.g. NGN).
NGN auto-convert. Prefer to receive naira instead? Set your crypto_settle preference to auto_convert_ngn (Settings, or PATCH /v1/me) and crypto pay-ins convert to NGN at the live rate, net of the platform FX margin. The default, hold_stablecoin, keeps them as USD. Passing "settle_as": { "currency": "NGN" } on a single payment overrides the preference for that charge.
Invoices
Create an invoice with line items.
List invoices.
Retrieve an invoice.
Collect an invoice — creates a linked payment intent; the invoice is marked paid when it settles.
Subscriptions & usage
Create a price (recurring or usage), start a subscription on it, and report metered usage for pay-as-you-go. A scheduler generates invoices each period; POST /v1/billing/run triggers it on demand.
Create a price (recurring or usage). Recurring prices take trial_days for a free trial.
List prices.
Start a subscription on a price.
List subscriptions. Filter for entitlement checks: ?customer=cus_…&status=active.
Retrieve one — gate your product on status (trialing/active = entitled, past_due = grace, canceled = revoke) and current_period_end. Mirror it locally and keep it in sync with the subscription.* webhooks for instant updates.
Report metered usage (PAYG).
Cancel — at period end by default ({"at_period_end": false} revokes now).
Reverse a scheduled cancellation before it takes effect.
Subscribe via checkout
The recommended way to start a paid subscription: create a checkout session with a price_id instead of an amount. The payer pays the first period on the hosted page; when it settles, the subscription activates automatically (opening period marked prepaid) and the payer becomes a customer keyed by their email. The session echoes subscription_id once active.
POST /v1/checkout_sessions
{ "price_id": "price_…", "methods": ["bank_transfer","card"] }
# → { "url": "https://www.harepay.click/checkout/cs_…", … }Renewals, dunning & lifecycle
Each period the platform invoices the subscription and walks a collection ladder: prepaid balance auto-debit → saved card (where the payer previously paid by tokenized card) → emailed invoice with a hosted pay link. Unpaid renewals retry at +3/+7/+14 days; after that the subscription goes past_due (a later payment recovers it to active). Customers on monthly+ plans get a renewal reminder 7 days ahead. Statuses: trialing → active → past_due → canceled.
Webhook events keep your systems in sync: subscription.created, subscription.renewed, subscription.past_due, subscription.recovered, subscription.cancel_scheduled / resumed / canceled, invoice.created and invoice.paid — gate access on these rather than polling.
Allowances & overage
A usage price can include a per-period allowance via included_units: usage up to it is free, and unit_amount is the overage rate beyond it (0 = bill every unit). As you report usage, the moment cumulative usage crosses the allowance the API auto-generates an overage invoice for the exceeded units — it tracks what’s been billed so it never double-charges, and resets each period. The usage response includes an overage_invoice when one is raised.
POST /v1/prices
{ "model": "usage", "currency": "NGN",
"unit_amount": 100, // ₦1.00 per overage unit
"included_units": 1000, // first 1,000 units free each period
"auto_charge": true, // debit prepaid balance on overage (else invoice)
"interval": { "unit": "month", "count": 1 } }Prepaid balances & auto-charge
When a usage price sets auto_charge: true, an overage is debited from the customer’s prepaid balance instead of just invoiced (the resulting invoice is marked paid). If the balance can’t cover it, an open invoice is raised as a fallback. Top up a customer’s balance (in production this is funded by a real payment):
Credit a customer’s prepaid balance ({ "amount": { "amount": 100000, "currency": "NGN" } }).
The customer’s current balance is returned on GET /v1/customers/{id} as prepaid_ngn.
Webhooks
Register an endpoint (dashboard → Webhooks, or POST /v1/webhook_endpoints) and we deliver events as they happen. Each delivery carries four headers and a JSON body; every delivery is signed so you can verify it came from us.
Multiple products or sites? Register up to 5 endpoints — every event is delivered to all of them, each signed with its own secret, and each retried independently (one slow endpoint never blocks the others). Posting a new URL adds an endpoint; re-posting an existing URL rotates that endpoint’s secret. GET /v1/webhook_endpoints lists them; DELETE /v1/webhook_endpoints/{id} removes one. Each endpoint can also subscribe to a subset of events instead of all of them.
POST https://your-app.com/hooks
X-Lodum-Event: payment_intent.succeeded // also in the body as "type"
X-Lodum-Event-Id: evt_000002 // stable across retries
X-Lodum-Timestamp: 1750000000
X-Lodum-Signature: sha256=<hex>
X-Lodum-Attempt: 1 // 1-based delivery attempt
{
"id": "evt_000002",
"object": "event",
"type": "payment_intent.succeeded",
"livemode": true,
"created": 1750000000,
"data": {
"object": {
"id": "pi_000001",
"object": "payment_intent",
"status": "succeeded",
"livemode": true,
"amount": { "amount": 1000000, "currency": "NGN", "display": "NGN 10,000.00" },
"metadata": { "order_id": "order-1001" }
}
}
}The event type is in the body (and mirrored in the X-Lodum-Event header); the resource is under data.object and mirrors the REST object, including the metadata you set at creation. Capture is idempotent, so duplicate deliveries are safe — de-dupe on data.object.id (or X-Lodum-Event-Id).
Test vs live. You register one endpoint and it receives both test and live deliveries. Read the top-level livemode boolean (also on data.object.livemode) to tell them apart: test-mode events come from the sandbox rail and move no real money. Don't act on "livemode": false deliveries in production — ignore them or route them to a staging handler.
Events:
payment_intent.created,payment_intent.succeeded,payment_intent.payment_failedpayout.created,payout.paid,payout.failedrefund.pending,refund.completed,refund.rejectedinvoice.created,invoice.paidsubscription.created,subscription.renewed,subscription.past_due,subscription.recovered,subscription.cancel_scheduled,subscription.resumed,subscription.canceledbalance.updated,balance.low,compliance.alert
Retries and dead letters
A delivery that doesn’t return 2xx is retried on a backing-off schedule — 30s, 2m, 10m, 1h, 4h, 12h, 24h, 24h after the first attempt, so 9 attempts over about 2.7 days. Your endpoint isn’t hammered in between, and a deploy, a restart or a certificate renewal recovers on its own with nothing lost.
Anything other than 2xx counts as a failure, and so does a timeout (we wait 15 seconds). Return 2xx as soon as you have durably accepted the event, then do your work — a slow handler turns a delivered event into a retried one. The X-Lodum-Event-Id is identical across retries, so de-duplicate on it; X-Lodum-Attempt tells you which attempt you’re seeing.
Dead letters. When the schedule runs out we stop retrying, but the event isn’t lost: the final attempt is kept with its body as a dead letter. That list is what you work through after an outage.
# what never reached you
GET https://api.harepay.click/v1/webhook_deliveries?status=dead
# replay them, oldest first (up to 50 per call)
POST https://api.harepay.click/v1/webhook_deliveries/replay_dead
-> { "attempted": 50, "replayed": 50, "failed": 0, "remaining": 12 }
# call again until "remaining" is 0 — it also stops early if your
# endpoint is slow, so "attempted" can be less than the page size
# or replay just one
POST https://api.harepay.click/v1/webhook_deliveries/{id}/retryA dead letter is cleared only when a replay of it succeeds, so a replay into an endpoint that is still down leaves your list intact. Dead letters are mode-scoped and are never evicted by newer traffic.
Subscribe to only the events you want
By default an endpoint receives every event — that stays true for every endpoint you have already registered, and you never have to think about it. If you’d rather receive less, pass an events array when you register the endpoint (dashboard → Webhooks → Edit events). Each entry is an exact type (payout.paid), a family wildcard (payout.*, which also picks up new events we add to that family), or * for everything.
POST /v1/webhook_endpoints
{
"url": "https://finance.your-app.com/hooks",
"secret": "whsec_...",
"events": ["payout.*", "refund.*", "balance.low"]
}Filters are per endpoint, so you can point payout.* at your finance service and payment_intent.* at your order service.
- Omitting
eventsmeans everything. On a re-post it also leaves the endpoint’s current filter alone — rotating a secret never silently widens your subscription. Send[]or["*"]to reset it back to every event. - Editing only the filter of an existing URL? You may omit
secret— the endpoint keeps the one it has. - An unknown type or family is rejected with
400rather than quietly delivering nothing, so a typo can’t black-hole your webhooks. GET /v1/webhook_endpointsreturnseventsandall_eventsper endpoint, plus a top-levelavailable_eventscatalogue you can build a picker from.
Verify the signature against the raw request body — the signed string is timestamp.rawBody, HMAC-SHA256, hex-encoded, compared to the sha256= value in X-Lodum-Signature. Reject deliveries whose timestamp is too old to prevent replay:
import crypto from "crypto";
function verify(secret, timestamp, rawBody, signature) {
const mac = crypto.createHmac("sha256", secret);
mac.update(timestamp + "." + rawBody);
const expected = "sha256=" + mac.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// verify(SECRET, headers["x-lodum-timestamp"], rawBody, headers["x-lodum-signature"]);Disputes
List disputes against your payments.
Retrieve a dispute.
Submit evidence while a dispute is open.
Full machine-readable reference: OpenAPI 3.0 spec — generate a client SDK in your language with any OpenAPI generator.