Authentication
Every Pesapal API call except RequestToken itself needs a bearer token.
You exchange your consumer key and secret for one, then send it in the
Authorization header.
Two things about this endpoint are worth knowing before you write any code:
Get a Token
Section titled “Get a Token”-
Get your consumer and secret keys from the Pesapal demo keys page. Sandbox credentials are published publicly, so you can start without an account.
-
Exchange them for a token.
Terminal window curl --request POST \--url https://cybqa.pesapal.com/pesapalv3/api/Auth/RequestToken \--header 'Accept: application/json' \--header 'Content-Type: application/json' \--data '{"consumer_key": "YOUR_CONSUMER_KEY","consumer_secret": "YOUR_CONSUMER_SECRET"}'const url = 'https://cybqa.pesapal.com/pesapalv3/api/Auth/RequestToken';const options = {method: 'POST',headers: {Accept: 'application/json', 'Content-Type': 'application/json'},body: '{"consumer_key":"YOUR_CONSUMER_KEY","consumer_secret":"YOUR_CONSUMER_SECRET"}'};try {const response = await fetch(url, options);const data = await response.json();console.log(data);} catch (error) {console.error(error);}import requestsurl = "https://cybqa.pesapal.com/pesapalv3/api/Auth/RequestToken"payload = {"consumer_key": "YOUR_CONSUMER_KEY","consumer_secret": "YOUR_CONSUMER_SECRET"}headers = {"Accept": "application/json","Content-Type": "application/json"}response = requests.post(url, json=payload, headers=headers)print(response.json())<?php$curl = curl_init();curl_setopt_array($curl, [CURLOPT_URL => "https://cybqa.pesapal.com/pesapalv3/api/Auth/RequestToken",CURLOPT_RETURNTRANSFER => true,CURLOPT_ENCODING => "",CURLOPT_MAXREDIRS => 10,CURLOPT_TIMEOUT => 30,CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,CURLOPT_CUSTOMREQUEST => "POST",CURLOPT_POSTFIELDS => json_encode(['consumer_key' => 'YOUR_CONSUMER_KEY','consumer_secret' => 'YOUR_CONSUMER_SECRET']),CURLOPT_HTTPHEADER => ["Accept: application/json","Content-Type: application/json"],]);$response = curl_exec($curl);$err = curl_error($curl);curl_close($curl);if ($err) {echo "cURL Error #:" . $err;} else {echo $response;}package mainimport ("fmt""strings""net/http""io")func main() {url := "https://cybqa.pesapal.com/pesapalv3/api/Auth/RequestToken"payload := strings.NewReader("{\n \"consumer_key\": \"YOUR_CONSUMER_KEY\",\n \"consumer_secret\": \"YOUR_CONSUMER_SECRET\"\n}")req, _ := http.NewRequest("POST", url, payload)req.Header.Add("Accept", "application/json")req.Header.Add("Content-Type", "application/json")res, _ := http.DefaultClient.Do(req)defer res.Body.Close()body, _ := io.ReadAll(res.Body)fmt.Println(res)fmt.Println(string(body))} -
A successful response looks like this:
{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI...","expiryDate": "2026-09-03T12:08:08.5585879Z","error": null,"status": "200","message": "Request processed successfully"} -
Send the token as a bearer header on subsequent calls:
Authorization: Bearer YOUR_ACCESS_TOKEN
Token Lifetime
Section titled “Token Lifetime”expiryDate is exactly one hour after the request which you can confirm by
decoding the JWT and substracting iat from exp to get 3600:
{ "uid": "qkio1BGGYAXTu2JOfm7XSXNruoZsrqEW", "iat": 1788432786, "exp": 1788436386, "iss": "http://cybqa.pesapal.com/", "aud": "http://cybqa.pesapal.com/"}exp minus iat is 3600.
Caching the Token
Section titled “Caching the Token”Request one token and reuse it until shortly before it expires. Requesting a new token everytime is unnecessary:
let cached = null;
async function getToken() { // Refresh a minute early, so a request in flight cannot expire mid-call. if (cached && cached.expiresAt - 60_000 > Date.now()) { return cached.token; }
const res = await fetch(`${BASE_URL}/Auth/RequestToken`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ consumer_key: process.env.PESAPAL_CONSUMER_KEY, consumer_secret: process.env.PESAPAL_CONSUMER_SECRET, }), });
const body = await res.json(); if (!body.token) throw new Error(body.error?.message ?? "Authentication failed");
// exp is seconds since epoch, and avoid parsing expiryDate const { exp } = JSON.parse(atob(body.token.split('.')[1]));
cached = { token: body.token, expiresAt: exp * 1000 }; return cached.token;}Note the check on body.token rather than on res.ok or body.error. This API
returns HTTP 200 for authentication failures, so the transport status tells you
nothing. See Errors.
When authentication fails
Section titled “When authentication fails”Getting a token fails with HTTP 200 and an error body:
| Cause | error_type |
code |
message |
|---|---|---|---|
| Wrong key or secret | api_error |
invalid_consumer_key_or_secret_provided |
Invalid Access Credentials provided |
| Missing fields | invalid_request_error |
invalid_api_request_parameters |
Consumer Key is required|Consumer Secret is required |
Using a token in endpoints that require it fails differently, and inconsistently:
| Cause | HTTP | Body |
|---|---|---|
No Authorization header |
401 | error encoded as a JSON string inside message |
| Invalid or expired token | 500 | Standard error object, status: null |
Misleading Errors
Section titled “Misleading Errors”Three unrelated mistakes all produce a message about your credentials:
| What you actually did wrong | What the API says |
|---|---|
| Sent malformed JSON | ||Consumer Key is required|Consumer Secret is required |
| Sent form-encoded instead of JSON | Consumer Key is required|Consumer Secret is required |
| Genuinely omitted the fields | Consumer Key is required|Consumer Secret is required |
Only the third is honest. A JSON syntax error is not reported as a parse error, and a form-encoded body is accepted by content type and then silently fails to bind. In both cases the API tells you your credentials are missing when your credentials were fine.
Multiple validation errors arrive pipe-delimited in a single string rather than as an array, so splitting is up to you, and empty segments need filtering:
const problems = body.error.message.split('|').filter(Boolean);// ["Consumer Key is required", "Consumer Secret is required"]Reference
Section titled “Reference”POST /Auth/RequestToken. Full schemas and examples are in the
API reference.
Request
Section titled “Request”| Field | Type | Required | Notes |
|---|---|---|---|
consumer_key |
string | Yes | From the Pesapal demo keys page. |
consumer_secret |
string | Yes | From the Pesapal demo keys page. |
Content-Type: application/json is mandatory. Omitting it returns HTTP 415.
Response
Section titled “Response”| Field | Type | Notes |
|---|---|---|
token |
string | JWT. Send as Authorization: Bearer <token>. |
expiryDate |
string | UTC with a Z and seven fractional digits. One hour after issue. |
error |
object or null | null on success. |
status |
string | "200" on success. A quoted string, not a number. |
message |
string | "Request processed successfully" on success. |
Errors
Section titled “Errors”code |
Meaning |
|---|---|
invalid_consumer_key_or_secret_provided |
The key or secret is wrong. |
invalid_api_request_parameters |
Fields missing, or the body did not parse. See misleading errors. |
invalid_api_credentials_provided |
The token you sent is missing or invalid. Returned by other endpoints, not this one. |