Accept Payroon in minutes
A simple API: you create a payment, the customer pays on the Payroon page, you receive a signed webhook. Examples in 8 languages.
1Quickstart
2Authentication
Send your secret key in the Authorization header. Test keys start with sk_test_ and never move money. Live keys start with sk_live_ and switch on once your business is approved. Never put a secret key in a mobile app or a web page: it stays on your server.
Authorization: Bearer sk_test_a1b2c3... (test key)
Authorization: Bearer sk_live_d4e5f6... (live key)3Create a payment
The amount is in whole units of your merchant account currency (for example 5000 for 5,000 XAF). Add an Idempotency-Key header: if your request is repeated you get the same payment back, never a duplicate.
curl https://payroon.com/api/gateway.php?action=create_payment \
-H "Authorization: Bearer sk_test_YOUR_KEY" \
-H "Idempotency-Key: order-1042-attempt-1" \
-H "Content-Type: application/json" \
-d '{"amount": 5000, "reference": "order-1042", "description": "2 x T-shirt",
"return_url": "https://yourshop.com/thanks", "cancel_url": "https://yourshop.com/cart"}'$ch = curl_init('https://payroon.com/api/gateway.php?action=create_payment');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('PAYROON_SECRET_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['amount' => 5000, 'reference' => 'order-1042',
'return_url' => 'https://yourshop.com/thanks', 'cancel_url' => 'https://yourshop.com/cart'])]);
$pay = json_decode(curl_exec($ch), true)['data'];
header('Location: ' . $pay['checkout_url']);const res = await fetch('https://payroon.com/api/gateway.php?action=create_payment', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYROON_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 5000, reference: 'order-1042',
return_url: 'https://yourshop.com/thanks', cancel_url: 'https://yourshop.com/cart' })
});
const { data } = await res.json();
response.redirect(data.checkout_url);import os, requests
r = requests.post('https://payroon.com/api/gateway.php?action=create_payment',
headers={'Authorization': 'Bearer ' + os.environ['PAYROON_SECRET_KEY']},
json={'amount': 5000, 'reference': 'order-1042',
'return_url': 'https://yourshop.com/thanks', 'cancel_url': 'https://yourshop.com/cart'})
checkout_url = r.json()['data']['checkout_url']require 'net/http'; require 'json'
uri = URI('https://payroon.com/api/gateway.php?action=create_payment')
req = Net::HTTP::Post.new(uri, 'Authorization' => "Bearer #{ENV['PAYROON_SECRET_KEY']}", 'Content-Type' => 'application/json')
req.body = { amount: 5000, reference: 'order-1042', return_url: 'https://yourshop.com/thanks' }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
checkout_url = JSON.parse(res.body)['data']['checkout_url']body := strings.NewReader(`{"amount":5000,"reference":"order-1042","return_url":"https://yourshop.com/thanks"}`)
req, _ := http.NewRequest("POST", "https://payroon.com/api/gateway.php?action=create_payment", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("PAYROON_SECRET_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
var out struct{ Data struct{ CheckoutURL string `json:"checkout_url"` } `json:"data"` }
json.NewDecoder(resp.Body).Decode(&out)HttpRequest req = HttpRequest.newBuilder(URI.create("https://payroon.com/api/gateway.php?action=create_payment"))
.header("Authorization", "Bearer " + System.getenv("PAYROON_SECRET_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"amount\":5000,\"reference\":\"order-1042\",\"return_url\":\"https://yourshop.com/thanks\"}"))
.build();
String json = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body();
// parse json -> data.checkout_url with your JSON libraryvar http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("PAYROON_SECRET_KEY"));
var body = new StringContent("{\"amount\":5000,\"reference\":\"order-1042\",\"return_url\":\"https://yourshop.com/thanks\"}", Encoding.UTF8, "application/json");
var res = await http.PostAsync("https://payroon.com/api/gateway.php?action=create_payment", body);
var checkoutUrl = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement.GetProperty("data").GetProperty("checkout_url").GetString();Response
{ "success": true, "data": {
"id": "pay_9f2c1a7e5b3d4c6a8e0f1b2d", "object": "payment", "mode": "test", "status": "pending",
"amount": 5000, "currency": "XAF", "reference": "order-1042",
"checkout_url": "https://payroon.com/pay?id=pay_9f2c1a7e5b3d4c6a8e0f1b2d",
"expires_at": "2026-11-01T10:30:00+00:00", "paid_at": null } }4Checkout page and return
Redirect your customer to checkout_url. After paying they return to your return_url with ?payroon_payment=pay_… added. On that page the customer picks Payroon (wallet), MTN MoMo or Orange Money (no Payroon account needed). MTN and Orange payments land in your merchant balance. A fee per payment method applies; in your settings (or per payment with fee_payer) you choose whether the customer or you pays it. You can cash out to MTN or Orange straight from your merchant balance, for free. Never fulfil an order on that redirect alone: wait for the webhook or call get_payment, because a customer can close the page before coming back.
5Webhooks
Save your https webhook URL in the merchant portal. Payroon sends a JSON POST for every event. Reply with a 2xx status. Otherwise we retry automatically: after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. You can also resend any event by hand from the portal.
Events
payment.createdA payment was just created.payment.succeededThe customer paid: fulfil the order.payment.expiredThe link expired without payment.payment.cancelledThe payment was cancelled.Example event
{ "id": "evt_4a1b...", "object": "event", "type": "payment.succeeded", "created": 1793000000, "mode": "live",
"data": { "payment": { "id": "pay_9f2c...", "status": "paid", "amount": 5000, "currency": "XAF", "reference": "order-1042", "paid_at": "2026-11-01T10:12:09+00:00" } } }6Verify the signature
Every request carries the header Payroon-Signature: t=<timestamp>,v1=<signature>. Compute HMAC-SHA256 of "t.body" with your webhook secret (whsec_…), compare in constant time and reject timestamps older than 5 minutes. Use the raw body, not re-encoded JSON.
$body = file_get_contents('php://input');
preg_match('/t=(\d+),v1=([a-f0-9]+)/', $_SERVER['HTTP_PAYROON_SIGNATURE'] ?? '', $m);
$ok = $m && abs(time() - (int)$m[1]) < 300
&& hash_equals(hash_hmac('sha256', $m[1] . '.' . $body, getenv('PAYROON_WEBHOOK_SECRET')), $m[2]);
if (!$ok) { http_response_code(400); exit; }
$event = json_decode($body, true);
if ($event['type'] === 'payment.succeeded') { /* fulfil order $event['data']['payment']['reference'] once */ }
http_response_code(200);const crypto = require('crypto'); // use the RAW body
app.post('/payroon-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const m = /t=(\d+),v1=([a-f0-9]+)/.exec(req.get('Payroon-Signature') || '');
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return res.sendStatus(400);
const expected = crypto.createHmac('sha256', process.env.PAYROON_WEBHOOK_SECRET).update(m[1] + '.' + req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]))) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (event.type === 'payment.succeeded') { /* fulfil event.data.payment.reference once */ }
res.sendStatus(200);
});import hmac, hashlib, os, re, time, json
from flask import Flask, request, abort
@app.post('/payroon-webhook')
def webhook():
body = request.get_data()
m = re.match(r't=(\d+),v1=([a-f0-9]+)', request.headers.get('Payroon-Signature', ''))
if not m or abs(time.time() - int(m[1])) > 300: abort(400)
expected = hmac.new(os.environ['PAYROON_WEBHOOK_SECRET'].encode(), m[1].encode() + b'.' + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, m[2]): abort(400)
event = json.loads(body)
if event['type'] == 'payment.succeeded': pass # fulfil the order once
return '', 200require 'openssl'; require 'json'
post '/payroon-webhook' do
body = request.body.read
m = /t=(\d+),v1=([a-f0-9]+)/.match(request.env['HTTP_PAYROON_SIGNATURE'].to_s)
halt 400 unless m && (Time.now.to_i - m[1].to_i).abs < 300
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['PAYROON_WEBHOOK_SECRET'], "#{m[1]}.#{body}")
halt 400 unless Rack::Utils.secure_compare(expected, m[2])
event = JSON.parse(body)
# event['type'] == 'payment.succeeded' -> fulfil the order once
status 200
endfunc webhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
m := regexp.MustCompile(`t=(\d+),v1=([a-f0-9]+)`).FindStringSubmatch(r.Header.Get("Payroon-Signature"))
if m == nil { http.Error(w, "bad", 400); return }
ts, _ := strconv.ParseInt(m[1], 10, 64)
if math.Abs(float64(time.Now().Unix()-ts)) > 300 { http.Error(w, "old", 400); return }
mac := hmac.New(sha256.New, []byte(os.Getenv("PAYROON_WEBHOOK_SECRET")))
mac.Write([]byte(m[1] + "." + string(body)))
if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(m[2])) { http.Error(w, "bad", 400); return }
// parse body; if type == "payment.succeeded" fulfil the order once
w.WriteHeader(200)
}// ASP.NET minimal API
app.MapPost("/payroon-webhook", async (HttpRequest req) => {
var body = await new StreamReader(req.Body).ReadToEndAsync();
var m = Regex.Match(req.Headers["Payroon-Signature"].ToString(), @"t=(\d+),v1=([a-f0-9]+)");
if (!m.Success || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(m.Groups[1].Value)) > 300) return Results.BadRequest();
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("PAYROON_WEBHOOK_SECRET")!));
var expected = Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(m.Groups[1].Value + "." + body))).ToLower();
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(m.Groups[2].Value))) return Results.BadRequest();
// parse body; if type == "payment.succeeded" fulfil the order once
return Results.Ok();
});7API reference
All routes live under /api/gateway.php?action=… and use the same key.
Creates a payment and returns the checkout link.
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | number | yes | Amount in whole units of your currency. |
currency | string | no | Must equal your account currency (optional). |
reference | string | no | Your order number (100 characters max). |
description | string | no | Text shown to the customer. |
customer_email | string | no | Customer email address (optional). |
return_url | string | no | https address to return to after payment. |
cancel_url | string | no | https address if the customer cancels. |
metadata | object | no | Up to 20 keys, 1000 characters: returned in your webhooks. |
expires_in | integer | no | Link lifetime in minutes (5 up to the limit Payroon sets). |
fee_payer | string | no | "customer": the customer pays the price plus the fee; "merchant": the fee is taken from your sale. Default: your account setting. |
Returns one payment.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | The payment id (pay_…). |
Lists your payments (for your key’s mode).
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Number of results, 1 to 100. |
status | string | no | pending, paid, expired or cancelled. |
reference | string | no | Your order number (100 characters max). |
Cancels a payment that is still waiting.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | yes | The payment id (pay_…). |
Checks your key and returns your profile.
8Errors
Errors always have the same shape and a matching HTTP status.
{ "success": false, "error": { "code": "invalid_amount", "message": "amount must be a number, in whole currency units." } }401Key missing, wrong or replaced.403Merchant account not approved (live key) or suspended.404Payment not found for this key.409Not possible in the payment’s current state.422Invalid data: read the message field.429Too many requests: slow down.9Test mode and live mode
With a test key the checkout page offers “Simulate successful payment” and “Simulate cancelled payment”. No money moves and your webhooks carry mode=test. Switch between Test and Live whenever you like in the merchant portal. Live payments land in your merchant balance, which you then move to your main balance for free.