API documentation
Everything you need to collect mobile money payments from your own website, app or backend, and to understand where the money goes.
Introduction
This gateway lets you charge customers through mobile money STK Push. Your server asks us to request a payment from a phone number. The customer receives a prompt, enters their mobile money PIN, and the mobile network tells us the result. We credit the payment to your wallet, notify your server, and you can withdraw the balance to any mobile money number.
The API is a small, predictable REST interface. Requests and responses are JSON, and you authenticate with a bearer token.
https://harakapaye.co.ke/api/v1JSON over HTTPSKES, whole shillings120 requests/minute per keyKeep the secret key on your server. Never put it in a website’s JavaScript, a mobile app or a public repository. Browsers and phones should call your own backend, which then calls this API.
Quickstart
You can take a test payment in about five minutes.
Create an account
Sign up, or sign in. New accounts may need approval from the gateway administrator before they can send payments.
Generate an API key
Open API keys, give the key a label (for example the site it belongs to) and press Generate key. You get three values in one place, each with a copy button, plus a ready-made
.envblock. The secret key is shown only once, so copy it immediately.Store the credentials on your server
Put them in environment variables, not in your code:
GATEWAY_API_URL=https://harakapaye.co.ke/api/v1 GATEWAY_PUBLIC_KEY=pk_live_xxxxxxxxxxxxxxxxxxxx GATEWAY_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx GATEWAY_SIGNING_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxSend a payment request
Call
POST /stk/pushwith the customer’s phone and an amount. Use your own order id asreference.curl -X POST https://harakapaye.co.ke/api/v1/stk/push \ -H "Authorization: Bearer $GATEWAY_SECRET_KEY" \ -H "X-Public-Key: $GATEWAY_PUBLIC_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "0712345678", "amount": 1500, "reference": "ORDER-1042", "description": "Order 1042" }'<?php $ch = curl_init(getenv('GATEWAY_API_URL') . '/stk/push'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('GATEWAY_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'phone' => '0712345678', 'amount' => 1500, 'reference' => 'ORDER-1042', // your own order id 'description' => 'Order 1042', ]), ]); $res = json_decode(curl_exec($ch), true); if ($res['success']) { $gatewayRef = $res['data']['reference']; // store this next to your order } else { error_log('STK push failed: ' . $res['error']); }const res = await fetch(`${process.env.GATEWAY_API_URL}/stk/push`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.GATEWAY_SECRET_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ phone: '0712345678', amount: 1500, reference: 'ORDER-1042', // your own order id description: 'Order 1042', }), }); const body = await res.json(); if (body.success) { console.log('Payment requested:', body.data.reference); } else { console.error('STK push failed:', body.error); }import os, requests res = requests.post( os.environ["GATEWAY_API_URL"] + "/stk/push", headers={"Authorization": "Bearer " + os.environ["GATEWAY_SECRET_KEY"]}, json={ "phone": "0712345678", "amount": 1500, "reference": "ORDER-1042", # your own order id "description": "Order 1042", }, timeout=30, ) body = res.json() if body["success"]: print("Payment requested:", body["data"]["reference"]) else: print("STK push failed:", body["error"])Learn the result
Either wait for a webhook (recommended) or poll the payment. When the status becomes
success, fulfil the order.Withdraw your money
Go to Withdrawals, enter an amount and a mobile money number, and confirm with your password. See Withdrawals.
How a payment works
- Your server calls
POST /stk/pushwith the phone, amount and your order reference. - The gateway validates the request, records a
pendingpayment under your account and asks the mobile network to send the prompt. - The customer sees the payment prompt on their phone and enters their PIN (or cancels).
- The mobile network calls the gateway with the outcome. If the call is missed, the gateway asks the network directly.
- The gateway sets the final status. On success it credits your wallet with the full amount and debits the fee as a separate entry.
- Your server receives a signed webhook (or sees the new status when it polls) and fulfils the order.
A payment moves through these states only once. It starts as pending and ends as exactly one of success, failed, cancelled or expired.
Authentication
Each API key has three credentials. They are created together and are shown together on the API keys page.
| Credential | Looks like | Purpose | Secret? |
|---|---|---|---|
| Public key | pk_live_… | Identifies the key. You may send it in the X-Public-Key header; if you do, it must match the secret key. It also appears in webhook headers so you can tell which key a webhook belongs to. | No, safe to log |
| Secret key | sk_live_… | Authenticates API requests as a bearer token. Anyone holding it can create payments on your account. | Yes. Shown once; only a hash is stored |
| Signing secret | whsec_… | Verifies that a webhook really came from the gateway (HMAC-SHA256). Never used to call the API. | Yes. Can be viewed again on the keys page |
Sending the key
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-Public-Key: pk_live_xxxxxxxxxxxxxxxxxxxx (optional)
Content-Type: application/jsonGood practice
- One key per site or service. If one is compromised you revoke only that key.
- Restrict by IP. Add your server’s addresses when you create the key. Requests from other addresses get
403. - Never expose the secret key in front-end code, logs, screenshots or version control.
- Revoke on suspicion. Revoking is instant. Create a replacement key first to avoid downtime.
A missing, malformed or revoked key returns 401. A key that belongs to a suspended or unapproved account returns 403.
Conventions
| Topic | Details |
|---|---|
| Request body | Send JSON with Content-Type: application/json. Form-encoded bodies are accepted for POST /stk/push too. |
| Response envelope | Every response has success (true/false). Successful responses carry data. Failures carry error, a message written for humans. |
| Amounts | Whole Kenya shillings as integers (1500 means KES 1,500). Decimals are not supported by STK Push. Allowed range: KES 1 to KES 250,000 per payment. |
| Phone numbers | Mobile numbers in any of these forms: 0712345678, 0112345678, 712345678, 254712345678, +254 712 345 678. We normalise them to 2547XXXXXXXX and return that form. |
| Your reference | reference is your own order or invoice id (up to 64 characters). It makes requests idempotent and lets you look a payment up later by your own id. |
| Timestamps | Format YYYY-MM-DD HH:MM:SS in East Africa Time (Africa/Nairobi). |
| Rate limits | 120 requests per minute per key. Beyond that you get 429; wait a few seconds and retry. |
| Idempotency | If you send a reference that already has a pending or success payment, we do not create another one. We return the existing payment with duplicate: true and HTTP 200. A failed, cancelled or expired attempt can be retried with the same reference. |
Create a payment
POST /api/v1/stk/push
Starts an STK Push. The customer receives a payment prompt within a few seconds. The payment is created as pending; the final result arrives by webhook or polling.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Yes | Customer’s mobile number in any supported format. |
amount | integer | Yes | Whole shillings, between 1 and 250,000. |
reference | string | Recommended | Your own order id (max 64 chars). Enables idempotency and lookups by your id. |
description | string | No | Short note stored with the payment (max 60 chars). Some handsets show only the first 13 characters. |
callback_url | string | No | Webhook URL for this payment only. Overrides the webhook URL saved in your settings. |
Example request
curl -X POST https://harakapaye.co.ke/api/v1/stk/push \
-H "Authorization: Bearer $GATEWAY_SECRET_KEY" \
-H "X-Public-Key: $GATEWAY_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "0712345678",
"amount": 1500,
"reference": "ORDER-1042",
"description": "Order 1042"
}'<?php
$ch = curl_init(getenv('GATEWAY_API_URL') . '/stk/push');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('GATEWAY_SECRET_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'phone' => '0712345678',
'amount' => 1500,
'reference' => 'ORDER-1042', // your own order id
'description' => 'Order 1042',
]),
]);
$res = json_decode(curl_exec($ch), true);
if ($res['success']) {
$gatewayRef = $res['data']['reference']; // store this next to your order
} else {
error_log('STK push failed: ' . $res['error']);
}const res = await fetch(`${process.env.GATEWAY_API_URL}/stk/push`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GATEWAY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone: '0712345678',
amount: 1500,
reference: 'ORDER-1042', // your own order id
description: 'Order 1042',
}),
});
const body = await res.json();
if (body.success) {
console.log('Payment requested:', body.data.reference);
} else {
console.error('STK push failed:', body.error);
}import os, requests
res = requests.post(
os.environ["GATEWAY_API_URL"] + "/stk/push",
headers={"Authorization": "Bearer " + os.environ["GATEWAY_SECRET_KEY"]},
json={
"phone": "0712345678",
"amount": 1500,
"reference": "ORDER-1042", # your own order id
"description": "Order 1042",
},
timeout=30,
)
body = res.json()
if body["success"]:
print("Payment requested:", body["data"]["reference"])
else:
print("STK push failed:", body["error"])Response 201 Created
{
"success": true,
"duplicate": false,
"message": "STK push sent. Customer should enter their mobile money PIN.",
"data": {
"reference": "KP3F9A1C0B7D",
"external_reference": "ORDER-1042",
"status": "pending",
"amount": 1500,
"fee": 0,
"net": 0,
"phone": "254712345678",
"receipt_code": null,
"message": null,
"checkout_request_id": "ws_CO_04112026101530123712345678",
"created_at": "2026-11-04 10:15:30",
"completed_at": null
}
}Store data.reference next to your order. Fees and net are filled in when the payment succeeds.
Response when the reference already exists 200 OK
Same shape, with "duplicate": true and the existing payment in data. Do not ask the customer to pay again; check the status of that payment.
Possible errors
| HTTP | When | Example message |
|---|---|---|
401 | Missing, wrong or revoked key | Invalid or revoked API key |
403 | IP not on the allowlist, or account inactive | Request IP is not on this key’s allowlist |
422 | Bad phone, amount outside limits, bad callback URL | Invalid phone number. Use 07XXXXXXXX or 2547XXXXXXXX |
429 | Rate limit or your daily collection limit | Daily collection limit reached for this account |
502 | The network rejected or could not process the push | The payment record is kept as failed with the reason |
Get a payment
GET /api/v1/payments/{reference}
Returns one payment. {reference} can be the gateway reference (KP3F9A1C0B7D) or your own reference (ORDER-1042). If a payment has been pending for more than about 20 seconds, this call also asks the network for the outcome, so polling recovers from a missed callback.
curl https://harakapaye.co.ke/api/v1/payments/ORDER-1042 \
-H "Authorization: Bearer $GATEWAY_SECRET_KEY"<?php
$ch = curl_init(getenv('GATEWAY_API_URL') . '/payments/ORDER-1042');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('GATEWAY_SECRET_KEY')],
]);
$res = json_decode(curl_exec($ch), true);
echo $res['data']['status']; // pending | success | failed | cancelled | expiredconst r = await fetch(`${process.env.GATEWAY_API_URL}/payments/ORDER-1042`, {
headers: { Authorization: `Bearer ${process.env.GATEWAY_SECRET_KEY}` },
});
const { data } = await r.json();
console.log(data.status); // pending | success | failed | cancelled | expiredResponse for a paid order 200 OK
{
"success": true,
"data": {
"reference": "KP3F9A1C0B7D",
"external_reference": "ORDER-1042",
"status": "success",
"amount": 1500,
"fee": 75,
"net": 1425,
"phone": "254712345678",
"receipt_code": "QGH7X2K9LP",
"message": "The service request is processed successfully.",
"created_at": "2026-11-04 10:15:30",
"completed_at": "2026-11-04 10:15:52"
}
}Polling tips: poll every 3 to 5 seconds for up to two minutes, then stop and treat the order as unpaid until a webhook says otherwise. Prefer webhooks in production; polling is a fallback and for pages that show a live “waiting for payment” screen. Unknown references return 404.
Get your balance
GET /api/v1/balance
Returns the amount available to withdraw now: collected payments, minus fees, minus withdrawals in progress or paid, plus or minus any adjustments by the administrator.
{
"success": true,
"data": { "balance": 48250, "currency": "KES" }
}Payment statuses
| Status | Meaning | What to do |
|---|---|---|
| pending | The prompt was sent and we are waiting for the customer. | Wait for the webhook or poll. Do not fulfil the order yet. |
| success | Paid. The wallet was credited and a receipt code is available in receipt_code. | Fulfil the order. |
| failed | The payment did not complete: wrong PIN, insufficient funds, or an error. message says why. | Show the reason and let the customer retry. |
| cancelled | The customer dismissed the prompt. | Offer to resend the request. |
| expired | No answer arrived in time (the phone was off, out of coverage, or the prompt was ignored). | Offer to resend. A late success still credits the wallet and updates the status. |
Fees and your wallet
The current collection fee is 5% per successful payment (your account may have a custom rate). Failed, cancelled and expired payments are free. Withdrawals cost KES 10 each, with a minimum of KES 100.
Worked example
A customer pays KES 1,500. We credit KES 1,500 to your wallet and debit a fee of KES 75, so your net is KES 1,425. Both entries appear in your ledger and on the payment: amount: 1500, fee: 75, net: 1425.
The customer’s money settles in the gateway’s collection account. Your wallet balance is the part of that money which belongs to you. Every credit and debit is recorded in an append-only ledger so the balance can always be explained.
Webhooks
A webhook is an HTTP POST from us to your server when a payment reaches a final status. It is the most reliable way to learn about payments because it works even if the customer closes their browser.
Set it up
- Save a URL under Settings → Webhook (used for every payment), or pass
callback_urlon a single request. - The URL must be public and reachable over HTTP or HTTPS. Use HTTPS in production.
- Respond with any 2xx status within 8 seconds. Do your slow work after responding.
Events
| Event | Sent when |
|---|---|
payment.success | The customer paid and your wallet was credited. |
payment.failed | The payment failed (wrong PIN, insufficient funds, error). |
payment.cancelled | The customer cancelled the prompt. |
payment.expired | No response arrived in time. |
Headers we send
| Header | Value |
|---|---|
X-Gateway-Event | The event name, for example payment.success. |
X-Gateway-Timestamp | Unix time in seconds when the webhook was signed. |
X-Gateway-Signature | sha256= followed by the hex HMAC. See Verify signatures. |
X-Gateway-Key-Id | The public key of the API key that created the payment (absent for payments sent from the dashboard). |
Content-Type | application/json |
Payload
{
"id": "evt_KP3F9A1C0B7D_success",
"event": "payment.success",
"data": {
"reference": "KP3F9A1C0B7D",
"external_reference": "ORDER-1042",
"status": "success",
"amount": 1500,
"fee": 75,
"net": 1425,
"phone": "254712345678",
"receipt_code": "QGH7X2K9LP",
"message": "The service request is processed successfully.",
"completed_at": "2026-11-04 10:15:52"
}
}Delivery and retries
- Each webhook is attempted when the payment completes. If your server does not answer with a 2xx, it is retried automatically every minute, up to 5 attempts.
- Each payment shows its webhook status (
deliveredorfailed) on the Payments page, and you can press Resend webhook at any time. - The same event can arrive more than once. Use the
idfield to make your handler idempotent. - Webhooks may arrive out of order. Trust the
statusin the payload, and confirm withGET /payments/{reference}if you need certainty.
Verify signatures
Anyone can send a request to your webhook URL, so always verify the signature before trusting it. We sign the string timestamp + "." + raw_body with HMAC-SHA256 using your key’s signing secret.
- Read the raw request body without parsing or reformatting it.
- Take
X-Gateway-Timestampand buildtimestamp + "." + body. - Compute the HMAC-SHA256 hex digest with your signing secret and prefix it with
sha256=. - Compare it to
X-Gateway-Signaturewith a constant-time comparison. - Reject the request if the timestamp is more than 5 minutes old (replay protection).
<?php
$secret = getenv('GATEWAY_SIGNING_SECRET');
$body = file_get_contents('php://input'); // the RAW body, exactly as received
$ts = $_SERVER['HTTP_X_GATEWAY_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
http_response_code(401); // reject: forged or replayed
exit;
}
$event = json_decode($body, true);
if ($event['event'] === 'payment.success') {
// Look up the order by $event['data']['external_reference'], mark it paid.
// Use $event['id'] to ignore duplicates if the same event arrives twice.
}
http_response_code(200); // any 2xx = deliveredconst crypto = require('crypto');
const express = require('express');
const app = express();
// Keep the RAW body: signatures are computed over the exact bytes we sent
app.post('/payment-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.header('X-Gateway-Timestamp') || '';
const sig = req.header('X-Gateway-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.GATEWAY_SIGNING_SECRET)
.update(ts + '.' + req.body.toString('utf8'))
.digest('hex');
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
if (event.event === 'payment.success') {
// mark event.data.external_reference as paid (ignore repeats of event.id)
}
res.sendStatus(200);
});import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/payment-webhook")
def webhook():
raw = request.get_data() # RAW body bytes
ts = request.headers.get("X-Gateway-Timestamp", "")
sig = request.headers.get("X-Gateway-Signature", "")
expected = "sha256=" + hmac.new(
os.environ["GATEWAY_SIGNING_SECRET"].encode(),
ts.encode() + b"." + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig) or abs(time.time() - int(ts or 0)) > 300:
abort(401)
event = request.get_json()
if event["event"] == "payment.success":
pass # mark event["data"]["external_reference"] as paid
return "", 200Which secret? Payments created with an API key are signed with that key’s signing secret. Payments sent from the dashboard form are signed with the account secret shown on Settings.
Withdrawals
Withdrawals are made from your dashboard, not through the API, so that moving money always involves a person.
- Open Withdrawals, enter an amount and any mobile money number, and confirm with your password.
- The amount plus the withdrawal fee leaves your balance immediately, so it cannot be spent twice. The request shows as
pending. - The gateway administrator reviews it and may mark it
approved. - The administrator sends the money to the number and records the receipt. The request becomes
paidand the receipt code appears in your history. - If the request is
rejected, the amount and fee are returned to your wallet and a note explains why.
Verification (KYC) and support
To keep the platform safe, merchants verify their identity by uploading three photos: the front and back of an ID (National ID, passport or driving licence) and a selfie holding that ID, on the Verification page. An administrator reviews the documents and approves or rejects them.
| When | What happens |
|---|---|
| Your successful deposits exceed KES 1,000 | Verification becomes mandatory. You see a notice on every page and withdrawals are paused until your ID is approved. |
| An administrator asks you to verify (including random compliance checks) | You are asked to upload your ID. Withdrawals pause until it is approved. |
| You do not verify | An administrator can suspend the account. A suspended account cannot create payments through the API (403), but you can still sign in to upload your ID and to use Support. |
| Your ID is approved | The account is reactivated automatically if it was suspended for KYC, and withdrawals unlock. |
| Your ID is rejected | The reason is shown on the Verification page. Upload clearer documents and submit again. |
Need help? Open the Support page to start a conversation with the gateway team. Replies appear in the same thread.
Testing
Simulation mode
When the administrator runs the gateway in Simulation mode, no real prompts are sent. Payments stay pending until the administrator marks them paid or failed, which exercises your whole flow: API call, status polling, webhook and wallet credit.
Sandbox
In Sandbox mode the gateway talks to the network’s test environment using a test shortcode. Use the test phone numbers that come with your test credentials; real handsets do not receive prompts.
Live, with small amounts
Before launch, make a few real payments of KES 1 to 10 from your own phone. Check that the status turns success, the wallet balance rises, your webhook arrives and verifies, and a withdrawal completes.
Suggested test checklist
- Successful payment, wrong PIN and cancelled prompt.
- Retrying a payment with the same
reference(should be idempotent). - Webhook delivered while your server is down, then resent.
- A webhook with a tampered body is rejected by your verifier.
Errors
Errors use standard HTTP status codes and this body:
{
"success": false,
"error": "Invalid phone number. Use 07XXXXXXXX or 2547XXXXXXXX"
}| HTTP | Meaning | Typical fix |
|---|---|---|
200/201 | Success (201 when a new payment was created). | — |
401 | Missing, malformed or revoked key, or X-Public-Key mismatch. | Check the Authorization: Bearer sk_… header and that the key is active. |
403 | IP not allowed or account not active. | Add your server IP to the key, or ask the administrator to approve the account. |
404 | Unknown endpoint or payment. | Check the path and reference. |
405 | Wrong HTTP method. | Use POST for /stk/push. |
422 | Validation failed. | Fix the phone, amount or callback URL named in the message. |
429 | Rate limit or daily collection limit reached. | Back off and retry, or ask for a higher limit. |
502 | The network could not send the prompt. | Retry shortly. The payment record is kept as failed. |
500 | Unexpected server error. | Retry with the same reference; it is safe. |
Payment result codes
When a payment does not succeed, message carries the network’s explanation. These are the common outcomes:
| Code | Meaning | Status you will see |
|---|---|---|
0 | Success | success |
1 | Customer’s wallet balance is insufficient | failed |
2001 | Wrong PIN entered | failed |
1001 | Another transaction is already in progress for that customer | failed |
1019 | The transaction expired | failed |
1025 | The prompt could not be sent to the phone | failed |
1032 | Customer cancelled the prompt | cancelled |
1037 | Timed out waiting for the customer’s phone | expired |
More codes may be added over time. Always treat any status other than success as unpaid.
Go-live checklist
- Secret key and signing secret stored in server environment variables, not in code or Git.
- An IP allowlist set on the live key.
- Webhook URL is HTTPS, verifies the signature and replies 2xx quickly.
- Webhook handler is idempotent (uses
idor your order state). - Orders are fulfilled only on
success, never on a browser redirect. - Your site shows a clear “check your phone” screen and handles failed, cancelled and expired outcomes.
- You made at least one small live payment and one withdrawal end to end.
- You know who to contact for support (ask the gateway administrator).
Troubleshooting
- I get 401 even though my key is right.
- Make sure the header is exactly
Authorization: Bearer sk_live_…with one space and no quotes. Some servers strip the header; if you host this gateway yourself, check the.htaccessrule that passes it through. A revoked key also returns 401. - The customer never gets a prompt.
- Check the number belongs to a supported mobile network, that the phone has coverage, and that the status is not
failedwith a reason. If you run the gateway yourself, verify the mobile money API credentials with Test API connection in Gateway settings. - A payment is pending for a long time.
- The customer may not have answered. After a few minutes it becomes
expired. If they did pay, the gateway will still credit it when the network reports success. - My webhook is not arriving.
- Confirm the URL is public (not localhost), responds in under 8 seconds with a 2xx, and is not blocked by a firewall. Use Resend webhook on the Payments page and check the status shown there.
- Signature verification always fails.
- You must hash the raw body, not a re-serialised JSON object, and use the signing secret of the key that created the payment. Build the string as
timestamp + "." + bodyand compare against the full header value includingsha256=. - I sent the same reference twice and got
duplicate: true. - That is intentional: it protects the customer from a double charge. Use a new reference for a new order, or check the status of the existing payment.