Products
Solutions
Developers
Pricing
Company
Get started Sign in

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.

Language
POST /v1/payment/intents
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"
}
₦2,500.00 is sent as 250000: amounts are integers in minor units. Retrying with the same Idempotency-Key returns this same response.

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/node
    Coming, not yet published Get notified
  • Python

    Server library for Python 3.

    pip install pediwave
    Coming, not yet published Get notified
  • PHP

    Server library for PHP and Laravel.

    composer require pediwave/pediwave-php
    Coming, 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-go
    Coming, not yet published Get notified
  • .NET

    Server library for C# and .NET.

    dotnet add package PediWave
    Coming, not yet published Get notified
  • Flutter

    Client calls for Flutter apps, with publishable keys only.

    flutter pub add pediwave
    Coming, 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.

Test cards
Show
Test card numbers and outcomes
Card numberOutcome
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
Sandbox simulator endpoints
CallWhat 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

  1. Attempt 1 · con_sim_a · do_not_honorDeclined
  2. Attempt 2 · con_sim_b · approvedSucceeded

Status: succeeded · 2 attempts

Card 4084 0840 8408 4084 declines on the first provider and is approved on the second, so you can see a cascade in your own logs.

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 guide
POST https://acme.example/webhooks/pediwave
Content-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"}}}
Verification language
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);
});
Two 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 guide

Keys 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

API key types
PrefixKeyWhat it can doWhere 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.expiring 14 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)

Rate limits
CallsLimit
Reads100 requests a second sustained, bursts to 200
Writes25 requests a second
Card tokens (/v1/tokens)5 requests a second per publishable key per IP address
  • Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A 429 carries Retry-After.
  • Platforms share a pooled limit across their sub-merchants.
  • Lists use cursor pagination (limit 1 to 100, default 20, with starting_after), so paging a large history never skips or repeats an item.
Authentication guide
The second call reused its 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.

Request logs in the docs

Reference

Go straight to the detail.

Get a test key.

Create an account and make your first call in five minutes. Live keys follow verification.