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.

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
Node (SDK)
Python
PHP
Go
Ruby
Java
C#
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.

Create payment
List payments
Get NGN balance
Create invoice
List disputes

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).

TypeScript
Python
Go
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": "..." } }

Payments

POST/v1/payment_intents

Create a payment intent. Returns a next_action for the payer.

GET/v1/payment_intents

List payment intents.

GET/v1/payment_intents/{id}

Retrieve a payment intent.

POST/v1/payment_intents/{id}/refund

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.

POST/v1/checkout_sessions

Create a session — amount, optional methods, description, success_url, metadata. Returns the hosted url.

GET/v1/checkout_sessions

List your sessions.

POST/v1/checkout_sessions/{id}/cancel

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.

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

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.

POST/v1/payme/ledgers

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.

GET/v1/banks

List supported banks with their code (NIP) and name, for a bank picker.

POST/v1/bank/resolve

Resolve { bank_code, account_no } { account_name } via live NIP name-enquiry.

POST/v1/payouts

Disburse to a bank account or crypto wallet. 422 if available balance is insufficient.

GET/v1/payouts

List payouts.

GET/v1/payouts/{id}

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}.

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.

POST/v1/verifications

Run a check: type is bvn / nin / cac / drivers_license.

GET/v1/verifications

Your check history.

GET/v1/verification_options

Supported types, your fees, and each type’s required_fields / optional_fields.

What each check needs
  • Person (bvn, nin, drivers_license) — id_number plus firstname and lastname. 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, email and gender are 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

GET/v1/balances/{asset}

Available, pending, and reserved balance for an asset (e.g. /v1/balances/NGN).

Putting money in

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

POST/v1/invoices

Create an invoice with line items.

GET/v1/invoices

List invoices.

GET/v1/invoices/{id}

Retrieve an invoice.

POST/v1/invoices/{id}/pay

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.

POST/v1/prices

Create a price (recurring or usage). Recurring prices take trial_days for a free trial.

GET/v1/prices

List prices.

POST/v1/subscriptions

Start a subscription on a price.

GET/v1/subscriptions

List subscriptions. Filter for entitlement checks: ?customer=cus_…&status=active.

GET/v1/subscriptions/{id}

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.

POST/v1/subscriptions/{id}/usage

Report metered usage (PAYG).

POST/v1/subscriptions/{id}/cancel

Cancel — at period end by default ({"at_period_end": false} revokes now).

POST/v1/subscriptions/{id}/resume

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-debitsaved 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):

POST/v1/customers/{id}/prepaid/topup

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
X-Lodum-Timestamp: 1750000000
X-Lodum-Signature: sha256=<hex>

{
  "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_failed
  • payout.created, payout.paid, payout.failed
  • refund.pending, refund.completed, refund.rejected
  • invoice.created, invoice.paid
  • subscription.created, subscription.renewed, subscription.past_due, subscription.recovered, subscription.cancel_scheduled, subscription.resumed, subscription.canceled
  • balance.updated, balance.low, compliance.alert

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 events means 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 400 rather than quietly delivering nothing, so a typo can’t black-hole your webhooks.
  • GET /v1/webhook_endpoints returns events and all_events per endpoint, plus a top-level available_events catalogue 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:

Node
Python
PHP
Go
Ruby
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

GET/v1/disputes

List disputes against your payments.

GET/v1/disputes/{id}

Retrieve a dispute.

POST/v1/disputes/{id}/evidence

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.