Developers
Built the way you'd build it.
Prefixed ids, minor-unit amounts, Idempotency-Key on every money call, timestamped signed webhooks,
a date-versioned API, cursor pagination and sandbox simulators for every rail, including offline.
Already have an account? Sign in and find your test keys under Developers.
import PediWave from "@pediwave/node";
const pediwave = new PediWave(process.env.PEDIWAVE_SECRET_KEY);
const intent = await pediwave.paymentIntents.create({
amount: 250000, currency: "NGN", reference: "ORD-1001",
payment_method_types: ["bank_transfer", "qr", "offline"],
}, { idempotencyKey: "ORD-1001-1" }); import os, pediwave
client = pediwave.PediWave(os.environ["PEDIWAVE_SECRET_KEY"])
intent = client.payment_intents.create(
amount=250000, currency="NGN", reference="ORD-1001",
payment_method_types=["bank_transfer", "qr", "offline"],
idempotency_key="ORD-1001-1") <?php
$pediwave = new \PediWave\PediWaveClient(getenv('PEDIWAVE_SECRET_KEY'));
$intent = $pediwave->paymentIntents->create([
'amount' => 250000, 'currency' => 'NGN', 'reference' => 'ORD-1001',
'payment_method_types' => ['bank_transfer', 'qr', 'offline'],
], ['idempotency_key' => 'ORD-1001-1']); client := pediwave.NewClient(os.Getenv("PEDIWAVE_SECRET_KEY"))
intent, err := client.PaymentIntents.Create(ctx, &pediwave.PaymentIntentParams{
Amount: 250000, Currency: "NGN", Reference: "ORD-1001",
PaymentMethodTypes: []string{"bank_transfer", "qr", "offline"},
}, pediwave.WithIdempotencyKey("ORD-1001-1"))
if err != nil { return err } curl https://api.pediwave.com/v1/payment/intents \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: ORD-1001-1" \
-H "PediWave-Version: 2026-10-01" \
-H "Content-Type: application/json" \
-d '{"amount":250000,"currency":"NGN","reference":"ORD-1001",
"payment_method_types":["bank_transfer","qr","offline"]}' Response · 201 Created
{
"id": "pi_01J9ZK3M7Q8R1S2T3U4V5W6X7Y",
"object": "payment_intent",
"amount": 250000,
"currency": "NGN",
"status": "requires_payment_method",
"reference": "ORD-1001",
"payment_method_types": ["bank_transfer", "qr", "offline"],
"client_secret": "pi_01J9ZK3M7Q8R1S2T3U4V5W6X7Y_secret_…",
"livemode": false,
"created": "2026-10-08T10:15:30Z"
} Principles
Five things we decided so you don't have to.
Payments fail in untidy ways: dropped connections, slow banks, retries that arrive twice. These rules hold on every endpoint, so your code can be simple and still be safe.
Idempotency
Send an Idempotency-Key on every call that moves money. We claim the key before doing any work, store the answer for 24 hours and replay it for the same key and body. The same key with a different body is refused, and a second request while the first is still running gets 409, so a retry can never create a second payment.
POST /v1/payment/intents Idempotency-Key: ORD-1001-1
→ 201 Created
POST /v1/payment/intents Idempotency-Key: ORD-1001-1 (same body)
→ 201 Created Idempotency-Replayed: true
POST /v1/payment/intents Idempotency-Key: ORD-1001-1 (amount changed)
→ 409 idempotency_error key_reused_with_different_body Webhooks
Every delivery is signed with HMAC-SHA256 over a timestamp and the raw body. Rotate a secret and we sign with both for the grace window. Failed deliveries retry eight times over a day, then wait in a dead-letter queue for you to replay.
PediWave-Event-Id: evt_01J9ZK4B2C3D4E5F6G7H8J9K0M
PediWave-Event-Type: payment_intent.succeeded
PediWave-Signature: t=1759918530,v1=5257a869e7…
Retries: 30s 2m 10m 30m 2h 6h 12h 24h Versioning
Your key is pinned to the API version that was current when you created it. New fields and new enum values arrive without a version change; anything that would break you ships as a new dated version with a changelog. Override per request with one header.
PediWave-Version: 2026-10-01
# Additive changes: no new version.
# Breaking changes: a new date, a changelog entry, your choice when to move. Errors
One error object everywhere, with a machine-readable type and code, a decline_code for card declines, the parameter at fault, a request_id to quote to support and a link to the fix.
{"error": {
"type": "card_error", "code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"request_id": "req_01J9ZK5N6P7Q8R9S0T1V2W3X4Y",
"doc_url": "https://docs.pediwave.com/errors#card_declined"}} Unknown is a state
When a bank or provider does not answer, the payment is not failed. It stays processing, we ask the provider what happened before trying anywhere else, and you get an event when the answer arrives. A customer is never charged twice because a network was slow.
{"type": "payment_intent.processing",
"data": {"object": {"id": "pi_01J9ZK3M7Q8R1S2T3U4V5W6X7Y",
"status": "processing"}},
"reason": "awaiting_connector"}
// Then: payment_intent.succeeded, or a cascade only after the enquiry says no debit.
Your code should treat any status value it does not recognise as "in progress". Enum values marked
extensible may grow in a minor release.
SDKs
A library for your stack.
Typed server libraries generated from the same contract as the API reference, so a field in the docs is a field in your editor. Client libraries keep card data and PINs out of your app.
-
Node
Server library for Node.js and TypeScript.
npm i @pediwave/nodeComing, not yet published Get notified -
Python
Server library for Python 3.
pip install pediwaveComing, not yet published Get notified -
PHP
Server library for PHP and Laravel.
composer require pediwave/pediwave-phpComing, not yet published Get notified -
Java
Server library for Java and Kotlin.
implementation("com.pediwave:pediwave-java")Coming, not yet published Get notified -
Go
Server library for Go.
go get github.com/pediwave/pediwave-goComing, not yet published Get notified -
.NET
Server library for C# and .NET.
dotnet add package PediWaveComing, not yet published Get notified -
Flutter
Client calls for Flutter apps, with publishable keys only.
flutter pub add pediwaveComing, not yet published Get notified -
PediWave.js
Hosted card fields and embedded Checkout for the web. Card numbers go to our tokeniser, never your server.
<script src="https://js.pediwave.com/v1/pediwave.js"></script>Coming, not yet published Get notified -
iOS
Native Checkout sheet for SwiftUI and UIKit, with 3D Secure in the app.
.package(url: "https://github.com/pediwave/pediwave-ios")Coming, not yet published Get notified -
Android
Native Checkout sheet for Jetpack Compose and Views, with 3D Secure in the app.
implementation("com.pediwave:pediwave-android")Coming, not yet published Get notified -
Offline SDK
Offline taps in two modes: terminal mode for your POS or till, payer mode for wallets inside your own app.
implementation("com.pediwave:pediwave-offline")Coming, not yet published Get notified -
Terminal SDK
Online POS and agent screens, receipts and slips, with automatic handover to offline.
implementation("com.pediwave:pediwave-terminal")Coming, not yet published Get notified
No library for your language? The API is plain HTTPS and JSON. Every example in the reference has a cURL tab.
Sandbox test cards: 4084 0840 8408 4081 succeeds, 4082 asks for 3D Secure, 4083 is declined for insufficient funds, 4084 cascades to a second provider and succeeds, 4085 times out then succeeds on enquiry. A result card shows a ₦2,500 test payment declined by the first simulator and approved by the second.
| Card number | Outcome |
|---|---|
4084 0840 8408 4081 | Succeeds |
4084 0840 8408 4082 | Asks for 3D Secure, then succeeds |
4084 0840 8408 4083 | Declined: insufficient funds |
4084 0840 8408 4084 | Do not honour on the first provider, cascades to the second and succeeds |
4084 0840 8408 4085 | Times out, then succeeds on enquiry |
4084 0840 8408 4086 | Provider fault |
4084 0840 8408 4087 | Declines once for insufficient funds, then approves (for retry testing) |
5061 … (Verve) | Asks for PIN, then OTP 123456 |
| Call | What it does |
|---|---|
POST /v1/test/simulate/bank/transfer | Credits the dynamic account for a payment intent |
POST /v1/test/simulate/ussd/payment | Completes or fails a USSD payment |
POST /v1/test/simulate/mobile/money/prompt | Approves or rejects a mobile money prompt |
POST /v1/test/simulate/offline/tap | Builds a dual-signed tap: ok, double_spend or bad_signature |
POST /v1/test/simulate/dispute | Opens a dispute on a payment intent |
POST /v1/test/simulate/connector | Sets a provider healthy, degraded or down for a set time |
POST /v1/test/simulate/voucher | Issues a redeemable test voucher |
POST /v1/test/clocks | Creates a test clock to advance subscriptions |
Any future expiry date and any three-digit CVC. Cards work in the sandbox today; live card payments follow our PCI DSS assessment.
pi_01J9ZK3M7Q8R1S2T3U4V5W6X7Y · ₦2,500.00
- Attempt 1 ·
con_sim_a· do_not_honorDeclined - Attempt 2 ·
con_sim_b· approvedSucceeded
Status: succeeded · 2 attempts
Sandbox
Test every outcome before it happens for real.
Test keys run the same code paths as live keys, against simulators instead of banks and providers. Card numbers trigger each outcome, including a decline that cascades to a second provider and a timeout that resolves on enquiry. Simulators credit a transfer, complete a USSD or mobile money payment, open a dispute, take a provider down, and build a valid offline tap, or a double-spent one, so you can see how your code handles it.
Test clocks move a subscription through months in seconds, so you can watch renewals, failed payments and retries without waiting.
Sandbox guideContent-Type: application/json
PediWave-Event-Id: evt_01J9ZK4B2C3D4E5F6G7H8J9K0M
PediWave-Event-Type: payment_intent.succeeded
PediWave-Delivery-Id: dlv_01J9ZK4C3D4E5F6G7H8J9K0M1N
PediWave-Delivery-Attempt: 1
PediWave-Timestamp: 1759918530
PediWave-Signature: t=1759918530,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{"id":"evt_01J9ZK4B2C3D4E5F6G7H8J9K0M","object":"event",
"type":"payment_intent.succeeded","api_version":"2026-10-01",
"data":{"object":{"id":"pi_01J9ZK3M7Q8R1S2T3U4V5W6X7Y","status":"succeeded",…},
"previous_attributes":{"status":"processing"}}} import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const parts = header.split(",").map((p) => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return parts.some(([k, v]) => k === "v1" && v.length === expected.length &&
timingSafeEqual(Buffer.from(v), Buffer.from(expected)));
} import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in parts if k == "t"), None)
if t is None or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in parts) app.post("/webhooks/pediwave", express.raw({ type: "application/json" }), (req, res) => {
const event = pediwave.webhooks.constructEvent(
req.body, req.get("PediWave-Signature"), process.env.PEDIWAVE_WEBHOOK_SECRET);
if (event.type === "payment_intent.succeeded") fulfil(event.data.object.reference);
res.sendStatus(200);
}); v1 values arrive while a secret is rotating. Accept the delivery if either matches, and always verify
the raw body before parsing it.
Webhooks
Know it happened. Prove we sent it.
Every event is signed with your endpoint's secret: an HMAC-SHA256 of the timestamp and the raw body, checked within
five minutes. Rotate the secret and both signatures arrive during the grace window, so you can roll without
downtime. Deliveries are ordered for each object; deduplicate on PediWave-Event-Id.
- Retries at 30 s, 2 min, 10 min, 30 min, 2 h, 6 h, 12 h and 24 h, then a dead-letter queue.
- Replay any event from the last 30 days from the dashboard or
POST /v1/events/{id}/replay. - Send a test event to your endpoint with
POST /v1/webhook/endpoints/{id}/test. - Choose a full snapshot of the object or a thin event with just its id. Coming, not yet available
Prefer a queue? Send events straight to Kafka, Amazon SQS or Google Pub/Sub. Coming, not yet available
Webhooks guideKeys and limits
The right key in the right place.
Test and live keys never mix, and every key carries its environment in its prefix. Give each integration only the scopes it needs, and roll a key without a minute of downtime.
Keys
| Prefix | Key | What it can do | Where it lives |
|---|---|---|---|
sk_test_ · sk_live_ | Secret | Everything your account may do | Your server only |
rk_test_ · rk_live_ | Restricted | Only the scopes you choose, such as payment_intents:write or refunds:read | Your server, one per integration |
pk_test_ · pk_live_ | Publishable | Create a card token from hosted fields, and read or confirm one payment with its client secret | Browsers and mobile apps |
tk_ | Terminal | Offline enrolment and batch upload for one terminal | Inside the terminal's secure storage |
cst_ | Customer session | Wallet and offline calls for one customer on one device, for up to 24 hours | Inside your app, held by the Offline SDK |
- A key's secret is shown once, when you create or roll it. We never show it again.
- Rolling a key creates its replacement and keeps the old one working for up to 72 hours, so you can deploy without downtime. Revoking takes effect at once.
- Add an IP allowlist to any key. Calls from other addresses are refused with
403 permission_error, and calls outside a restricted key's scopes are refused naming the missing scope. - Keys can expire on a date you set; we send
api_key.expiring14 days and 3 days before. - Card numbers are refused on every endpoint except the tokeniser, which accepts only a publishable key, so raw card data cannot reach your server by accident.
Rate limits (per key)
| Calls | Limit |
|---|---|
| Reads | 100 requests a second sustained, bursts to 200 |
| Writes | 25 requests a second |
Card tokens (/v1/tokens) | 5 requests a second per publishable key per IP address |
- Every response carries
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset. A429carriesRetry-After. - Platforms share a pooled limit across their sub-merchants.
- Lists use cursor pagination (
limit1 to 100, default 20, withstarting_after), so paging a large history never skips or repeats an item.
Request logs
| 10:15:30 | POST | /v1/payment/intents | 201 | req_01J9ZK5N6P… |
| 10:15:31 | POST | /v1/payment/intents | 201 replayed | req_01J9ZK5P7Q… |
| 10:16:02 | POST | /v1/payment/intents/pi_01J9…/confirm | 402 | req_01J9ZK5Q8R… |
{"amount": 250000, "currency": "NGN", "reference": "ORD-1001",
"receipt_email": "a***@example.com",
"payment_method_types": ["bank_transfer", "qr", "offline"]}
Idempotency-Key: ORD-1001-1 · Idempotency-Replayed: true $ pediwave listen --forward-to localhost:3000/webhooks/pediwave
Ready. Forwarding test events with signing secret whsec_test_…
$ pediwave trigger payment_intent.succeeded
→ evt_01J9ZK6R7S… payment_intent.succeeded [200] Idempotency-Key, so it returned the first response. The email address is
redacted in the log.
Tools
See every call you made, exactly as we saw it.
Request logs keep your own API calls for 30 days: method, path, status, the request id you can quote to support, and the body with card data, secrets and personal details redacted. Every event you were sent is kept for 30 days too, with each delivery attempt and your endpoint's response.
PediWave CLI Coming, not yet available
Forward webhooks to your laptop with pediwave listen, and fire any event on demand with
pediwave trigger payment_intent.succeeded.
Reference
Go straight to the detail.
- Payment intents ↗ One object for every rail, and its next_action types (opens docs site)
- Offline ↗ Terminals, policies, batches and taps (opens docs site)
- Routing ↗ Rules, simulation and routing decisions (opens docs site)
- Billing ↗ Plans, subscriptions, invoices and test clocks (opens docs site)
- Payouts ↗ Transfers, recipients, batches and settlements (opens docs site)
- Platforms ↗ Sub-merchants, splits and application fees (opens docs site)
- Webhooks ↗ Endpoints, signatures and the event catalogue (opens docs site)
- Errors ↗ Every error type and code, and what to do (opens docs site)
- Changelog ↗ Every dated API version and what changed (opens docs site)
- Status ↗ Live and recent incidents (opens status site)
Get a test key.
Create an account and make your first call in five minutes. Live keys follow verification.