This walks a merchant developer through the tracer-bullet flow: create a
payment intent, drive it to completion in test mode, and receive the signed
webhook.
Sign up (basic CDD) and create a test secret key, or a publishable key if you
only need to create intents. Authenticate requests with:
Authorization: Bearer <key>
The aggregator will POST signed payment events to this URL.
POST /webhook-endpoints
Authorization: Bearer sk_test_...
Content-Type: application/json
{ "url": "https://you.example.com/hooks/transxact" }
The response returns the secret **once** — store it. An unverified endpoint
is marked active only after it answers the webhook.verified event with a
2xx.
POST /payment-intents
Authorization: Bearer sk_test_...
Idempotency-Key: order-1234
Content-Type: application/json
{ "amount": 2500, "reference": "order-1234", "return_url": "https://you.example.com/thanks" }
The intent starts in requires_action and returns a next_action:
{ "intent": { "id": "pi_...", "status": "requires_action", "next_action": { "type": "redirect", "url": "/simulate/pi_..." } } }
In test mode every rail is simulated; the outcome is forced by a magic value.
**Redirect rails** (M-PAiSA, card) — open the next_action.url, or simulate it:
POST /simulate/pi_...
| Input | Outcome |
| --- | --- |
| reference: "test_success" or amount 1 | succeeded |
| reference: "test_fail" or amount 2 | failed |
| reference: "test_pending" | processing, then succeeds on the next simulate |
**MyCash** — create with "payment_method": "mycash" to get a collect_otp
next_action, then confirm the mobile and OTP server-side:
POST /payment-intents/pi_.../confirm { "mobile": "+6797000000" }
POST /payment-intents/pi_.../confirm { "otp": "123456" }
In test mode any OTP approves except the magic 000000 (which fails the
intent); the magic mobile +6790000000 is rejected.
You receive payment.processing then payment.succeeded (or payment.failed).
Verify the signature:
Transxact-Signature: v1=<hex>
Transxact-Timestamp: <unix-seconds>
X-Transxact-Event-Id: evt_...
The signature is HMAC-SHA256("<timestamp>.<body>") with your webhook secret.
Reject any delivery whose timestamp is older than 5 minutes.
Full contract: docs/api/openapi.yaml (the single source of truth, ADR-0006).
An intent is created in the mode of the key that authenticates it: a live
secret/publishable key produces a mode: "live" intent; a test key produces
a mode: "test" intent.
values inside the aggregator, no real money, no external rail call.
payment_method mpaisa, the default) performs the real generateAuth → handshake → hosted-page redirect; the aggregator verifies
the tokenv2 redirect callback before polling requeststatus to terminal
and posting the ledger capture.
payment_method mycash) runs the real paymentRequest → sendOTP → approvePayment server-to-server flow on confirm.
Live mode requires the rail credentials — see scripts/setup-mpaisa.sh and
scripts/setup-mycash.sh.
A **single-use link** has a fixed amount and is consumed on its first
successful payment; a **reusable "pay me" link** accepts a free amount with an
optional suggested amount.
POST /payment-links
Authorization: Bearer sk_test_...
Content-Type: application/json
{ "type": "single_use", "amount": 2500, "reference": "inv-1" }
The response's url is the hosted checkout. Opening it presents the shopper a
vertical method list (M-PAiSA, MyCash, card) and drives the payment in test
mode; a successful payment lands on a branded confirmation. A single-use link
refuses a second visit after it is used.