Paytrack DocsAPI v1
PayTrack API reference

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.

Base URLhttps://api.paytrack.pesagrid.co.ke
FormatJSON over HTTPS, UTF-8
CurrencyKES, whole shillings
TimestampsRFC 3339, UTC
IDsPrefixed, time-sortable: pay_, chn_, evt_…
Base URL
https://api.paytrack.pesagrid.co.ke
Every response carries a request ID
HTTP/1.1 201 Created
Content-Type: application/json
X-Request-Id: req_01k7x5j2p3q4r5s6t7v8w9x0yz

Quickstart

From zero to a paid order in four steps.

  1. Get an API key. In the dashboard, go to API keys → Create key. Copy the ptk_… key into your backend's secrets as PAYTRACK_API_KEY. It's shown once.
  2. Add a channel. Tell PayTrack where the money should land: a bank account, a PayBill or a Till. Keep the returned chn_… ID.
  3. Set your webhook URL and copy the whsec_… signing secret into PAYTRACK_SIGNING_SECRET.
  4. Charge the customer. Create a payment with your order ID as the Idempotency-Key. The customer gets a prompt; you get a payment.succeeded webhook when they pay.
Never call the PayTrack API from a browser or mobile app. API keys can move money into your channels and must stay on your server.
2. Add a channel
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"
  }'
3. Set the webhook URL
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"
  }'
4. Charge the customer
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.

Treat API keys like passwords. Keep them on your server, use one key per environment or service, and revoke any key you suspect has leaked.
Authenticated request
curl https://api.paytrack.pesagrid.co.ke/v1/me \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
401 Unauthorized
{
  "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.

StatusMeaning
200 / 201 / 202 / 204Success.
401 UnauthorizedNo API key, or the key is invalid or revoked.
404 Not FoundThe object doesn't exist or belongs to another account.
409 ConflictThe request conflicts with current state (Idempotency-Key reused, delivery in flight).
422 Unprocessable EntityThe request was understood but a field is invalid. error.code says which rule failed.
500 Internal Server ErrorSomething went wrong on our side. Safe to retry with the same Idempotency-Key.

Error codes

CodeStatusDescription
missing_token401No Authorization: Bearer ptk_… header.
invalid_api_key401API key is unknown or revoked.
idempotency_key_reused409The Idempotency-Key was already used with a different request body.
delivery_in_progress409The webhook delivery is being attempted right now; try again shortly.
payment_not_found404No payment with that ID on your account.
channel_not_found404 / 422No channel with that ID on your account (422 when referenced from a payment).
delivery_not_found404No webhook delivery with that ID on your account.
invalid_json422Body isn't valid JSON or contains unknown fields.
idempotency_key_required422Idempotency-Key header missing or longer than 255 characters.
invalid_amount422Amount must be a whole number of KES from 1 to 250,000.
invalid_phone422Phone must be a Safaricom number, e.g. 0712345678 or 254712345678.
invalid_account_reference4221–12 characters: letters, digits, space and -_/.
account_reference_required422The PayBill channel has no default account, so account_reference is required.
account_reference_mismatch422Bank channels always credit the channel's account; omit account_reference.
invalid_description422Description must be at most 13 characters.
invalid_metadata422At most 20 keys; keys ≤ 40 chars, values ≤ 500 chars.
channel_inactive422The channel has been deactivated.
invalid_channel_type422type must be bank, paybill or till.
invalid_bank_code422bank_code must be a code from GET /v1/banks.
account_number_required422Bank channels need the account_number to credit.
invalid_account_number4221–12 characters: letters, digits, space and -_/.
invalid_business_number422PayBill / Till number must be 5–7 digits.
invalid_status422Unknown status filter value.
invalid_name422Name must be 1–100 characters.
invalid_webhook_url422Webhook URL must be an absolute http(s) URL without credentials.
internal_error500Unexpected server error. Quote the request_id when contacting support.
Error object
{
  "error": {
    "code": "invalid_amount",
    "message": "amount must be a whole number of KES between 1 and 250000",
    "request_id": "req_01k7x5j2p3q4r5s6t7v8w9x0yz"
  }
}
Include the 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 as cursor to 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.

Iterate every succeeded payment (Node.js)
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 200 and Idempotent-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.

Retry on timeouts and 5xx with the same key. Generating a fresh key on every retry can send the customer two prompts and charge them twice.
Replayed response headers
HTTP/1.1 200 OK
Idempotent-Replayed: true
X-Request-Id: req_01k7x5k9…
409 Conflict
{
  "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 sendStored as
0712345678254712345678
712345678254712345678
+254 712 345 678254712345678
0110123456254110123456

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.

422 Unprocessable Entity
{
  "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.

created
Saved; push being sent or Safaricom's answer was ambiguous
→
pending
Prompt on the customer's phone
→
succeeded
failed
cancelled
expired
StatusMeaningFinal?Webhook
createdPayment 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).Nonone
pendingSafaricom accepted the push; waiting for the customer to enter their PIN.Nonone
succeededMoney received at your destination. mpesa_receipt_number is set when Safaricom provides it.Yespayment.succeeded
failedSafaricom rejected the push or the payment failed (e.g. insufficient funds, wrong PIN). See result_desc / failure_reason.Yespayment.failed
cancelledThe customer dismissed the prompt (result code 1032).Yespayment.cancelled
expiredPayTrack couldn't learn the outcome in time. Can still become succeeded if proof of payment arrives later.Almostpayment.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 created after 3 minutes with no callback expires.
  • Late success wins. If an expired payment later turns out to have been paid, it moves to succeeded and you get payment.succeeded, so money that arrived is never reported as lost.
Treat 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.

TypeMoney goes toCreate withaccount_reference on payments
bankYour bank account, via the bank's PayBillbank_code from List banks + account_numberOmit it. Always the channel's account number; a different value returns account_reference_mismatch.
paybillAny PayBillbusiness_number + optional default account_numberOverrides the default. Required if the channel has no default (account_reference_required).
tillA Buy Goods Tillbusiness_numberOptional; 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.

Charging each channel type
// 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

TypeSent when
payment.succeededThe customer paid. Fulfil the order. Can follow a payment.expired.
payment.failedThe push was rejected or the payment failed.
payment.cancelledThe customer dismissed the prompt.
payment.expiredNo outcome could be determined in time.

Request headers

HeaderValue
PayTrack-Event-IdThe event ID (evt_…). Same across retries; dedupe on it.
PayTrack-Event-Typee.g. payment.succeeded
PayTrack-Delivery-IdThe delivery ID (whd_…), for matching against the dashboard.
PayTrack-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
User-AgentPayTrack-Webhooks/1
Content-Typeapplication/json

Verifying signatures

Anyone can POST to your URL, so verify every request before trusting it:

  1. Split PayTrack-Signature on , and read t and v1.
  2. Compute HMAC-SHA256(signing_secret, t + "." + raw_body) as lowercase hex. Use the raw bytes, not re-serialised JSON.
  3. Compare it with v1 in constant time.
  4. Reject if t is 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:

attempt 1+10s+30s+1m+5m+15m+30m+1h+2h+4h+8h

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.status and data.updated_at as the truth, not arrival order.
  • Match orders with metadata. Put your order ID in metadata when creating the payment.
  • Use a public HTTPS URL. Endpoints that resolve to private or loopback IPs are refused. Redirects are treated as failures.
POST to your webhook URL
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
Event body
{
  "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"
  }
}
Verify & handle
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/payments sends a stable Idempotency-Key, and retries reuse it.
  • Your webhook endpoint verifies PayTrack-Signature against the raw body and checks the timestamp.
  • Events are deduplicated on id and acknowledged within 10 seconds.
  • Orders are fulfilled on payment.succeeded only, and payment.expired is handled as “unknown”.
  • You log request_id on API errors and branch on error.code, not message.
  • 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.
Object

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.

Attributes
idstring
Unique identifier, prefixed pay_. IDs sort by creation time.
channel_idstring
The channel the payment was sent through.
destination_typeenum
bank, paybill or till. Snapshot of the channel type at creation.
business_numberstring
The PayBill / Till the money was sent to. For bank channels, the bank's PayBill.
bank_namestring
Bank name for bank destinations. Omitted otherwise.
statusenum
created, pending, succeeded, failed, cancelled or expired. See Payment lifecycle.
amountinteger
Whole Kenyan shillings.
currencystring
Always KES.
phonestring
Customer number normalised to 2547XXXXXXXX / 2541XXXXXXXX.
account_referencestring
Account shown to the customer and credited at the destination.
descriptionstring
Up to 13 characters shown on the M-Pesa prompt. Defaults to Payment.
metadataobject
Your key/value strings, returned untouched. Omitted when empty.
merchant_request_idstring
Safaricom's request ID. Present once Daraja accepted the push.
checkout_request_idstring
Safaricom's checkout ID. Useful when talking to Safaricom support.
mpesa_receipt_numberstring
The M-Pesa receipt, e.g. TJF3K8Q2XD. Empty if the result was learned via a status query.
result_codeinteger
Safaricom result code. 0 is success, 1032 is cancelled by the customer.
result_descstring
Safaricom's human-readable result.
failure_reasonstring
Why PayTrack failed or expired the payment, when there's no Safaricom result.
created_attimestamp
RFC 3339, UTC.
updated_attimestamp
RFC 3339, UTC.
completed_attimestamp | null
When the payment reached its current final state.
The Payment object
{
  "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"
}
Object

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.

Attributes
idstring
Unique identifier, prefixed chn_.
namestring
Your label for the channel.
typeenum
bank, paybill or till.
business_numberstring
The PayBill or Till. For bank, the bank's PayBill from /v1/banks.
bank_namestring
Only for bank channels.
account_numberstring
bank: the account credited. paybill: optional default account reference. Omitted for till.
activeboolean
false once deactivated. Inactive channels reject new payments.
created_attimestamp
RFC 3339, UTC.
The Channel object
{
  "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"
}
Object

The Event object

Events are what PayTrack POSTs to your webhook URL. data is the full Payment at the time of the event.

Attributes
idstring
Unique identifier, prefixed evt_. Dedupe on this.
typeenum
payment.succeeded, payment.failed, payment.cancelled or payment.expired.
tenant_idstring
Your account ID.
created_attimestamp
When the event happened.
dataPayment
Snapshot of the payment.
The Event object
{
  "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"
  }
}
Object

The Delivery object

A Delivery tracks sending one Event to your webhook URL, including every retry.

Attributes
idstring
Unique identifier, prefixed whd_. Sent as PayTrack-Delivery-Id.
event_idstring
The event being delivered.
event_typestring
Same as the event's type.
payment_idstring
The payment the event is about.
statusenum
pending, delivering, delivered or failed (retries exhausted).
attemptsinteger
Attempts made so far. Reset to 0 by a redelivery.
next_attempt_attimestamp | null
When the next retry is due.
last_errorstring
Last transport error or endpoint returned HTTP 5xx.
last_status_codeinteger
HTTP status your endpoint returned last time.
delivered_attimestamp | null
When a 2xx was received.
created_attimestamp
RFC 3339, UTC.
payloadEvent
The exact event body sent.
The Delivery object
{
  "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"
    }
  }
}
Endpoints

Payments

Charge a customer with an M-Pesa STK push, then follow the payment until it settles.

Create a payment

POST/v1/paymentsAPI key

Sends 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.

Headers
Idempotency-Keystringrequired
Unique per charge, up to 255 characters. Use your order ID or a UUID.
Body parameters
channel_idstringrequired
The channel to route the money to.
amountintegerrequired
Whole KES, 1 – 250,000.
phonestringrequired
Safaricom number in any common format: 0712345678, 712345678, +254712345678, 254712345678. 01… numbers work too.
account_referencestringoptional
1–12 chars. bank: omit. paybill: overrides the channel default; required if it has none. till: optional, defaults to Till Payment.
descriptionstringoptional
Up to 13 characters shown on the prompt. Defaults to Payment.
metadataobjectoptional
Up to 20 string key/value pairs (keys ≤ 40, values ≤ 500 chars). Returned on the payment and in webhooks.
Returns

A 201 Created response with the JSON shown. Errors use the standard error object.

POST /v1/payments
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"
    }
  }'
Response
{
  "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

GET/v1/payments/{id}API key

Fetches the current state of a payment. Prefer webhooks for completion; poll sparingly (every few seconds at most) if you must.

Path parameters
idstringrequired
The payment ID, e.g. pay_01k7….
Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

GET /v1/payments/{id}
curl https://api.paytrack.pesagrid.co.ke/v1/payments/pay_01k7x5g3r4s5t6v7w8x9y0z1ab \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
{
  "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

GET/v1/paymentsAPI key

Returns your payments, newest first, with cursor pagination.

Query parameters
statusenumoptional
created, pending, succeeded, failed, cancelled or expired.
channel_idstringoptional
Only payments sent through this channel.
phonestringoptional
Only payments to this phone. Any accepted format.
limitintegeroptional
1–100. Default 25.
cursorstringoptional
next_cursor from the previous page.
Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

GET /v1/payments
curl https://api.paytrack.pesagrid.co.ke/v1/payments?status=succeeded&limit=2 \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
{
  "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"
}
Endpoints

Channels

Channels are where money lands. Create one per bank account, PayBill or Till you collect into.

List banks

GET/v1/banksNo auth

Banks you can use as a bank channel destination. Public, no authentication needed. code is the bank's PayBill.

Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

GET /v1/banks
curl https://api.paytrack.pesagrid.co.ke/v1/banks
Response
{
  "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

POST/v1/channelsAPI key

Registers a destination. Which fields are required depends on type.

Body parameters
namestringrequired
Your label, 1–100 characters.
typeenumrequired
bank, paybill or till.
bank_codestringoptional
Required for `bank`. A code from List banks.
business_numberstringoptional
Required for `paybill` and `till`. 5–7 digits.
account_numberstringoptional
Required for `bank` (the account credited). Optional default for paybill. Ignored for till. 1–12 chars.
Returns

A 201 Created response with the JSON shown. Errors use the standard error object.

POST /v1/channels
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"
  }'
Response
{
  "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

GET/v1/channels/{id}API key

Fetches one channel.

Path parameters
idstringrequired
The channel ID.
Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

GET /v1/channels/{id}
curl https://api.paytrack.pesagrid.co.ke/v1/channels/chn_01k7x4e2p3q4r5s6t7v8w9x0yz \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
{
  "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

GET/v1/channelsAPI key

Returns all your channels, including deactivated ones. Not paginated.

Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

GET /v1/channels
curl https://api.paytrack.pesagrid.co.ke/v1/channels \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
{
  "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

DEL/v1/channels/{id}API key

New 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.

Path parameters
idstringrequired
The channel ID.
Returns

An empty 204 No Content response on success.

DEL /v1/channels/{id}
curl -X DELETE https://api.paytrack.pesagrid.co.ke/v1/channels/chn_01k7x4e2p3q4r5s6t7v8w9x0yz \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
// No content
Endpoints

Webhooks

Configure where events are sent, rotate the signing secret, and inspect or replay deliveries.

Retrieve webhook settings

GET/v1/webhookAPI key

Returns the webhook URL (empty until set) and the signing secret. To rotate the secret, use the dashboard.

Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

GET /v1/webhook
curl https://api.paytrack.pesagrid.co.ke/v1/webhook \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
{
  "url": "https://acme.co.ke/hooks/paytrack",
  "signing_secret": "whsec_4c1f9a0e7b2d8c3f6a5e1b9d0c7f2a8e4b6d1c3f5a7e9b0d"
}

Set the webhook URL

PUT/v1/webhookAPI key

Sets the URL events are POSTed to. Takes effect for the next attempt, including retries of earlier events.

Body parameters
urlstringrequired
Absolute https:// URL (plain http:// is accepted but not recommended). No credentials in the URL.
Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

PUT /v1/webhook
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"
  }'
Response
{
  "url": "https://acme.co.ke/hooks/paytrack",
  "signing_secret": "whsec_4c1f9a0e7b2d8c3f6a5e1b9d0c7f2a8e4b6d1c3f5a7e9b0d"
}

List deliveries

GET/v1/webhook/deliveriesAPI key

Every attempt to send an event to you, newest first, with cursor pagination.

Query parameters
payment_idstringoptional
Only deliveries for this payment.
statusenumoptional
pending, delivering, delivered or failed.
limitintegeroptional
1–100. Default 25.
cursorstringoptional
next_cursor from the previous page.
Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

GET /v1/webhook/deliveries
curl https://api.paytrack.pesagrid.co.ke/v1/webhook/deliveries?payment_id=pay_01k7x5g3r4s5t6v7w8x9y0z1ab \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
{
  "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

POST/v1/webhook/deliveries/{id}/redeliverAPI key

Queues the delivery again immediately with a fresh retry budget. The same event ID is sent, so your deduplication keeps it safe.

Path parameters
idstringrequired
The delivery ID (whd_…).
Returns

A 202 Accepted response with the JSON shown. Errors use the standard error object.

POST /v1/webhook/deliveries/{id}/redeliver
curl -X POST https://api.paytrack.pesagrid.co.ke/v1/webhook/deliveries/whd_01k7x5h8c0d1e2f3g4h5j6k7mn/redeliver \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
{
  "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"
    }
  }
}
Endpoints

Account

Check which account an API key belongs to.

Verify your API key

GET/v1/meAPI key

Returns the account the API key belongs to. Call it at startup or in a health check to confirm the key is valid.

Returns

A 200 OK response with the JSON shown. Errors use the standard error object.

GET /v1/me
curl https://api.paytrack.pesagrid.co.ke/v1/me \
  -H "Authorization: Bearer $PAYTRACK_API_KEY"
Response
{
  "tenant": {
    "id": "ten_01k7x2c9m4q8r6t3v5w7y9z1ab",
    "name": "Acme Shop",
    "created_at": "2026-10-01T08:00:00Z"
  },
  "user": null,
  "auth_kind": "api_key"
}