Collect M-Pesa payments with one API call
The PayTrack API is a REST API for sending M-Pesa STK push prompts and routing the money straight into your bank account, PayBill or Till. You don't need your own Daraja app: PayTrack sends every push from its platform app, tracks it to a final state, and tells your backend with a signed webhook.
Requests and responses are JSON, every response uses standard HTTP status codes, and every request is authenticated with a bearer token.
https://api.paytrack.pesagrid.co.kepay_, chn_, evt_…https://api.paytrack.pesagrid.co.ke
HTTP/1.1 201 Created Content-Type: application/json X-Request-Id: req_01k7x5j2p3q4r5s6t7v8w9x0yz
Quickstart
From zero to a paid order in four steps.
- Get an API key. In the dashboard, go to API keys → Create key. Copy the
ptk_…key into your backend's secrets asPAYTRACK_API_KEY. It's shown once. - Add a channel. Tell PayTrack where the money should land: a bank account, a PayBill or a Till. Keep the returned
chn_…ID. - Set your webhook URL and copy the
whsec_…signing secret intoPAYTRACK_SIGNING_SECRET. - Charge the customer. Create a payment with your order ID as the
Idempotency-Key. The customer gets a prompt; you get apayment.succeededwebhook when they pay.
curl -X POST https://api.paytrack.pesagrid.co.ke/v1/channels \ -H "Authorization: Bearer $PAYTRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Main account", "type": "bank", "bank_code": "247247", "account_number": "0123456789" }'
curl -X PUT https://api.paytrack.pesagrid.co.ke/v1/webhook \ -H "Authorization: Bearer $PAYTRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://acme.co.ke/hooks/paytrack" }'
curl -X POST https://api.paytrack.pesagrid.co.ke/v1/payments \ -H "Authorization: Bearer $PAYTRACK_API_KEY" \ -H "Idempotency-Key: order-1042" \ -H "Content-Type: application/json" \ -d '{ "channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz", "amount": 1500, "phone": "0712345678", "description": "Order 1042", "metadata": { "order_id": "1042" } }'
Authentication
Authenticate every request with your API key as a bearer token: Authorization: Bearer ptk_…. Keys don't expire; you create and revoke them in the dashboard.
A missing key returns 401 missing_token; an unknown or revoked key returns 401 invalid_api_key.
curl https://api.paytrack.pesagrid.co.ke/v1/me \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"error": {
"code": "invalid_api_key",
"message": "API key is invalid or revoked",
"request_id": "req_01k7x5j2p3q4r5s6t7v8w9x0yz"
}
}Errors
PayTrack uses conventional HTTP status codes: 2xx succeeded, 4xx means the request needs fixing, 5xx means something went wrong on our side. Every error has the same shape, with a stable machine-readable code to branch on and a human-readable message that may change.
| Status | Meaning |
|---|---|
| 200 / 201 / 202 / 204 | Success. |
| 401 Unauthorized | No API key, or the key is invalid or revoked. |
| 404 Not Found | The object doesn't exist or belongs to another account. |
| 409 Conflict | The request conflicts with current state (Idempotency-Key reused, delivery in flight). |
| 422 Unprocessable Entity | The request was understood but a field is invalid. error.code says which rule failed. |
| 500 Internal Server Error | Something went wrong on our side. Safe to retry with the same Idempotency-Key. |
Error codes
| Code | Status | Description |
|---|---|---|
missing_token | 401 | No Authorization: Bearer ptk_… header. |
invalid_api_key | 401 | API key is unknown or revoked. |
idempotency_key_reused | 409 | The Idempotency-Key was already used with a different request body. |
delivery_in_progress | 409 | The webhook delivery is being attempted right now; try again shortly. |
payment_not_found | 404 | No payment with that ID on your account. |
channel_not_found | 404 / 422 | No channel with that ID on your account (422 when referenced from a payment). |
delivery_not_found | 404 | No webhook delivery with that ID on your account. |
invalid_json | 422 | Body isn't valid JSON or contains unknown fields. |
idempotency_key_required | 422 | Idempotency-Key header missing or longer than 255 characters. |
invalid_amount | 422 | Amount must be a whole number of KES from 1 to 250,000. |
invalid_phone | 422 | Phone must be a Safaricom number, e.g. 0712345678 or 254712345678. |
invalid_account_reference | 422 | 1–12 characters: letters, digits, space and -_/. |
account_reference_required | 422 | The PayBill channel has no default account, so account_reference is required. |
account_reference_mismatch | 422 | Bank channels always credit the channel's account; omit account_reference. |
invalid_description | 422 | Description must be at most 13 characters. |
invalid_metadata | 422 | At most 20 keys; keys ≤ 40 chars, values ≤ 500 chars. |
channel_inactive | 422 | The channel has been deactivated. |
invalid_channel_type | 422 | type must be bank, paybill or till. |
invalid_bank_code | 422 | bank_code must be a code from GET /v1/banks. |
account_number_required | 422 | Bank channels need the account_number to credit. |
invalid_account_number | 422 | 1–12 characters: letters, digits, space and -_/. |
invalid_business_number | 422 | PayBill / Till number must be 5–7 digits. |
invalid_status | 422 | Unknown status filter value. |
invalid_name | 422 | Name must be 1–100 characters. |
invalid_webhook_url | 422 | Webhook URL must be an absolute http(s) URL without credentials. |
internal_error | 500 | Unexpected server error. Quote the request_id when contacting support. |
{
"error": {
"code": "invalid_amount",
"message": "amount must be a whole number of KES between 1 and 250000",
"request_id": "req_01k7x5j2p3q4r5s6t7v8w9x0yz"
}
}request_id when contacting support. It's also in the X-Request-Id response header.Pagination
List endpoints for payments and deliveries use cursor pagination and return objects newest first. Each page has:
data: the objects on this page.has_more: whether older objects exist.next_cursor: pass it ascursorto get the next page. Omitted on the last page.
Use limit (1–100, default 25) to set the page size. Cursors stay valid while new objects are created, so you never skip or repeat objects.
let cursor; do { const url = new URL("https://api.paytrack.pesagrid.co.ke/v1/payments"); url.searchParams.set("status", "succeeded"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const page = await fetch(url, { headers: { Authorization: `Bearer ${process.env.PAYTRACK_API_KEY}` }, }).then((r) => r.json()); for (const payment of page.data) reconcile(payment); cursor = page.has_more ? page.next_cursor : undefined; } while (cursor);
Idempotency
Networks fail. To retry POST /v1/payments safely, send an Idempotency-Key header (required, up to 255 characters). PayTrack remembers the key for your account:
- Same key, same body: no new push is sent. You get the original payment with
200andIdempotent-Replayed: true. - Same key, different body:
409 idempotency_key_reused. Nothing is charged. - New key: a new payment and a new prompt to the customer.
Use something that identifies the charge attempt, such as your order ID, or order-1042-attempt-2 when you deliberately want to prompt the customer again after a failure.
5xx with the same key. Generating a fresh key on every retry can send the customer two prompts and charge them twice.HTTP/1.1 200 OK Idempotent-Replayed: true X-Request-Id: req_01k7x5k9…
{
"error": {
"code": "idempotency_key_reused",
"message": "Idempotency-Key was already used with a different request body",
"request_id": "req_01k7x5j2p3q4r5s6t7v8w9x0yz"
}
}Phones & amounts
Phone numbers
Send the customer's Safaricom number in whatever format you have. PayTrack strips spaces and symbols and normalises it to 2547XXXXXXXX or 2541XXXXXXXX, which is the format you'll see on the payment.
| You send | Stored as |
|---|---|
0712345678 | 254712345678 |
712345678 | 254712345678 |
+254 712 345 678 | 254712345678 |
0110123456 | 254110123456 |
Amounts
amount is an integer number of Kenyan shillings, from 1 to 250,000 (the M-Pesa per-transaction limit). M-Pesa doesn't support cents, so 1500 means KES 1,500.
Text limits
account_reference: 1–12 characters, letters, digits, space and -_/.. description: up to 13 characters. Both limits come from Safaricom.
{
"error": {
"code": "invalid_phone",
"message": "phone must be a Safaricom number, e.g. 0712345678 or 254712345678",
"request_id": "req_01k7x5j2p3q4r5s6t7v8w9x0yz"
}
}Payment lifecycle
A payment moves through these statuses. succeeded, failed and cancelled are final. Every move into a final (or expired) state sends you a webhook.
| Status | Meaning | Final? | Webhook |
|---|---|---|---|
created | Payment saved. Either the push is in flight, or Safaricom's response was ambiguous (a timeout or 5xx doesn't prove the prompt wasn't sent). | No | none |
pending | Safaricom accepted the push; waiting for the customer to enter their PIN. | No | none |
succeeded | Money received at your destination. mpesa_receipt_number is set when Safaricom provides it. | Yes | payment.succeeded |
failed | Safaricom rejected the push or the payment failed (e.g. insufficient funds, wrong PIN). See result_desc / failure_reason. | Yes | payment.failed |
cancelled | The customer dismissed the prompt (result code 1032). | Yes | payment.cancelled |
expired | PayTrack couldn't learn the outcome in time. Can still become succeeded if proof of payment arrives later. | Almost | payment.expired |
How PayTrack finds the outcome
- Callbacks first. Safaricom calls PayTrack when the customer responds, usually within 10–60 seconds.
- Status queries as backup. If no callback arrives, PayTrack queries Safaricom at 45s, 1m, 2m, 5m, 10m and 20m, then expires the payment.
- Ambiguous pushes. A payment still
createdafter 3 minutes with no callback expires. - Late success wins. If an expired payment later turns out to have been paid, it moves to
succeededand you getpayment.succeeded, so money that arrived is never reported as lost.
payment.expired as “unknown”, not “unpaid”. Don't ship goods, but don't re-charge automatically either. Release the order if payment.succeeded arrives later.Routing money
Every channel has a type that decides where the money goes and how account_reference on a payment behaves.
| Type | Money goes to | Create with | account_reference on payments |
|---|---|---|---|
bank | Your bank account, via the bank's PayBill | bank_code from List banks + account_number | Omit it. Always the channel's account number; a different value returns account_reference_mismatch. |
paybill | Any PayBill | business_number + optional default account_number | Overrides the default. Required if the channel has no default (account_reference_required). |
till | A Buy Goods Till | business_number | Optional; defaults to Till Payment. Buy Goods ignores it. |
Each payment keeps a snapshot of its routing (destination_type, business_number, bank_name), so deactivating or replacing a channel never rewrites past payments.
// bank: account_reference is always the channel's account { "channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz", "amount": 1500, "phone": "0712345678" } // paybill: per-payment account, e.g. the customer's meter number { "channel_id": "chn_01k7x4e9a8b7c6d5e4f3g2h1jk", "amount": 2000, "phone": "0712345678", "account_reference": "METER-55102" } // till: nothing extra { "channel_id": "chn_01k7x4f0m1n2p3q4r5s6t7v8wx", "amount": 350, "phone": "0712345678" }
Webhooks
When a payment reaches a final or expired state, PayTrack POSTs an Event to your webhook URL. Webhooks are the reliable way to fulfil orders; polling is a fallback.
Event types
| Type | Sent when |
|---|---|
payment.succeeded | The customer paid. Fulfil the order. Can follow a payment.expired. |
payment.failed | The push was rejected or the payment failed. |
payment.cancelled | The customer dismissed the prompt. |
payment.expired | No outcome could be determined in time. |
Request headers
| Header | Value |
|---|---|
PayTrack-Event-Id | The event ID (evt_…). Same across retries; dedupe on it. |
PayTrack-Event-Type | e.g. payment.succeeded |
PayTrack-Delivery-Id | The delivery ID (whd_…), for matching against the dashboard. |
PayTrack-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
User-Agent | PayTrack-Webhooks/1 |
Content-Type | application/json |
Verifying signatures
Anyone can POST to your URL, so verify every request before trusting it:
- Split
PayTrack-Signatureon,and readtandv1. - Compute
HMAC-SHA256(signing_secret, t + "." + raw_body)as lowercase hex. Use the raw bytes, not re-serialised JSON. - Compare it with
v1in constant time. - Reject if
tis more than 5 minutes from now, to block replays.
Responding and retries
Return any 2xx within 10 seconds to acknowledge. Anything else (non-2xx, redirects, timeouts, connection errors) is retried with backoff for about 16 hours:
After the last retry the delivery is marked failed. You can replay any delivery from the dashboard or with Redeliver an event, which resets its retry budget. Each attempt uses your current URL and secret.
Best practices
- Dedupe on
PayTrack-Event-Id. Delivery is at-least-once, so the same event can arrive more than once. - Acknowledge fast. Store the event, return
200, and do slow work (emails, ERP sync) in a background job. - Don't rely on order. Use
data.statusanddata.updated_atas the truth, not arrival order. - Match orders with
metadata. Put your order ID inmetadatawhen creating the payment. - Use a public HTTPS URL. Endpoints that resolve to private or loopback IPs are refused. Redirects are treated as failures.
POST /hooks/paytrack HTTP/1.1 Host: acme.co.ke Content-Type: application/json User-Agent: PayTrack-Webhooks/1 PayTrack-Event-Id: evt_01k7x5h8a9b0c1d2e3f4g5h6jk PayTrack-Event-Type: payment.succeeded PayTrack-Delivery-Id: whd_01k7x5h8c0d1e2f3g4h5j6k7mn PayTrack-Signature: t=1760520279,v1=5f2b9c1e0a7d4b38e6f19a2c7d0e4b85a3c6f1d9e2b7a40c58d3e1f6a9b2c7d4
{
"id": "evt_01k7x5h8a9b0c1d2e3f4g5h6jk",
"type": "payment.succeeded",
"tenant_id": "ten_01k7x2c9m4q8r6t3v5w7y9z1ab",
"created_at": "2026-10-15T09:24:38Z",
"data": {
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "succeeded",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:38Z",
"mpesa_receipt_number": "TJF3K8Q2XD",
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"completed_at": "2026-10-15T09:24:38Z"
}
}import crypto from "node:crypto"; import express from "express"; const app = express(); const secret = process.env.PAYTRACK_SIGNING_SECRET; // whsec_... // Use the RAW body: re-serialised JSON won't match the signature. app.post("/hooks/paytrack", express.raw({ type: "application/json" }), (req, res) => { const header = req.get("PayTrack-Signature") || ""; const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("="))); const expected = crypto.createHmac("sha256", secret).update(`${t}.${req.body}`).digest("hex"); const valid = v1 && v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300; if (!valid || !fresh) return res.sendStatus(400); const event = JSON.parse(req.body); if (alreadyProcessed(event.id)) return res.sendStatus(200); // dedupe switch (event.type) { case "payment.succeeded": markOrderPaid(event.data.metadata.order_id, event.data); break; case "payment.failed": case "payment.cancelled": case "payment.expired": markOrderUnpaid(event.data.metadata.order_id, event.data); break; } res.sendStatus(200); // respond fast; do slow work in a queue });
Go-live checklist
- API keys are stored server-side only, one per environment, with clear names.
- Every
POST /v1/paymentssends a stableIdempotency-Key, and retries reuse it. - Your webhook endpoint verifies
PayTrack-Signatureagainst the raw body and checks the timestamp. - Events are deduplicated on
idand acknowledged within 10 seconds. - Orders are fulfilled on
payment.succeededonly, andpayment.expiredis handled as “unknown”. - You log
request_idon API errors and branch onerror.code, notmessage. - A nightly job lists recent payments to reconcile against your orders, in case webhooks were missed.
- The webhook signing secret is in your secret manager, not in code.
The Payment object
A Payment is one STK push to one customer. It records where the money was routed at the moment it was created, so later changes to the channel never rewrite history.
idstringpay_. IDs sort by creation time.channel_idstringdestination_typeenumbank, paybill or till. Snapshot of the channel type at creation.business_numberstringbank_namestringstatusenumcreated, pending, succeeded, failed, cancelled or expired. See Payment lifecycle.amountintegercurrencystringKES.phonestring2547XXXXXXXX / 2541XXXXXXXX.account_referencestringdescriptionstringPayment.metadataobjectmerchant_request_idstringcheckout_request_idstringmpesa_receipt_numberstringTJF3K8Q2XD. Empty if the result was learned via a status query.result_codeinteger0 is success, 1032 is cancelled by the customer.result_descstringfailure_reasonstringcreated_attimestampupdated_attimestampcompleted_attimestamp | null{
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "succeeded",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:38Z",
"mpesa_receipt_number": "TJF3K8Q2XD",
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"completed_at": "2026-10-15T09:24:38Z"
}The Channel object
A Channel is a destination for money. PayTrack sends every STK push from its own Daraja app; the channel decides which bank account, PayBill or Till is credited.
idstringchn_.namestringtypeenumbank, paybill or till.business_numberstringbank, the bank's PayBill from /v1/banks.bank_namestringbank channels.account_numberstringbank: the account credited. paybill: optional default account reference. Omitted for till.activebooleanfalse once deactivated. Inactive channels reject new payments.created_attimestamp{
"id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"name": "Main account",
"type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"account_number": "0123456789",
"active": true,
"created_at": "2026-10-15T09:24:11Z"
}The Event object
Events are what PayTrack POSTs to your webhook URL. data is the full Payment at the time of the event.
idstringevt_. Dedupe on this.typeenumpayment.succeeded, payment.failed, payment.cancelled or payment.expired.tenant_idstringcreated_attimestampdataPayment{
"id": "evt_01k7x5h8a9b0c1d2e3f4g5h6jk",
"type": "payment.succeeded",
"tenant_id": "ten_01k7x2c9m4q8r6t3v5w7y9z1ab",
"created_at": "2026-10-15T09:24:38Z",
"data": {
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "succeeded",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:38Z",
"mpesa_receipt_number": "TJF3K8Q2XD",
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"completed_at": "2026-10-15T09:24:38Z"
}
}The Delivery object
A Delivery tracks sending one Event to your webhook URL, including every retry.
idstringwhd_. Sent as PayTrack-Delivery-Id.event_idstringevent_typestringtype.payment_idstringstatusenumpending, delivering, delivered or failed (retries exhausted).attemptsintegernext_attempt_attimestamp | nulllast_errorstringendpoint returned HTTP 5xx.last_status_codeintegerdelivered_attimestamp | nullcreated_attimestamppayloadEvent{
"id": "whd_01k7x5h8c0d1e2f3g4h5j6k7mn",
"event_id": "evt_01k7x5h8a9b0c1d2e3f4g5h6jk",
"event_type": "payment.succeeded",
"payment_id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"status": "delivered",
"attempts": 1,
"next_attempt_at": null,
"last_status_code": 200,
"delivered_at": "2026-10-15T09:24:39Z",
"created_at": "2026-10-15T09:24:38Z",
"payload": {
"id": "evt_01k7x5h8a9b0c1d2e3f4g5h6jk",
"type": "payment.succeeded",
"tenant_id": "ten_01k7x2c9m4q8r6t3v5w7y9z1ab",
"created_at": "2026-10-15T09:24:38Z",
"data": {
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "succeeded",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:38Z",
"mpesa_receipt_number": "TJF3K8Q2XD",
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"completed_at": "2026-10-15T09:24:38Z"
}
}
}Payments
Charge a customer with an M-Pesa STK push, then follow the payment until it settles.
Create a payment
/v1/paymentsAPI keySends an STK push to the customer's phone. Returns 201 with status pending once Safaricom accepts the push, failed if Safaricom rejected it, or created if Safaricom's response was ambiguous. In every case the final outcome arrives by webhook. Retrying with the same Idempotency-Key and body returns the original payment with 200 and the header Idempotent-Replayed: true.
Idempotency-Keystringrequiredchannel_idstringrequiredamountintegerrequiredphonestringrequired0712345678, 712345678, +254712345678, 254712345678. 01… numbers work too.account_referencestringoptionalTill Payment.descriptionstringoptionalPayment.metadataobjectoptionalA 201 Created response with the JSON shown. Errors use the standard error object.
curl -X POST https://api.paytrack.pesagrid.co.ke/v1/payments \ -H "Authorization: Bearer $PAYTRACK_API_KEY" \ -H "Idempotency-Key: order-1042" \ -H "Content-Type: application/json" \ -d '{ "channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz", "amount": 1500, "phone": "0712345678", "description": "Order 1042", "metadata": { "order_id": "1042" } }'
{
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "pending",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:11Z"
}Retrieve a payment
/v1/payments/{id}API keyFetches the current state of a payment. Prefer webhooks for completion; poll sparingly (every few seconds at most) if you must.
idstringrequiredpay_01k7….A 200 OK response with the JSON shown. Errors use the standard error object.
curl https://api.paytrack.pesagrid.co.ke/v1/payments/pay_01k7x5g3r4s5t6v7w8x9y0z1ab \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "succeeded",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:38Z",
"mpesa_receipt_number": "TJF3K8Q2XD",
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"completed_at": "2026-10-15T09:24:38Z"
}List payments
/v1/paymentsAPI keyReturns your payments, newest first, with cursor pagination.
statusenumoptionalcreated, pending, succeeded, failed, cancelled or expired.channel_idstringoptionalphonestringoptionallimitintegeroptionalcursorstringoptionalnext_cursor from the previous page.A 200 OK response with the JSON shown. Errors use the standard error object.
curl https://api.paytrack.pesagrid.co.ke/v1/payments?status=succeeded&limit=2 \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"data": [
{
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "succeeded",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:38Z",
"mpesa_receipt_number": "TJF3K8Q2XD",
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"completed_at": "2026-10-15T09:24:38Z"
}
],
"has_more": true,
"next_cursor": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab"
}Channels
Channels are where money lands. Create one per bank account, PayBill or Till you collect into.
List banks
/v1/banksNo authBanks you can use as a bank channel destination. Public, no authentication needed. code is the bank's PayBill.
A 200 OK response with the JSON shown. Errors use the standard error object.
curl https://api.paytrack.pesagrid.co.ke/v1/banks{
"data": [
{
"code": "522522",
"name": "KCB Bank"
},
{
"code": "247247",
"name": "Equity Bank"
},
{
"code": "400200",
"name": "Co-operative Bank"
},
{
"code": "600100",
"name": "Stanbic Bank"
},
{
"code": "880100",
"name": "NCBA Bank"
},
{
"code": "972900",
"name": "Bank of Africa"
},
{
"code": "111777",
"name": "ABC Bank"
},
{
"code": "902363",
"name": "Bank of Baroda"
}
]
}Create a channel
/v1/channelsAPI keyRegisters a destination. Which fields are required depends on type.
namestringrequiredtypeenumrequiredbank, paybill or till.bank_codestringoptionalcode from List banks.business_numberstringoptionalaccount_numberstringoptionalpaybill. Ignored for till. 1–12 chars.A 201 Created response with the JSON shown. Errors use the standard error object.
curl -X POST https://api.paytrack.pesagrid.co.ke/v1/channels \ -H "Authorization: Bearer $PAYTRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Main account", "type": "bank", "bank_code": "247247", "account_number": "0123456789" }'
{
"id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"name": "Main account",
"type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"account_number": "0123456789",
"active": true,
"created_at": "2026-10-15T09:24:11Z"
}Retrieve a channel
/v1/channels/{id}API keyFetches one channel.
idstringrequiredA 200 OK response with the JSON shown. Errors use the standard error object.
curl https://api.paytrack.pesagrid.co.ke/v1/channels/chn_01k7x4e2p3q4r5s6t7v8w9x0yz \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"name": "Main account",
"type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"account_number": "0123456789",
"active": true,
"created_at": "2026-10-15T09:24:11Z"
}List channels
/v1/channelsAPI keyReturns all your channels, including deactivated ones. Not paginated.
A 200 OK response with the JSON shown. Errors use the standard error object.
curl https://api.paytrack.pesagrid.co.ke/v1/channels \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"data": [
{
"id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"name": "Main account",
"type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"account_number": "0123456789",
"active": true,
"created_at": "2026-10-15T09:24:11Z"
},
{
"id": "chn_01k7x4f0m1n2p3q4r5s6t7v8wx",
"name": "Shop till",
"type": "till",
"business_number": "123456",
"active": true,
"created_at": "2026-10-15T09:24:11Z"
}
]
}Deactivate a channel
/v1/channels/{id}API keyNew payments on the channel are rejected with channel_inactive. Payments already in flight still complete. Deactivation is permanent; create a new channel to reuse the destination.
idstringrequiredAn empty 204 No Content response on success.
curl -X DELETE https://api.paytrack.pesagrid.co.ke/v1/channels/chn_01k7x4e2p3q4r5s6t7v8w9x0yz \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
// No contentWebhooks
Configure where events are sent, rotate the signing secret, and inspect or replay deliveries.
Retrieve webhook settings
/v1/webhookAPI keyReturns the webhook URL (empty until set) and the signing secret. To rotate the secret, use the dashboard.
A 200 OK response with the JSON shown. Errors use the standard error object.
curl https://api.paytrack.pesagrid.co.ke/v1/webhook \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"url": "https://acme.co.ke/hooks/paytrack",
"signing_secret": "whsec_4c1f9a0e7b2d8c3f6a5e1b9d0c7f2a8e4b6d1c3f5a7e9b0d"
}Set the webhook URL
/v1/webhookAPI keySets the URL events are POSTed to. Takes effect for the next attempt, including retries of earlier events.
urlstringrequiredhttps:// URL (plain http:// is accepted but not recommended). No credentials in the URL.A 200 OK response with the JSON shown. Errors use the standard error object.
curl -X PUT https://api.paytrack.pesagrid.co.ke/v1/webhook \ -H "Authorization: Bearer $PAYTRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://acme.co.ke/hooks/paytrack" }'
{
"url": "https://acme.co.ke/hooks/paytrack",
"signing_secret": "whsec_4c1f9a0e7b2d8c3f6a5e1b9d0c7f2a8e4b6d1c3f5a7e9b0d"
}List deliveries
/v1/webhook/deliveriesAPI keyEvery attempt to send an event to you, newest first, with cursor pagination.
payment_idstringoptionalstatusenumoptionalpending, delivering, delivered or failed.limitintegeroptionalcursorstringoptionalnext_cursor from the previous page.A 200 OK response with the JSON shown. Errors use the standard error object.
curl https://api.paytrack.pesagrid.co.ke/v1/webhook/deliveries?payment_id=pay_01k7x5g3r4s5t6v7w8x9y0z1ab \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"data": [
{
"id": "whd_01k7x5h8c0d1e2f3g4h5j6k7mn",
"event_id": "evt_01k7x5h8a9b0c1d2e3f4g5h6jk",
"event_type": "payment.succeeded",
"payment_id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"status": "delivered",
"attempts": 1,
"next_attempt_at": null,
"last_status_code": 200,
"delivered_at": "2026-10-15T09:24:39Z",
"created_at": "2026-10-15T09:24:38Z",
"payload": {
"id": "evt_01k7x5h8a9b0c1d2e3f4g5h6jk",
"type": "payment.succeeded",
"tenant_id": "ten_01k7x2c9m4q8r6t3v5w7y9z1ab",
"created_at": "2026-10-15T09:24:38Z",
"data": {
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "succeeded",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:38Z",
"mpesa_receipt_number": "TJF3K8Q2XD",
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"completed_at": "2026-10-15T09:24:38Z"
}
}
}
],
"has_more": false
}Redeliver an event
/v1/webhook/deliveries/{id}/redeliverAPI keyQueues the delivery again immediately with a fresh retry budget. The same event ID is sent, so your deduplication keeps it safe.
idstringrequiredwhd_…).A 202 Accepted response with the JSON shown. Errors use the standard error object.
curl -X POST https://api.paytrack.pesagrid.co.ke/v1/webhook/deliveries/whd_01k7x5h8c0d1e2f3g4h5j6k7mn/redeliver \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"id": "whd_01k7x5h8c0d1e2f3g4h5j6k7mn",
"event_id": "evt_01k7x5h8a9b0c1d2e3f4g5h6jk",
"event_type": "payment.succeeded",
"payment_id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"status": "pending",
"attempts": 0,
"next_attempt_at": "2026-10-15T10:02:00Z",
"last_status_code": 200,
"delivered_at": "2026-10-15T09:24:39Z",
"created_at": "2026-10-15T09:24:38Z",
"payload": {
"id": "evt_01k7x5h8a9b0c1d2e3f4g5h6jk",
"type": "payment.succeeded",
"tenant_id": "ten_01k7x2c9m4q8r6t3v5w7y9z1ab",
"created_at": "2026-10-15T09:24:38Z",
"data": {
"id": "pay_01k7x5g3r4s5t6v7w8x9y0z1ab",
"channel_id": "chn_01k7x4e2p3q4r5s6t7v8w9x0yz",
"destination_type": "bank",
"business_number": "247247",
"bank_name": "Equity Bank",
"status": "succeeded",
"amount": 1500,
"currency": "KES",
"phone": "254712345678",
"account_reference": "0123456789",
"description": "Order 1042",
"metadata": {
"order_id": "1042"
},
"merchant_request_id": "29115-34620561-1",
"checkout_request_id": "ws_CO_15102026122411734712345678",
"created_at": "2026-10-15T09:24:11Z",
"updated_at": "2026-10-15T09:24:38Z",
"mpesa_receipt_number": "TJF3K8Q2XD",
"result_code": 0,
"result_desc": "The service request is processed successfully.",
"completed_at": "2026-10-15T09:24:38Z"
}
}
}Account
Check which account an API key belongs to.
Verify your API key
/v1/meAPI keyReturns the account the API key belongs to. Call it at startup or in a health check to confirm the key is valid.
A 200 OK response with the JSON shown. Errors use the standard error object.
curl https://api.paytrack.pesagrid.co.ke/v1/me \ -H "Authorization: Bearer $PAYTRACK_API_KEY"
{
"tenant": {
"id": "ten_01k7x2c9m4q8r6t3v5w7y9z1ab",
"name": "Acme Shop",
"created_at": "2026-10-01T08:00:00Z"
},
"user": null,
"auth_kind": "api_key"
}