Developer documentation

JasperFly API and webhook reference

A clean REST interface for accepting Mobile Money payments, checking status and receiving signed webhook events. Predictable resources, JSON over HTTPS, Bearer authentication.

Overview

All requests use HTTPS and return JSON. The public API is versioned in the URL path.

Base URL

https://jasperfly.com/api/public/v1

Environments

  • Test — keys prefixed jf_test_. No real money moves and no MoMo prompt is sent to the customer. Test transactions appear in Sandbox and are excluded from live dashboard revenue.
  • Live — keys prefixed jf_live_. Real Mobile Money debits the customer and settles to your balance.

Every resource the API returns includes an env field ("test" or "live") that mirrors the key used to create it. Webhook payloads carry the same field so you can branch on it.

Authentication

Send your API key as a Bearer token on every request. Create and rotate keys from the Developers workspace.

Authorization: Bearer jf_live_1234567890abcdef...
Key hygiene: never commit live keys or expose them in browser code. Rotate immediately if a key leaks - revoke it in the dev console and generate a new one.

Server-side only

The API is a server-to-server surface. We do not return an Access-Control-Allow-Origin header, and any request that arrives with an Origin header plus an API key is rejected with 403 browser_origin_forbidden. Call JasperFly from your backend and proxy the result to your frontend.

Scoped keys

A key can be created with full access (*) or restricted to specific scopes such as collections:write or customers:read. Calling an endpoint outside a key's scopes returns 403 insufficient_scope. Keys can also be given an expiry date; after it passes they return 401 expired_api_key.

IP allowlist

Every key can carry an optional allowlist of caller IP addresses, set per key in the dev console. Entries are single addresses (196.61.32.10) or CIDR ranges (41.66.0.0/16), IPv4 or IPv6. A key with an empty allowlist, which is the default, accepts requests from anywhere; once one address is added, every other source is rejected with 403 ip_not_allowed.

We do not require your server IP to go live. Add one only if your infrastructure has a static egress address and you want the extra containment. If your servers autoscale behind dynamic IPs, leave the allowlist empty and rely on key secrecy, scopes and expiry instead.

OpenAPI specification

The full machine-readable spec is published at /api/public/v1/openapi.json (OpenAPI 3.1). Import it into Postman or Insomnia, or generate a client with your preferred codegen tool.

Test mode

Test mode lets you build and verify your integration end-to-end without touching real money. Every endpoint accepts a jf_test_ key and returns the same shape as live — the only differences are that no MoMo prompt is sent, nothing moves through the ledger, and the resulting transactions surface in Sandbox instead of your live dashboard totals.

Magic test phone numbers

Use these numbers as customer_phone on any test-mode collection to force a specific outcome. The first three resolve inside the same API call — the response already carries the terminal status and the matching payment.* webhook has been dispatched. The expiry number resolves as expired about a minute later; any other test number auto-succeeds shortly after.

Phone numberOutcome
+233200000000Succeeds instantly
+233200000001Fails — insufficient funds
+233200000002Cancelled by customer
+233200000003Expires without response (~60s; applied on the next status read)
Any other numberSucceeds instantly — a test collection never sits in processing

Example: forced success

curl -X POST https://jasperfly.com/api/public/v1/collections \
  -H "Authorization: Bearer jf_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount_cents": 5000,
    "currency": "GHS",
    "customer_phone": "233200000000",
    "network": "MTN",
    "description": "Sandbox smoke test"
  }'

Response:

{
  "ok": true,
  "order_id": "APITEST...",
  "amount_cents": 5000,
  "currency": "GHS",
  "status": "succeeded",
  "env": "test"
}

Simulating outcomes manually

Any pending test transaction in Sandbox exposes "Simulate outcome" controls — succeed, fail, cancel, or expire — so you can drive integration flows that were not started with a magic number.

Webhooks in test mode

Webhook endpoints belong to one environment. An endpoint registered with a jf_test_ key (or created with the Test option in the dev console) receives sandbox events only; a live endpoint receives live events only — payloads never cross the boundary. Each payload still carries "env" so your handler can branch on it. Use the "Send test" button in the dev console to trigger a signed test.ping to a single endpoint without creating a transaction. An existing endpoint can be moved between environments with the test/live toggle on its row in the dev console, or with POST /api/public/v1/webhook-endpoints/{id} and { "env": "test" } — no re-registration needed. A manual "Send test" to a test endpoint is labelled test: true; to a live endpoint it is labelled ping: true, so a payload never claims test data on the live environment. When an event has no active endpoint subscribed in its environment, the skip is recorded in your audit log with the reason.

Simulated test payouts are recorded as test transactions (so they show up in GET /transactions) and dispatch payout.completed to your test endpoints.

Endpoints

GET /me — merchant profile

Return the merchant profile linked to the API key. Useful for verifying credentials.

curl https://jasperfly.com/api/public/v1/me \
  -H "Authorization: Bearer jf_live_..."

Response:

{
  "id": "b91a...",
  "env": "live",
  "business_name": "Akua Bakes",
  "email": "hello@akuabakes.com",
  "phone": "233244123456",
  "kyc_status": "approved",
  "kyc_tier": "tier_2"
}

POST /collections — initiate a collection

Trigger a MoMo prompt on the customer's phone. Returns immediately with an order id; final status is delivered by webhook or by polling.

On the momo rail only amount (or amount_cents) and customer_phone are required. network is detected from the number prefix when omitted, description defaults to "Payment", and when you omit customer_name we look up the wallet's registered name for you.

curl -X POST https://jasperfly.com/api/public/v1/collections \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount_cents": 5000,
    "currency": "GHS",
    "customer_phone": "233244123456",
    "reference": "ORD-1234",
    "metadata": { "channel": "web" }
  }'

Response (201 Created):

{
  "ok": true,
  "order_id": "API17612...",
  "reference": "ORD-1234",
  "amount_cents": 5000,
  "currency": "GHS",
  "network": "MTN",
  "customer_name": "AMA MENSAH",
  "customer_name_source": "wallet_lookup",
  "status": "processing",
  "env": "live",
  "message": "Request submitted"
}

Networks: MTN, AIRTELTIGO, TELECEL (alias VODAFONE). Amount is in the smallest currency unit (pesewas). Currency: GHS. The env field mirrors the key used for the request. customer_name_source is provided, wallet_lookup, or unavailable.

Payment rails: MoMo and card

Add payment_rail to choose how the customer pays. It defaults to momo.

  • momo — we debit the wallet directly. customer_phone is required; network and customer_name are optional.
  • card — we create a hosted authorization page and return authorization_url. Redirect the customer there to enter their card details. customer_email is required; phone and network are not used.
curl -X POST https://jasperfly.com/api/public/v1/collections \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount_cents": 5000,
    "payment_rail": "card",
    "customer_email": "ama@example.com",
    "customer_name": "Ama Mensah",
    "reference": "ORD-1235"
  }'

# 201 Created
{
  "ok": true,
  "order_id": "API17613...",
  "status": "processing",
  "payment_rail": "card",
  "authorization_url": "https://checkout.mojo-pay.com/s/..."
}

Card status still arrives on the same payment.* webhooks and the same GET /collections/{order_id} read. Hosted storefronts and JasperFly payment links remain Mobile Money only for now.

References and idempotency

Every transaction carries two identifiers, and both travel end to end:

  • Our reference - order_id. Generated by JasperFly, immutable, and the key used for provider callbacks and reconciliation on our side.
  • Your reference - reference. Optional, supplied by you on create, stored verbatim, and echoed on every read and every webhook.

A reference must be unique per environment. Reusing one returns 409 duplicate_reference, which protects you from accidentally charging the same order twice.

To retry a request safely after a timeout, send an Idempotency-Key header. If we already created a transaction for that key, we return the original transaction with 200 and "idempotent_replay": true instead of creating a second one.

curl -X POST https://jasperfly.com/api/public/v1/collections \
  -H "Authorization: Bearer jf_live_..." \
  -H "Idempotency-Key: 6f0a1c2e-checkout-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_cents": 5000,
    "customer_phone": "233244123456",
    "network": "MTN",
    "reference": "ORD-1234"
  }'

You can also look a transaction up by your own reference:

curl "https://jasperfly.com/api/public/v1/collections?reference=ORD-1234" \
  -H "Authorization: Bearer jf_live_..."

Payouts accept the same reference field, and both references appear on the Transactions dashboard and in CSV exports.

GET /collections/:orderId — poll status

Return the current status of a collection. Poll every 3–5 seconds until you get a terminal status, or rely on webhooks.

curl https://jasperfly.com/api/public/v1/collections/API17612... \
  -H "Authorization: Bearer jf_live_..."

Response:

{
  "order_id": "API17612...",
  "reference": "ORD-1234",
  "status": "succeeded",
  "status_code": "CL-00-SUCCESSFULLY-PROCESSED",
  "status_message": "Payment successful",
  "amount_cents": 5000,
  "currency": "GHS",
  "net_amount_cents": 4900,
  "env": "live",
  "failure_reason": null
}

Terminal statuses: succeeded, failed, cancelled, expired. Non-terminal: pending, processing.

Payouts API

Send money to Ghana bank accounts and Mobile Money wallets programmatically. Payouts are funded from your JasperFly balance, submitted instantly to the provider, and settled to the recipient. Configure your fee policy (merchant, recipient, or split) in the API Payouts workspace.

GET /balance — merchant balance

Returns available balance, pending payouts, and lifetime totals. Check this before sending a payout.

curl https://jasperfly.com/api/public/v1/balance \
  -H "Authorization: Bearer jf_live_..."

Response:

{
  "available_balance_cents": 125000,
  "lifetime_collected_cents": 480000,
  "lifetime_paid_out_cents": 355000,
  "pending_payout_cents": 0,
  "currency": "GHS"
}

POST /payouts — single automated payout

Debits your balance and submits a payout to the provider immediately. The final outcome arrives via the payout.completed or payout.failed webhook.

# Mobile Money payout
curl -X POST https://jasperfly.com/api/public/v1/payouts \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "momo",
    "amount_cents": 5000,
    "recipient_name": "Ama Mensah",
    "recipient_phone": "233244123456",
    "mobile_network": "MTN",
    "reference": "Salary June 2026"
  }'

# Bank payout
curl -X POST https://jasperfly.com/api/public/v1/payouts \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "bank",
    "amount_cents": 20000,
    "recipient_name": "Kojo Owusu",
    "bank_code": "GCB",
    "bank_name": "GCB Bank",
    "branch_sort_code": "GCB011",
    "account_number": "1234567890",
    "account_title": "Kojo Owusu",
    "reference": "Rent July"
  }'

Response (201 Created):

{
  "ok": true,
  "payout_id": "9c...uuid",
  "order_id": "AP1761...",
  "status": "processing",
  "amount_cents": 5000,
  "provider_txn_id": "MJC-...",
  "message": "Cashout request submitted"
}

If your balance is insufficient, the API returns 402 insufficient_balance with the available amount. If KYC is not approved, 403 kyc_required.

POST /payouts/bulk — bulk (all-or-nothing)

Validates every row upfront and checks total balance before submitting. If any row is invalid or total exceeds balance, the whole batch is rejected before any provider call.

curl -X POST https://jasperfly.com/api/public/v1/payouts/bulk \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "payouts": [
      { "kind": "momo", "amount_cents": 5000, "recipient_name": "Ama", "recipient_phone": "233244111111", "mobile_network": "MTN" },
      { "kind": "momo", "amount_cents": 3000, "recipient_name": "Yaw", "recipient_phone": "233555222222", "mobile_network": "AIRTELTIGO" }
    ]
  }'

Response:

{
  "ok": true,
  "batch_id": "b4...uuid",
  "count": 2,
  "results": [
    { "index": 0, "ok": true, "payout_id": "...", "order_id": "AP...", "status": "processing" },
    { "index": 1, "ok": true, "payout_id": "...", "order_id": "AP...", "status": "processing" }
  ]
}

Customers

Manage the people who buy from you. Creating a customer with an email or phone that already exists updates the existing record (upsert).

POST /customers

curl -X POST https://jasperfly.com/api/public/v1/customers \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Ama Mensah",
    "email": "ama@example.com",
    "phone": "233244123456",
    "metadata": { "vip": true }
  }'

GET /customers · GET /customers/:id

List with ?limit=50&offset=0&search=ama, or fetch by id.

Invoices

Create invoices with line items. Set send: true to immediately mark as sent and expose the hosted URL.

POST /invoices

curl -X POST https://jasperfly.com/api/public/v1/invoices \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "customer_name": "Ama Mensah",
    "customer_email": "ama@example.com",
    "customer_phone": "233244123456",
    "due_date": "2026-08-15",
    "notes": "Thanks for your business!",
    "send": true,
    "items": [
      { "description": "Website design", "quantity": 1, "unit_price_cents": 250000 },
      { "description": "Hosting (12 months)", "quantity": 12, "unit_price_cents": 15000 }
    ]
  }'

Response includes public_url — a hosted invoice page your customer can open and pay.

GET /invoices · GET /invoices/:id_or_slug · POST /invoices/:id/send

List with ?status=sent. Retrieve by UUID or public slug. Send transitions a draft to sent.

Create shareable, hosted payment pages. Three types: one_time (fixed amount or customer-entered), subscription (bound to a plan), and product (checkout for selected products).

POST /payment-links

# Fixed one-time link
curl -X POST https://jasperfly.com/api/public/v1/payment-links \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "June donation drive",
    "type": "one_time",
    "amount_cents": 5000,
    "description": "Support our mission"
  }'

# Customer-entered amount
-d '{ "name": "Tip jar", "type": "one_time", "allow_custom_amount": true, "min_amount_cents": 100 }'

# Subscription (requires an existing plan_id)
-d '{ "name": "Pro plan", "type": "subscription", "plan_id": "..." }'

Response includes public_url. Share that URL directly with customers.

GET /payment-links · GET /payment-links/:id_or_slug

Plans & Subscriptions

Model recurring revenue. A plan defines the price and cadence; a subscription links a customer to a plan.

POST /plans

curl -X POST https://jasperfly.com/api/public/v1/plans \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Pro monthly",
    "amount_cents": 5000,
    "interval": "month",
    "interval_count": 1,
    "trial_days": 7
  }'

Intervals: day, week, month, year.

POST /subscriptions

curl -X POST https://jasperfly.com/api/public/v1/subscriptions \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "plan_id": "...",
    "customer_name": "Ama Mensah",
    "customer_email": "ama@example.com",
    "customer_phone": "233244123456"
  }'

Pass an existing customer_id, or provide customer details and we upsert by email/phone.

GET /subscriptions · GET /subscriptions/:id · POST /subscriptions/:id/cancel

Filter with ?status=active. Cancel is immediate.

Storefronts

Storefronts are created and managed in the dashboard. Use the API to list them and their products, then send buyers to the hosted checkout URL.

GET /storefronts · GET /storefronts/:id_or_slug · GET /storefronts/:id_or_slug/products

curl https://jasperfly.com/api/public/v1/storefronts/akua-bakes/products \
  -H "Authorization: Bearer jf_live_..."

Generate a hosted checkout URL for any of your resources — a payment link, invoice, storefront, or subscription plan (via its subscription payment link).

curl -X POST https://jasperfly.com/api/public/v1/checkout-links \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "resource": "invoice", "id": "inv_abc123" }'

Response: { "data": { "url": "https://jasperfly.com/invoice/inv_abc123" } }

Transactions

One list for reconciliation. Every row carries both JasperFly's order_id and your own reference, plus any metadata you attached, so you can match records without querying each resource separately.

GET /transactions

# Find a payment by your own reference
curl "https://jasperfly.com/api/public/v1/transactions?reference=ORD-2026-0912" \
  -H "Authorization: Bearer jf_live_..."

# Everything that settled in a date window, above GHS 50
curl "https://jasperfly.com/api/public/v1/transactions?type=collection&status=succeeded\
&created_after=2026-07-01T00:00:00Z&amount_min_cents=5000" \
  -H "Authorization: Bearer jf_live_..."

Filters: type (collection | payout), status, order_id, reference, search (fuzzy across order id, reference and customer), created_after, created_before, amount_min_cents, amount_max_cents. Results are scoped to the key's environment.

GET /transactions/verify/:reference

The single reconciliation call. Pass your own reference (or the JasperFly order_id) and we resolve it across collections, hosted checkout sessions and payouts, refresh it against the provider if it is still pending, and return the settled result. Use this after a customer returns from checkout, or from a cron that sweeps anything you never received a webhook for.

curl "https://jasperfly.com/api/public/v1/transactions/verify/ORD-2026-0912" \
  -H "Authorization: Bearer jf_live_..."

{
  "object": "transaction",
  "reference": "ORD-2026-0912",
  "kind": "checkout_hosted",
  "status": "succeeded",
  "amount_cents": 1000,
  "currency": "GHS",
  "net_amount_cents": 1000,
  "provider_snapshot": { "...": "raw provider state" }
}

Hosted checkout

A JasperFly-hosted payment page you can open for any amount, with no resource created up front. This is the only way to charge a card; Mobile Money can either run through it or be pulled server-side with POST /collections.

POST /checkout/sessions

curl -X POST https://jasperfly.com/api/public/v1/checkout/sessions \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 120.00,
    "payment_rail": "card",
    "reference": "ORD-2026-0912",
    "description": "Order 0912",
    "success_url": "https://yourshop.com/thanks",
    "cancel_url": "https://yourshop.com/cart"
  }'

{
  "object": "checkout_session",
  "id": "CS_5C6B9DBB6F",
  "url": "https://...",
  "authorization_url": "https://...",
  "status": "pending",
  "expires_at": "2026-09-12T10:30:00Z"
}

payment_rail: "card" sends the customer straight to the card form. "momo" or omitting the field lets them pick their wallet on the page. Redirect the customer to url; both URLs must be https. When they come back, confirm with GET /transactions/verify/:reference — never trust the return URL alone.

Mandates (recurring debits)

A mandate is a customer's standing authorisation to debit their Mobile Money wallet up to a set amount on a set cycle. Create it once, the customer approves it on their handset, then charge it whenever a cycle falls due — no prompt to accept each time.

POST /mandates

curl -X POST https://jasperfly.com/api/public/v1/mandates \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50.00,
    "customer_phone": "+233200000000",
    "network": "MTN",
    "frequency": "MONTHLY",
    "starts_at": "2026-09-01T00:00:00Z",
    "ends_at": "2027-09-01T00:00:00Z",
    "reference": "SUB-8891"
  }'

frequency is DAILY, WEEKLY or MONTHLY, and ends_at must be after starts_at. The mandate starts pending; you receive mandate.approved or mandate.cancelled by webhook when the customer responds. Pass plan_id (plus customer_name and customer_email) to attach it to one of your billing plans.

POST /mandates/:id/debits

curl -X POST https://jasperfly.com/api/public/v1/mandates/2f7a.../debits \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "amount": 50.00, "reference": "SUB-8891-SEP" }'

Debiting a mandate that is not approved returns 422 mandate_not_active, and an amount above the authorised ceiling returns 422 amount_too_large. GET /mandates lists them, GET /mandates/:id retrieves one, and DELETE /mandates/:id cancels it.

Verifications & metadata

Confirm you are paying the right person before money moves, and pull the live code lists the payout endpoints expect.

POST /verifications/mobile · POST /verifications/bank-account

curl -X POST https://jasperfly.com/api/public/v1/verifications/mobile \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "mobile": "+233200000000", "network": "MTN" }'

{ "object": "verification", "channel": "mobile", "verified": true, "account_name": "AMA MENSAH" }

curl -X POST https://jasperfly.com/api/public/v1/verifications/bank-account \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "account_number": "1441000123456", "bank_code": "300304" }'

A lookup that the provider cannot resolve returns verified: false with a null account_name — it is not an error. Bank lookups also accept bank_name and we match it to a code where we can.

GET /metadata/mobile-providers · GET /metadata/banks

curl https://jasperfly.com/api/public/v1/metadata/banks \
  -H "Authorization: Bearer jf_live_..."

{ "object": "list", "data": [{ "bank_code": "300304", "bank_name": "GCB BANK" }], "has_more": false }

Use these values verbatim for bank_code and mobile_network on payouts. Cache them for a day rather than calling on every payout.

Refunds

Refunds send money back to the customer's Mobile Money wallet and are funded from your JasperFly balance, not from the original payment. Partial refunds are supported; the total refunded can never exceed the original amount.

POST /refunds

curl -X POST https://jasperfly.com/api/public/v1/refunds \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "API1786000000001",
    "amount": 25.00,
    "reason": "Item out of stock"
  }'

Identify the payment in whichever way you have it: order_id (returned by every collection), your own reference, or source_id — the internal id on the collection response — together with source_type (instant_charge, payment_link, invoice, order, subscription_invoice). We reuse the customer's number from the original payment; send recipient_phone to override it. Failure cases: 422 insufficient_balance when your balance does not cover it, 422 amount_too_large when it exceeds the refundable remainder, and 502 provider_error when the provider rejects it (no funds move).

In test mode refunds settle immediately without contacting the provider, so you can drive the refund.processed webhook end to end.

GET /refunds · GET /refunds/:id_or_order_id

Filter the list with ?status= or ?source_id=. Retrieve accepts either the refund id or its JasperFly order id.

Coupons

Coupons discount a checkout at redemption time. The storefront, payment links and invoices validate them automatically; the API manages the catalog.

POST /coupons

curl -X POST https://jasperfly.com/api/public/v1/coupons \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "code": "LAUNCH10",
    "type": "percent",
    "value": 10,
    "min_subtotal": 50.00,
    "applies_to": ["storefront", "payment_link"],
    "max_redemptions": 500,
    "valid_until": "2026-12-31T23:59:59Z"
  }'

type: "percent" takes a value between 1 and 100. type: "fixed" takes pesewas. Codes are stored uppercase and must be unique per business; a duplicate returns 409 resource_conflict.

GET /coupons/:id_or_code · POST /coupons/:id_or_code · DELETE /coupons/:id_or_code

Update accepts name, status, max_redemptions, min_subtotal_cents and valid_until. Deleting a coupon pauses it rather than removing it, so redemption history stays intact.

Splits

A split group describes how the proceeds of a payment are shared between recipients. Create the group once, then attach it to any collection with split_group_id; the shares are materialised when the payment settles.

POST /splits

curl -X POST https://jasperfly.com/api/public/v1/splits \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Marketplace payout",
    "recipients": [
      { "label": "Vendor", "type": "momo", "bank_or_provider": "MTN",
        "account_number": "0244123456", "share_type": "percent", "share_value": 85 },
      { "label": "Logistics", "type": "bank", "bank_or_provider": "GCB",
        "account_number": "1234567890", "share_type": "fixed", "share_value": 500 }
    ]
  }'

Percent shares across a group may not exceed 100%. Fixed shares are in pesewas. Whatever is left after all shares stays with you.

Attaching a split to a payment

curl -X POST https://jasperfly.com/api/public/v1/collections \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "amount": 100.00, "phone": "0244123456", "network": "MTN",
        "split_group_id": "2f7a..." }'

A split group belonging to another account returns 422 validation_failed.

Webhooks

JasperFly POSTs a signed JSON event to your registered endpoints when things happen. Add and manage endpoints in the Developers workspace.

Event payload

POST https://your-app.com/webhooks/jasperfly
X-JasperFly-Event: payment.succeeded
X-JasperFly-Timestamp: 1761234567
X-JasperFly-Signature: t=1761234567,v1=6a3f...b8

{
  "id": "evt_9c2...",
  "type": "payment.succeeded",
  "created_at": "2026-07-21T22:35:47.000Z",
  "data": {
    "order_id": "API1761...",
    "status": "succeeded",
    "amount_cents": 5000,
    "currency": "GHS",
    "provider_txn_id": "MJP-...",
    "net_amount_cents": 4900,
    "source_type": "payment_link"
  }
}

Verify the signature

The signature covers timestamp + "." + rawBody using HMAC-SHA256 with your endpoint's signing secret. Compare with a timing-safe function and reject requests older than 5 minutes.

import crypto from "node:crypto";

function verify(rawBody, headerValue, secret) {
  const parts = Object.fromEntries(headerValue.split(",").map(p => p.split("=")));
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5 min tolerance
  const expected = crypto.createHmac("sha256", secret)
    .update(`${t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Events

EventWhen
payment.succeededMoMo collection or payment link paid successfully.
payment.failedCollection failed, cancelled or expired.
invoice.paidAn invoice was fully paid.
order.paidA storefront order was paid.
payout.completedA payout to a bank/wallet completed.
payout.failedA payout failed or was rejected.
subscription.createdA new subscription started from a payment link.
subscription.cancelledA subscription was cancelled.
customer.createdA customer record was created.
refund.processedA refund was completed.
coupon.redeemedA discount coupon was applied to a checkout.
split.executedA payment's split shares were materialised.
test.pingSent when you click 'Send test' in the dev console.

Retries

A delivery is successful when your endpoint returns a 2xx status within 10 seconds. Non-2xx and network errors mark the delivery failed and schedule an automatic retry with exponential backoff (5, 15, 45 minutes). You can also retry manually from the delivery log.

Managing endpoints from the API

Endpoints can be registered from your own deploy pipeline instead of the dashboard. The signing secret is returned once, in the creation response.

curl -X POST https://jasperfly.com/api/public/v1/webhook-endpoints \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/jasperfly",
    "events": ["payment.succeeded", "payment.failed", "refund.processed"]
  }'

GET /webhook-endpoints lists them, POST /webhook-endpoints/:id updates one, and DELETE /webhook-endpoints/:id removes it. URLs must be https.

Rotating a secret without downtime

curl -X POST https://jasperfly.com/api/public/v1/webhook-endpoints/2f7a... \
  -H "Authorization: Bearer jf_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "rotate_secret": true }'

The response contains the new secret. The previous secret keeps verifying signatures for 24 hours, so you can deploy the new value to your receivers before the old one stops working.

Security model

JasperFly is multi-tenant. Every request is resolved to exactly one business and one environment before any data is touched. This section documents how that resolution works, so you can reason about what a key or a teammate can reach.

Keys, tenancy and environment

  • An API key is stored only as a SHA-256 hash. The plaintext is shown once at creation and never again.
  • Each key is bound to one business and one environment. The environment is derived from the key prefix, never from a request parameter, so a test key cannot create live activity and a live key cannot write into the Sandbox.
  • Every resource the API returns carries an env field matching the key used.
  • Requests for resources that belong to another business return 404, not 403, so ids cannot be probed.

Team capabilities

Dashboard and server actions are gated by capabilities, not raw roles. Base roles grant a default set, and an owner or admin can grant extra capabilities per teammate.

RoleDefault reach
ownerEverything, including team, settlement and business profile.
adminEverything except transferring ownership. Can manage API keys and webhooks.
financePayouts, refunds, invoices, balances and exports.
developerAPI keys, webhook endpoints, Sandbox and logs.
viewerRead-only access to transactions and reports.

SECURITY DEFINER RPCs

A small set of database functions run with elevated privileges so they can enforce rules that row-level security alone cannot express. These bypass RLS by design and each one performs its own authorization check in the function body. Anything not listed here is unavailable to anonymous and to ordinary authenticated callers.

FunctionCallable byCheck performed inside
get_public_storefrontanonOnly returns a storefront whose status is published (or a preview for its owner).
get_public_invoiceanonResolves by unguessable public slug; drafts and voided invoices return nothing.
get_public_payment_linkanonResolves by public slug and requires an active link.
get_public_instant_chargeanonResolves by public slug; returns only the amount and status needed to pay.
get_public_checkout_statusanonReturns status for a known order id only; never exposes customer records.
prepare_hosted_checkoutservice_roleCalled server-side after the slug and amount have been validated.
create_storefront_orderservice_roleRecomputes totals from stored prices; client amounts are ignored.
has_role / has_capabilityauthenticatedReads roles and capabilities for auth.uid() only.
current_business_id / current_business_role / current_business_capabilitiesauthenticatedMaps auth.uid() to its owned or member business; used by RLS policies.
create_invoice / create_instant_charge / create_refundauthenticatedWrites only into the caller's business as resolved from auth.uid().
get_merchant_balanceauthenticatedRejects a business id the caller is not a member of.
activate_my_membershipsauthenticatedActivates only memberships matching the caller's own email.
asset_publish_decisionservice_roleBackend-only helper behind the audited asset proxy.

Asset visibility

  • Images belonging to a published storefront (logo, cover, product photos) are publicly viewable and served from /api/public/assets/<path>.
  • Draft storefront assets, digital product files and KYC documents are never public. They require a short-lived signed URL issued to an authorized session.
  • Every request through the asset proxy is recorded with its decision (allowed or denied) and reason, so unexpected access attempts are auditable.

Amounts & currency

All amounts are Ghana Cedis (GHS). Internally we store and settle in pesewas, the minor unit, so nothing is ever rounded. You can send either unit.

FieldUnitExample for GHS 1.50
amount_centsPesewas, integer150
amountCedis, up to 2 decimals1.50

The same pairing applies to every other minor-unit field: unit_price for unit_price_cents, min_amount for min_amount_cents, and so on, including inside invoice line items and bulk payout rows. Responses always return both forms, so you never have to convert.

# these two requests are identical
-d '{ "amount": 1.50, "phone": "+233200000000" }'
-d '{ "amount_cents": 150, "phone": "+233200000000" }'

# response echoes both
{ "amount_cents": 150, "amount": 1.5, "currency": "GHS" }
  • Send one or the other. Sending both is accepted only when they agree exactly, otherwise you get 400 validation_failed.
  • More than 2 decimal places is an error rather than a silent round, so no one loses a pesewa quietly.
  • The smallest chargeable collection amount is GHS 0.10 (10 pesewas) — this applies to collections, hosted checkout sessions and mandate debits. Below that the request is rejected with 400 validation_failed. Payouts still start at GHS 1.00. Note that Mobile Money providers may enforce their own higher floor on some wallets; when they do we surface the provider's rejection message.
  • Amounts inside metadata are never converted; that object is stored verbatim.

References, idempotency & metadata

Every money movement carries two identities: ours and yours. Keeping both means either side can reconcile from either system without a lookup table.

FieldSet byUniqueWhat it does
order_idJasperFlyGlobally, foreverThe canonical id for the transaction. Returned on create, echoed on every webhook, and the value to use in GET /v1/collections/{order_id}. Never reused, including across test and live.
referenceYouNot enforcedYour own order or invoice number. Stored as merchant_reference, returned as reference, searchable with GET /v1/collections?reference=ORD-1234, shown in the dashboard and in CSV exports. Optional, but send it: reusing one is allowed, so keep it unique on your side if you want clean reconciliation.
Idempotency-KeyYou (header)Per key, 24 hoursDeduplicates retries. Same key + same body replays the original response with idempotent_replay: true. Same key + different body returns 409 idempotency_key_reuse. It is not a reference and is never shown to your customers.
metadataYoun/aUp to 20 string key/value pairs (keys 40 chars, values 500 chars). Stored verbatim, echoed on the API response and inside webhook payloads, and included in dashboard CSV exports. Do not put card data, passwords or personal data you would not want in a log.

The metadata contract

metadata is a flat JSON object you define. There is no fixed schema: the keys are yours. The only rules are the shape limits below, which exist so the object stays cheap to index, log and export.

  • Flat object only. Nested objects and arrays are rejected: serialise them to a string first.
  • At most 20 keys. Keys up to 40 characters, values up to 500 characters.
  • Values are strings. Numbers and booleans are coerced to their string form on read.
  • Stored verbatim, echoed on the create response, on GET reads, inside webhook payloads and in dashboard CSV exports.
  • Never send card data, PINs, passwords or personal data you would not want in a log.
"metadata": {
  "cart_id": "c_889",
  "channel": "web",
  "staff_id": "42"
}

Creating with all three

curl https://jasperfly.com/api/public/v1/collections \
  -H "Authorization: Bearer $JF_SECRET_KEY" \
  -H "Idempotency-Key: 6f0a1c2e-checkout-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 25,
    "phone": "+233200000000",
    "reference": "ORD-1234",
    "metadata": { "channel": "web", "cart_id": "c_889" }
  }'
{
  "order_id": "JF-8Q2K4M1P",
  "reference": "ORD-1234",
  "metadata": { "channel": "web", "cart_id": "c_889" },
  "status": "pending",
  "amount_cents": 2500,
  "currency": "GHS"
}

Looking a transaction back up

# by our id
GET /api/public/v1/collections/JF-8Q2K4M1P

# by your reference
GET /api/public/v1/collections?reference=ORD-1234

On the webhook

{
  "type": "payment.succeeded",
  "data": {
    "order_id": "JF-8Q2K4M1P",
    "reference": "ORD-1234",
    "metadata": { "channel": "web", "cart_id": "c_889" },
    "status": "succeeded",
    "amount_cents": 2500
  }
}

Test vs live

The three fields behave identically in both environments: a test key produces a real order_id, stores your reference and metadata, and fires webhooks, but moves no money. Test and live records are stored separately and never collide, so the same reference or Idempotency-Key can be used in each. Filter by environment in the dashboard, and treat a test order_id as invalid against a live key.

Rate limits & idempotency

Rate limits

Requests are counted in a fixed one-minute window, per API key, per endpoint. The default allowance is 120 requests per minute, with tighter limits on money movement: 60/min for POST /collections, 30/min for POST /payouts and 10/min for POST /payouts/bulk. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. When you exceed the limit you get 429 rate_limited with a Retry-After header in seconds.

Idempotency

Every POST endpoint accepts an Idempotency-Key header. Send a unique value (a UUID works well) per logical operation and retry safely: the original response is replayed with idempotent_replay: true instead of creating a second charge or payout. Reusing a key with a different request body returns 409 idempotency_key_reuse. Keys are retained for 24 hours.

curl https://jasperfly.com/api/public/v1/collections \
  -H "Authorization: Bearer $JF_SECRET_KEY" \
  -H "Idempotency-Key: 3f1b0c2e-9a44-4f7a-8f0e-1d2c3b4a5e6f" \
  -H "Content-Type: application/json" \
  -d '{"amount": 25, "phone": "+233200000000", "reference": "ORD-1234"}'

Request ids

Every response includes an X-Request-Id header, and every error body repeats it as request_id. Your last 100 API calls, with their request ids, statuses and latencies, are listed in the Developers workspace. Quote a request id when contacting support.

Pagination

List endpoints accept limit (1-200, default 50) and offset, and return a list envelope so you always know whether to keep paging.

{
  "object": "list",
  "data": [ ... ],
  "has_more": true,
  "next_cursor": "b3c1...",
  "limit": 50
}

Errors

Errors use one consistent envelope across every endpoint. type tells you the broad category, code is the stable value to branch on, and message is safe to log but not to show to your customers verbatim.

{
  "error": {
    "type": "invalid_request_error",
    "code": "duplicate_reference",
    "message": "A transaction with this reference already exists in this environment.",
    "doc_url": "https://jasperfly.com/developers/docs#error-duplicate_reference"
  },
  "request_id": "req_9f2c41a7c0d84be1a5f3"
}

HTTP status codes

This is the complete set of statuses the API can return. Anything else means the request never reached us.

StatusWhenSafe to retry?
200 OKRead succeeded, or a write replayed an earlier idempotent response.Yes
201 CreatedA collection, payout or other resource was created.Only with the same Idempotency-Key
204 No ContentPreflight OPTIONS response.Yes
400 Bad RequestMalformed JSON, or a field failed validation.No, fix the request first
401 UnauthorizedMissing, malformed, unknown, revoked or expired API key.No, fix the key first
402 Payment RequiredInsufficient settlement balance for a payout.Yes, after funding the balance
403 ForbiddenScope missing, IP not allowlisted, browser origin, or KYC not approved.No
404 Not FoundUnknown endpoint, or a resource that is not yours.No
409 ConflictDuplicate reference, or an Idempotency-Key reused with a different body.No, change the key or reference
422 Unprocessable EntityWell-formed request that the resource state does not allow, such as refunding a failed payment.No
429 Too Many RequestsRate limit exceeded. Honour the Retry-After header.Yes, after the delay
500 Internal Server ErrorUnexpected failure on our side. Quote the request_id to support.Yes, with the same Idempotency-Key
502 Bad GatewayThe Mobile Money provider rejected or failed the upstream call.Yes, with the same Idempotency-Key
503 Service UnavailableA capability is temporarily disabled, for example payouts during maintenance.Yes, later

Error codes

HTTPErrorMeaning
400bad_requestInput failed validation. See the message field.
400validation_failedA specific field is invalid. See the param field.
401missing_api_keyNo Authorization header was sent.
401malformed_api_keyKey does not match the jf_(test|live)_ format.
401invalid_api_keyKey not found.
401revoked_api_keyKey has been revoked in the dev console.
401expired_api_keyKey passed its expiry date.
403browser_origin_forbiddenA secret key was used from a browser.
403insufficient_scopeThe key lacks the scope this endpoint needs.
403ip_not_allowedThe calling IP is not on this key's allowlist.
404resource_missingResource does not exist or belongs to another business.
409duplicate_referenceThat merchant reference is already used in this environment.
409idempotency_key_reuseIdempotency-Key reused with a different request body.
429rate_limitedRate limit exceeded. Retry after the Retry-After period.
500server_errorUnexpected failure on our side. Retry with the same request id to hand to support.
502provider_errorUpstream provider rejected the request.

Implementation guide

  1. Create test keys. Open the dev console and generate a jf_test_ key. Store it as an environment variable — never in source control.
  2. Watch traffic in Sandbox. Open Sandbox to see every test request land in real time, then use the magic phone numbers to exercise the failure paths before switching to jf_live_.
  3. Register a webhook endpoint. Add your HTTPS URL and pick the events you care about (start with payment.succeeded and payment.failed). Copy the signing secret to your server env.
  4. Send a test event. Click "Send test" on your endpoint. Confirm you receive a signed test.ping and verify the signature.
  5. Initiate a test collection. POST to /collections. Save the returned order_id.
  6. Handle the outcome twice. Poll /collections/:orderId for immediate UI updates and handle the webhook event server-side as the source of truth. Idempotently update your database using order_id as the unique key.
  7. Switch to live. Generate a jf_live_ key, swap it in production, and rerun a real GHS 1.00 transaction end to end. Confirm the webhook fires and the deduction appears in the customer's wallet.

Need help integrating?

Our team can pair with your engineers to ship your first live transaction. Reach out and we'll get you unblocked.