Skip to content

Developers

A payments API that behaves the way you expect

One HTTP and JSON API across every provider, with idempotent writes, signed webhooks and documentation generated from the same contract the API validates against.

Charge by M-Pesa STK push
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.

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.

Webhooks you can trust

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.

  • Event ID and per-transaction sequence for ordering
  • At-least-once delivery, written in the same database transaction as the change
  • Optional raw provider payload alongside the normalised event

Projects keep systems apart

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.

Docs that cannot drift

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

Verify a webhook

HMAC-SHA256 over the timestamp, a dot and the raw body, compared in constant time.

Full API reference
Verify a webhook
import 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.

Developers questions

Something else? Ask us.

Is there an SDK?

No. The API is plain HTTP and JSON, and the reference has copy-ready samples in nine languages. Any HTTP client works.

How do I test without moving money?

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.

How do API keys work?

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.

Guides

All guides

Related

Ready to take payments properly?

Tell us what you are building and which providers you use. We set up your workspace and help you go live.