Idempotent by design
Every request that moves money takes an Idempotency-Key header. The same key and body replay the original response; a different body under that key is refused. Concurrent requests with one key dispatch once.
Developers
One HTTP and JSON API across every provider, with idempotent writes, signed webhooks and documentation generated from the same contract the API validates against.
curl https://pay.noria.co.ke/v1/charges \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-1042" \
-d '{
"amount_minor": 250000,
"currency": "KES",
"channel": "mpesa",
"payer_phone": "254712345678",
"reference": "INV-1042",
"description": "BillGrid invoice INV-1042"
}' const response = await fetch("https://pay.noria.co.ke/v1/charges", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "invoice-1042",
},
body: JSON.stringify({
"amount_minor": 250000,
"currency": "KES",
"channel": "mpesa",
"payer_phone": "254712345678",
"reference": "INV-1042",
"description": "BillGrid invoice INV-1042"
}),
});
const result = await response.json(); import os
import requests
result = requests.post(
"https://pay.noria.co.ke/v1/charges",
headers={
"Authorization": f"Bearer {os.environ['NORIA_API_KEY']}",
"Idempotency-Key": "invoice-1042",
},
json={
"amount_minor": 250000,
"currency": "KES",
"channel": "mpesa",
"payer_phone": "254712345678",
"reference": "INV-1042",
"description": "BillGrid invoice INV-1042",
},
timeout=30,
).json() <?php
$ch = curl_init('https://pay.noria.co.ke/v1/charges');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: invoice-1042',
],
CURLOPT_POSTFIELDS => json_encode([
'amount_minor' => 250000,
'currency' => 'KES',
'channel' => 'mpesa',
'payer_phone' => '254712345678',
'reference' => 'INV-1042',
'description' => 'BillGrid invoice INV-1042',
]),
]);
$result = json_decode(curl_exec($ch), true); body, _ := json.Marshal(map[string]any{
"amount_minor": 250000,
"currency": "KES",
"channel": "mpesa",
"payer_phone": "254712345678",
"reference": "INV-1042",
"description": "BillGrid invoice INV-1042",
})
req, _ := http.NewRequest("POST", "https://pay.noria.co.ke/v1/charges", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("NORIA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "invoice-1042")
res, err := http.DefaultClient.Do(req) {
"id": "0193a1d9-fcc3-7c3a-8d9d-838bd6273c4b",
"type": "succeeded",
"sequence": 3,
"created_at": "2026-11-14T09:41:07Z",
"data": {
"transaction_id": "0193a1d9-e0a2-7b71-9f0e-2a6c5f1d8e40",
"direction": "collection",
"status": "succeeded",
"provider": "daraja",
"channel": "mpesa",
"currency": "KES",
"amount_minor": 250000,
"settled_minor": 250000,
"reference": "INV-1042",
"provider_receipt": "TKE4X7Q2LM"
}
} One request sends the prompt. Pay confirms the result with Safaricom, then posts a signed webhook.
Every request that moves money takes an Idempotency-Key header. The same key and body replay the original response; a different body under that key is refused. Concurrent requests with one key dispatch once.
Events are signed with HMAC-SHA256 in a Pay-Signature header that carries a timestamp. Failed deliveries retry with backoff, every delivery is listed with its response, and you can re-send any of them.
Run several products from one workspace. Each project has its own API keys, provider credentials, webhook endpoints and signing secrets, and a key from one project cannot see another's data.
The OpenAPI spec, reference pages and examples are generated from the schemas the API validates against, and every documented success response is exercised by a real HTTP test.
In code
HMAC-SHA256 over the timestamp, a dot and the raw body, compared in constant time.
Full API referenceimport crypto from "node:crypto";
export function verifyNoriaSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const received = Buffer.from(parts.v1 || "", "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
} import hashlib
import hmac
import time
def verify_noria_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
timestamp = int(parts.get("t", "0"))
if not timestamp or abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", "")) <?php
function verifyNoriaSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$key] = $value;
}
$timestamp = (int) ($parts['t'] ?? 0);
if ($timestamp === 0 || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
} func verifyNoriaSignature(rawBody []byte, header, secret string, tolerance time.Duration) bool {
var timestamp int64
var signature string
for part := range strings.SplitSeq(header, ",") {
key, value, _ := strings.Cut(part, "=")
switch key {
case "t":
timestamp, _ = strconv.ParseInt(value, 10, 64)
case "v1":
signature = value
}
}
age := time.Since(time.Unix(timestamp, 0)).Abs()
if timestamp == 0 || age > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.%s", timestamp, rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(signature))
} HMAC-SHA256 over the timestamp, a dot and the raw body, compared in constant time.
Something else? Ask us.
No. The API is plain HTTP and JSON, and the reference has copy-ready samples in nine languages. Any HTTP client works.
Every payment method has sandbox and live modes. Point a sandbox method at the provider's sandbox credentials and use it exactly as you would live.
Keys belong to one project, are shown once, can expire after a set number of days, and revoking a key revokes every key it issued. Keys are for servers only and never sign in to the dashboard.
Tell us what you are building and which providers you use. We set up your workspace and help you go live.