Noria Pay API
Build payments into your product
Noria Pay is a payments API for collecting and paying out money across mobile money, cards and bank
rails. One API normalises the provider differences; the dashboard gives your team the operational view.
Base URL
All endpoints are relative to your deployment's API origin. The examples use
$NORIA_API_URL; in the hosted product this is normally the same origin as the dashboard.
export NORIA_API_URL="https://pay.example.com"
Conventions
Money is always an integer in the currency's minor unit. KES 1,250 is 125000.
Currency is an ISO 4217 code. Charges default to KES.
IDs are opaque strings. Store them as-is; do not parse them.
Timestamps are RFC 3339 strings in UTC.
Requests and responses are JSON. Send Content-Type: application/json.
Requests are normalized to the documented schema. Send the documented field names exactly.
Code samples
Every request example in these guides and in the API reference has a
language selector. Every example covers cURL, JavaScript, TypeScript, Python, PHP, Go, Ruby, Java and C#.
What you can build
Resource Use it for
Charges Collect from a payer by STK prompt, checkout URL or manual reconciliation.
Refunds Return all or part of a settled collection.
Payment links Sell without code: a hosted page with fixed or custom amounts.
Webhooks Receive signed settlement events instead of polling.
Payouts Send money to mobile wallets and bank accounts.
Start here
Quickstart
Create a sandbox key, collect a payment, and verify the result. This takes one request and one
webhook (or a poll while you are testing).
1. Create an API key
Open your project in the dashboard, configure its sandbox gateway account, then go to API keys and create a key in the test
environment. Test keys start with pay_test_; live keys start with pay_live_.
Grant only the scopes the integration needs.
Scope Allows
chargeCreate and cancel collections.
payoutCreate payouts and refunds.
readList and fetch transactions, links and statements.
manageManage payment links, webhook endpoints and provider methods.
reconcileRecord manual charges and import statements.
2. Collect a payment
Send an amount, a channel and a stable reference. The request must include an
Idempotency-Key; use a new key for each distinct payment, not a random value on every retry.
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X POST "$NORIA_API_URL/v1/charges" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: bg-invoice-1042-mpesa-attempt-1' \
--data-raw '{
"amount_minor": 125000,
"currency": "KES",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"payer_phone": "254712345678",
"payer_name": "Amina Otieno",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/charges", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-invoice-1042-mpesa-attempt-1",
},
body: JSON.stringify({
"amount_minor": 125000,
"currency": "KES",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"payer_phone": "254712345678",
"payer_name": "Amina Otieno",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/charges", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-invoice-1042-mpesa-attempt-1",
},
body: JSON.stringify({
"amount_minor": 125000,
"currency": "KES",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"payer_phone": "254712345678",
"payer_name": "Amina Otieno",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
payload = {
"amount_minor": 125000,
"currency": "KES",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"payer_phone": "254712345678",
"payer_name": "Amina Otieno",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}
request = urllib.request.Request(
base_url + "/v1/charges",
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-invoice-1042-mpesa-attempt-1",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/charges');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
'Content-Type: application/json',
'Idempotency-Key: bg-invoice-1042-mpesa-attempt-1',
],
CURLOPT_POSTFIELDS => json_encode([
'amount_minor' => 125000,
'currency' => 'KES',
'channel' => 'mpesa',
'provider' => 'daraja',
'mode' => 'sandbox',
'reference' => 'BG-INV-2026-1042',
'description' => 'BillGrid invoice 1042',
'payer_phone' => '254712345678',
'payer_name' => 'Amina Otieno',
'metadata' => [
'invoice_id' => '1042',
'customer_id' => 'cust_ami_204'
]
], JSON_THROW_ON_ERROR),
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
body := strings.NewReader("{\n \"amount_minor\": 125000,\n \"currency\": \"KES\",\n \"channel\": \"mpesa\",\n \"provider\": \"daraja\",\n \"mode\": \"sandbox\",\n \"reference\": \"BG-INV-2026-1042\",\n \"description\": \"BillGrid invoice 1042\",\n \"payer_phone\": \"254712345678\",\n \"payer_name\": \"Amina Otieno\",\n \"metadata\": {\n \"invoice_id\": \"1042\",\n \"customer_id\": \"cust_ami_204\"\n }\n}")
req, err := http.NewRequest("POST", baseURL + "/v1/charges", body)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "bg-invoice-1042-mpesa-attempt-1")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/charges")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = "bg-invoice-1042-mpesa-attempt-1"
request.body = JSON.generate({
"amount_minor" => 125000,
"currency" => "KES",
"channel" => "mpesa",
"provider" => "daraja",
"mode" => "sandbox",
"reference" => "BG-INV-2026-1042",
"description" => "BillGrid invoice 1042",
"payer_phone" => "254712345678",
"payer_name" => "Amina Otieno",
"metadata" => {
"invoice_id" => "1042",
"customer_id" => "cust_ami_204"
}
})
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/charges"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "bg-invoice-1042-mpesa-attempt-1")
.method("POST", HttpRequest.BodyPublishers.ofString("{\n \"amount_minor\": 125000,\n \"currency\": \"KES\",\n \"channel\": \"mpesa\",\n \"provider\": \"daraja\",\n \"mode\": \"sandbox\",\n \"reference\": \"BG-INV-2026-1042\",\n \"description\": \"BillGrid invoice 1042\",\n \"payer_phone\": \"254712345678\",\n \"payer_name\": \"Amina Otieno\",\n \"metadata\": {\n \"invoice_id\": \"1042\",\n \"customer_id\": \"cust_ami_204\"\n }\n}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("POST"), baseUrl + "/v1/charges");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
request.Headers.Add("Idempotency-Key", "bg-invoice-1042-mpesa-attempt-1");
request.Content = new StringContent(@"{
""amount_minor"": 125000,
""currency"": ""KES"",
""channel"": ""mpesa"",
""provider"": ""daraja"",
""mode"": ""sandbox"",
""reference"": ""BG-INV-2026-1042"",
""description"": ""BillGrid invoice 1042"",
""payer_phone"": ""254712345678"",
""payer_name"": ""Amina Otieno"",
""metadata"": {
""invoice_id"": ""1042"",
""customer_id"": ""cust_ami_204""
}
}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
Every request example in these guides uses the same language selector. The
API reference generates the same set from each operation's schema, with the same language coverage.
A successful dispatch returns 201 Created and a transaction:
{
"id": "0197d1f0-21a0-7000-8000-000000000003",
"object": "transaction",
"workspace_id": "0197d1f0-21a0-7000-8000-000000000001",
"application_id": "0197d1f0-21a0-7000-8000-000000000002",
"source": "billgrid",
"direction": "collection",
"status": "processing",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"currency": "KES",
"amount_minor": 125000,
"settled_minor": null,
"fee_minor": null,
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"parent_transaction_id": null,
"payment_link_id": null,
"payer_name": "Amina Otieno",
"payer_phone": "254712345678",
"payer_email": null,
"recipient_name": null,
"recipient_phone": null,
"recipient_account": null,
"provider_ref": "ws_CO_20102026090000123456789012",
"provider_receipt": null,
"failure_reason": null,
"next_action": {
"type": "await_payer",
"provider_ref": "ws_CO_20102026090000123456789012",
"payer_message": "Complete the M-Pesa prompt on your phone.",
"expires_at": "2026-10-20T09:05:00Z"
},
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
},
"expires_at": "2026-10-20T09:05:00Z",
"settled_at": null,
"created_at": "2026-10-20T09:00:00Z"
}
3. Handle the next action
await_payer — the payer has been prompted. Show the message and wait for the webhook.
Do not re-send the charge just because the payer is slow.
redirect — send the payer to next_action.url to complete checkout.
none — no further action; follow the transaction status.
4. Verify the result
Subscribe to succeeded, failed and outcome_unknown webhooks for
production. For local testing, poll GET /v1/transactions/{id} until the status leaves
pending or processing.
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X GET "$NORIA_API_URL/v1/transactions/0197d1f0-21a0-7000-8000-000000000003" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/transactions/0197d1f0-21a0-7000-8000-000000000003", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
},
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/transactions/0197d1f0-21a0-7000-8000-000000000003", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
},
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
request = urllib.request.Request(
base_url + "/v1/transactions/0197d1f0-21a0-7000-8000-000000000003",
method="GET",
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/transactions/0197d1f0-21a0-7000-8000-000000000003');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
req, err := http.NewRequest("GET", baseURL + "/v1/transactions/0197d1f0-21a0-7000-8000-000000000003", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/transactions/0197d1f0-21a0-7000-8000-000000000003")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/transactions/0197d1f0-21a0-7000-8000-000000000003"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("GET"), baseUrl + "/v1/transactions/0197d1f0-21a0-7000-8000-000000000003");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
!
Processing is not success. Only treat succeeded as settled. If the
provider outcome is unknown, query the transaction or wait for the webhook; do not create a second
charge.
Start here
Authentication
New API keys are project-scoped bearer tokens. Each project has its own gateway accounts and callbacks. Use a test key while building and a live key in
production. Never put a live key in a browser or mobile app.
Bearer authentication
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X GET "$NORIA_API_URL/v1/transactions?limit=20&reference=BG-INV-2026-1042" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/transactions?limit=20&reference=BG-INV-2026-1042", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
},
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/transactions?limit=20&reference=BG-INV-2026-1042", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
},
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
request = urllib.request.Request(
base_url + "/v1/transactions?limit=20&reference=BG-INV-2026-1042",
method="GET",
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/transactions?limit=20&reference=BG-INV-2026-1042');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
req, err := http.NewRequest("GET", baseURL + "/v1/transactions?limit=20&reference=BG-INV-2026-1042", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/transactions?limit=20&reference=BG-INV-2026-1042")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/transactions?limit=20&reference=BG-INV-2026-1042"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("GET"), baseUrl + "/v1/transactions?limit=20&reference=BG-INV-2026-1042");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
A project key belongs to one application within one workspace. It infers its project automatically and cannot override it through headers or source fields.
Keys carry scopes. A request outside the key's scopes returns 403 forbidden.
Revoke a key from the dashboard immediately if it leaks. Keys are shown only once when created.
Use HTTPS everywhere, including internal service-to-service calls.
Test and live
Key Environment Money moves
pay_test_…Sandbox No. Provider sandboxes and simulated outcomes.
pay_live_…Live Yes. Real customer funds.
Transactions created with a test key return mode: "sandbox". Use separate webhook
endpoints for test and live traffic so test events never reach production systems.
Browser and console sessions
The dashboard uses a short-lived browser session, not an API key. Those endpoints are intentionally
excluded from the public reference. If you are embedding a Noria console, use a console token scoped
to this service; do not copy a dashboard session into your own application.
!
Keep keys server-side. A key can move money. Proxy requests through your backend and
issue your own short-lived credentials to browsers and mobile apps.
Start here
Environments
Sandbox and live are separate payment methods, keys and data. Keep them separate end to end.
Sandbox
Create keys with the test environment in the dashboard.
Configure sandbox provider credentials in Payment methods .
Use the provider's test payer numbers and cards; no real money moves.
Trigger success or failure with the provider's sandbox tools, or use the dashboard's test controls.
Live
Create keys with the live environment.
Configure live provider credentials and complete any provider onboarding first.
Start with a small real charge and confirm the webhook and settlement before rolling out.
Monitor outcome_unknown events; they need an operator to reconcile, not an automatic retry.
Switching modes
The transaction's mode is decided by the key and the payment method, not by a request
field. A live key cannot spend a sandbox payment method, and a test key cannot move live funds. Keep
separate webhook endpoints and separate data stores so a test event can never mark a live invoice paid.
Core resources
Charges
A charge collects money from a payer. Noria Pay dispatches to the right provider and returns the
action the payer needs.
Create a charge
Required fields are amount_minor, channel, reference and
description. Supply payer_phone for mobile money, or let the checkout flow
collect it for cards and hosted links.
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X POST "$NORIA_API_URL/v1/charges" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: bg-invoice-1042-mpesa-attempt-1' \
--data-raw '{
"amount_minor": 125000,
"currency": "KES",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"payer_phone": "254712345678",
"payer_name": "Amina Otieno",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/charges", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-invoice-1042-mpesa-attempt-1",
},
body: JSON.stringify({
"amount_minor": 125000,
"currency": "KES",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"payer_phone": "254712345678",
"payer_name": "Amina Otieno",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/charges", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-invoice-1042-mpesa-attempt-1",
},
body: JSON.stringify({
"amount_minor": 125000,
"currency": "KES",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"payer_phone": "254712345678",
"payer_name": "Amina Otieno",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
payload = {
"amount_minor": 125000,
"currency": "KES",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"payer_phone": "254712345678",
"payer_name": "Amina Otieno",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}
request = urllib.request.Request(
base_url + "/v1/charges",
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-invoice-1042-mpesa-attempt-1",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/charges');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
'Content-Type: application/json',
'Idempotency-Key: bg-invoice-1042-mpesa-attempt-1',
],
CURLOPT_POSTFIELDS => json_encode([
'amount_minor' => 125000,
'currency' => 'KES',
'channel' => 'mpesa',
'provider' => 'daraja',
'mode' => 'sandbox',
'reference' => 'BG-INV-2026-1042',
'description' => 'BillGrid invoice 1042',
'payer_phone' => '254712345678',
'payer_name' => 'Amina Otieno',
'metadata' => [
'invoice_id' => '1042',
'customer_id' => 'cust_ami_204'
]
], JSON_THROW_ON_ERROR),
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
body := strings.NewReader("{\n \"amount_minor\": 125000,\n \"currency\": \"KES\",\n \"channel\": \"mpesa\",\n \"provider\": \"daraja\",\n \"mode\": \"sandbox\",\n \"reference\": \"BG-INV-2026-1042\",\n \"description\": \"BillGrid invoice 1042\",\n \"payer_phone\": \"254712345678\",\n \"payer_name\": \"Amina Otieno\",\n \"metadata\": {\n \"invoice_id\": \"1042\",\n \"customer_id\": \"cust_ami_204\"\n }\n}")
req, err := http.NewRequest("POST", baseURL + "/v1/charges", body)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "bg-invoice-1042-mpesa-attempt-1")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/charges")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = "bg-invoice-1042-mpesa-attempt-1"
request.body = JSON.generate({
"amount_minor" => 125000,
"currency" => "KES",
"channel" => "mpesa",
"provider" => "daraja",
"mode" => "sandbox",
"reference" => "BG-INV-2026-1042",
"description" => "BillGrid invoice 1042",
"payer_phone" => "254712345678",
"payer_name" => "Amina Otieno",
"metadata" => {
"invoice_id" => "1042",
"customer_id" => "cust_ami_204"
}
})
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/charges"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "bg-invoice-1042-mpesa-attempt-1")
.method("POST", HttpRequest.BodyPublishers.ofString("{\n \"amount_minor\": 125000,\n \"currency\": \"KES\",\n \"channel\": \"mpesa\",\n \"provider\": \"daraja\",\n \"mode\": \"sandbox\",\n \"reference\": \"BG-INV-2026-1042\",\n \"description\": \"BillGrid invoice 1042\",\n \"payer_phone\": \"254712345678\",\n \"payer_name\": \"Amina Otieno\",\n \"metadata\": {\n \"invoice_id\": \"1042\",\n \"customer_id\": \"cust_ami_204\"\n }\n}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("POST"), baseUrl + "/v1/charges");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
request.Headers.Add("Idempotency-Key", "bg-invoice-1042-mpesa-attempt-1");
request.Content = new StringContent(@"{
""amount_minor"": 125000,
""currency"": ""KES"",
""channel"": ""mpesa"",
""provider"": ""daraja"",
""mode"": ""sandbox"",
""reference"": ""BG-INV-2026-1042"",
""description"": ""BillGrid invoice 1042"",
""payer_phone"": ""254712345678"",
""payer_name"": ""Amina Otieno"",
""metadata"": {
""invoice_id"": ""1042"",
""customer_id"": ""cust_ami_204""
}
}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
Channels
Channel Typical payer action
mpesa, airtelApprove an STK prompt on their phone.
cardComplete a redirect or hosted checkout.
bank, walletProvider-specific instruction or redirect.
cashRecorded manually, then reconciled.
Status lifecycle
A charge normally moves from pending to processing and then to
succeeded, failed, cancelled or expired.
unknown means the provider did not give a definitive answer; query it before acting.
Cancel a processing charge
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X POST "$NORIA_API_URL/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
},
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
},
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
request = urllib.request.Request(
base_url + "/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel",
method="POST",
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
req, err := http.NewRequest("POST", baseURL + "/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.method("POST", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("POST"), baseUrl + "/v1/charges/0197d1f0-21a0-7000-8000-000000000003/cancel");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
Cancellation is only possible while the charge is processing. Once it settles, use a
refund instead.
Core resources
Refunds
Refunds return money against a settled collection. They are separate transactions linked to the
original charge, so they appear in the same ledger and webhook stream.
Create a refund
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X POST "$NORIA_API_URL/v1/refunds" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: bg-refund-1042-partial-attempt-1' \
--data-raw '{
"transaction_id": "0197d1f0-21a0-7000-8000-000000000003",
"amount_minor": 25000,
"reason": "Unused service credit"
}'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-refund-1042-partial-attempt-1",
},
body: JSON.stringify({
"transaction_id": "0197d1f0-21a0-7000-8000-000000000003",
"amount_minor": 25000,
"reason": "Unused service credit"
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-refund-1042-partial-attempt-1",
},
body: JSON.stringify({
"transaction_id": "0197d1f0-21a0-7000-8000-000000000003",
"amount_minor": 25000,
"reason": "Unused service credit"
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
payload = {
"transaction_id": "0197d1f0-21a0-7000-8000-000000000003",
"amount_minor": 25000,
"reason": "Unused service credit"
}
request = urllib.request.Request(
base_url + "/v1/refunds",
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "bg-refund-1042-partial-attempt-1",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/refunds');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
'Content-Type: application/json',
'Idempotency-Key: bg-refund-1042-partial-attempt-1',
],
CURLOPT_POSTFIELDS => json_encode([
'transaction_id' => '0197d1f0-21a0-7000-8000-000000000003',
'amount_minor' => 25000,
'reason' => 'Unused service credit'
], JSON_THROW_ON_ERROR),
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
body := strings.NewReader("{\n \"transaction_id\": \"0197d1f0-21a0-7000-8000-000000000003\",\n \"amount_minor\": 25000,\n \"reason\": \"Unused service credit\"\n}")
req, err := http.NewRequest("POST", baseURL + "/v1/refunds", body)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "bg-refund-1042-partial-attempt-1")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/refunds")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = "bg-refund-1042-partial-attempt-1"
request.body = JSON.generate({
"transaction_id" => "0197d1f0-21a0-7000-8000-000000000003",
"amount_minor" => 25000,
"reason" => "Unused service credit"
})
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/refunds"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "bg-refund-1042-partial-attempt-1")
.method("POST", HttpRequest.BodyPublishers.ofString("{\n \"transaction_id\": \"0197d1f0-21a0-7000-8000-000000000003\",\n \"amount_minor\": 25000,\n \"reason\": \"Unused service credit\"\n}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("POST"), baseUrl + "/v1/refunds");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
request.Headers.Add("Idempotency-Key", "bg-refund-1042-partial-attempt-1");
request.Content = new StringContent(@"{
""transaction_id"": ""0197d1f0-21a0-7000-8000-000000000003"",
""amount_minor"": 25000,
""reason"": ""Unused service credit""
}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
Omit amount_minor to refund the full settled amount.
Partial refunds can be repeated until the settled amount is exhausted.
Refunds are asynchronous. A 201 means the refund was accepted, not that it has settled.
Only collections that have settled can be refunded.
Refund status
The refund inherits the provider and channel of the original charge and has direction
refund. Its parent_transaction_id points at the original collection. Listen for
succeeded or failed before updating your ledger.
!
Do not refund by retrying a timeout. If a refund returns
outcome_unknown, query the refund transaction. A second refund could return money twice.
Core resources
Payment links
Payment links are hosted checkout pages for invoices, products and donations. Create one via the API
or dashboard, share its URL, and let Noria Pay collect the payment.
Create a link
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X POST "$NORIA_API_URL/v1/payment-links" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data-raw '{
"title": "BillGrid invoice 1042",
"description": "October service subscription",
"amount_minor": 125000,
"currency": "KES",
"channels": [
"mpesa",
"card"
],
"reference": "BG-INV-2026-1042",
"return_url": "https://billgrid.example.com/invoices/1042",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/payment-links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
"title": "BillGrid invoice 1042",
"description": "October service subscription",
"amount_minor": 125000,
"currency": "KES",
"channels": [
"mpesa",
"card"
],
"reference": "BG-INV-2026-1042",
"return_url": "https://billgrid.example.com/invoices/1042",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/payment-links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
"title": "BillGrid invoice 1042",
"description": "October service subscription",
"amount_minor": 125000,
"currency": "KES",
"channels": [
"mpesa",
"card"
],
"reference": "BG-INV-2026-1042",
"return_url": "https://billgrid.example.com/invoices/1042",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
payload = {
"title": "BillGrid invoice 1042",
"description": "October service subscription",
"amount_minor": 125000,
"currency": "KES",
"channels": [
"mpesa",
"card"
],
"reference": "BG-INV-2026-1042",
"return_url": "https://billgrid.example.com/invoices/1042",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
}
}
request = urllib.request.Request(
base_url + "/v1/payment-links",
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/payment-links');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'title' => 'BillGrid invoice 1042',
'description' => 'October service subscription',
'amount_minor' => 125000,
'currency' => 'KES',
'channels' => [
'mpesa',
'card'
],
'reference' => 'BG-INV-2026-1042',
'return_url' => 'https://billgrid.example.com/invoices/1042',
'metadata' => [
'invoice_id' => '1042',
'customer_id' => 'cust_ami_204'
]
], JSON_THROW_ON_ERROR),
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
body := strings.NewReader("{\n \"title\": \"BillGrid invoice 1042\",\n \"description\": \"October service subscription\",\n \"amount_minor\": 125000,\n \"currency\": \"KES\",\n \"channels\": [\n \"mpesa\",\n \"card\"\n ],\n \"reference\": \"BG-INV-2026-1042\",\n \"return_url\": \"https://billgrid.example.com/invoices/1042\",\n \"metadata\": {\n \"invoice_id\": \"1042\",\n \"customer_id\": \"cust_ami_204\"\n }\n}")
req, err := http.NewRequest("POST", baseURL + "/v1/payment-links", body)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/payment-links")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request.body = JSON.generate({
"title" => "BillGrid invoice 1042",
"description" => "October service subscription",
"amount_minor" => 125000,
"currency" => "KES",
"channels" => [
"mpesa",
"card"
],
"reference" => "BG-INV-2026-1042",
"return_url" => "https://billgrid.example.com/invoices/1042",
"metadata" => {
"invoice_id" => "1042",
"customer_id" => "cust_ami_204"
}
})
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/payment-links"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("{\n \"title\": \"BillGrid invoice 1042\",\n \"description\": \"October service subscription\",\n \"amount_minor\": 125000,\n \"currency\": \"KES\",\n \"channels\": [\n \"mpesa\",\n \"card\"\n ],\n \"reference\": \"BG-INV-2026-1042\",\n \"return_url\": \"https://billgrid.example.com/invoices/1042\",\n \"metadata\": {\n \"invoice_id\": \"1042\",\n \"customer_id\": \"cust_ami_204\"\n }\n}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("POST"), baseUrl + "/v1/payment-links");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
request.Content = new StringContent(@"{
""title"": ""BillGrid invoice 1042"",
""description"": ""October service subscription"",
""amount_minor"": 125000,
""currency"": ""KES"",
""channels"": [
""mpesa"",
""card""
],
""reference"": ""BG-INV-2026-1042"",
""return_url"": ""https://billgrid.example.com/invoices/1042"",
""metadata"": {
""invoice_id"": ""1042"",
""customer_id"": ""cust_ami_204""
}
}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
The response includes a hosted url and a stable token. Share the URL; do not
build your own checkout against the token.
Custom amounts
Set allow_custom_amount and optional min_amount_minor /
max_amount_minor to let the payer choose. Without it, the link charges its fixed amount.
Track payments
Payments made through a link are normal transactions with payment_link_id set. Filter the
transaction list, or fetch the link's analytics:
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X GET "$NORIA_API_URL/v1/payment-links/undefined/analytics" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/payment-links/undefined/analytics", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
},
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/payment-links/undefined/analytics", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
},
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
request = urllib.request.Request(
base_url + "/v1/payment-links/undefined/analytics",
method="GET",
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/payment-links/undefined/analytics');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
req, err := http.NewRequest("GET", baseURL + "/v1/payment-links/undefined/analytics", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/payment-links/undefined/analytics")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/payment-links/undefined/analytics"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("GET"), baseUrl + "/v1/payment-links/undefined/analytics");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
Close a link
Set active: false with PATCH /v1/payment-links/{id}. Existing payments remain;
new checkouts are refused with link_closed.
Core resources
Webhooks
Webhooks are the reliable way to learn that a transaction changed state. Point an endpoint at your
server, verify the signature, and make your handler idempotent.
Register an endpoint
cURL JavaScript TypeScript Python PHP Go Ruby Java C#
curl --fail-with-body --max-time 30 -X POST "$NORIA_API_URL/v1/webhook-endpoints" \
-H "Authorization: Bearer $NORIA_API_KEY" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data-raw '{
"url": "https://billgrid.example.com/webhooks/noria-pay",
"description": "Invoice reconciliation",
"event_types": [
"succeeded",
"failed",
"outcome_unknown",
"reversed"
],
"include_provider_payload": false
}'
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/webhook-endpoints", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://billgrid.example.com/webhooks/noria-pay",
"description": "Invoice reconciliation",
"event_types": [
"succeeded",
"failed",
"outcome_unknown",
"reversed"
],
"include_provider_payload": false
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
const baseUrl = process.env.NORIA_API_URL ?? "https://pay.example.com";
const response = await fetch(baseUrl + "/v1/webhook-endpoints", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://billgrid.example.com/webhooks/noria-pay",
"description": "Invoice reconciliation",
"event_types": [
"succeeded",
"failed",
"outcome_unknown",
"reversed"
],
"include_provider_payload": false
}),
});
const text = await response.text();
if (!response.ok) throw new Error(`Noria Pay ${response.status}: ${text}`);
const result = text ? JSON.parse(text) : null;
console.log(result);
import json
import os
import urllib.error
import urllib.request
base_url = os.environ.get("NORIA_API_URL", "https://pay.example.com")
payload = {
"url": "https://billgrid.example.com/webhooks/noria-pay",
"description": "Invoice reconciliation",
"event_types": [
"succeeded",
"failed",
"outcome_unknown",
"reversed"
],
"include_provider_payload": False
}
request = urllib.request.Request(
base_url + "/v1/webhook-endpoints",
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + os.environ["NORIA_API_KEY"],
"Accept": "application/json",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
print(json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
<?php
$baseUrl = getenv('NORIA_API_URL') ?: 'https://pay.example.com';
$ch = curl_init($baseUrl . '/v1/webhook-endpoints');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'url' => 'https://billgrid.example.com/webhooks/noria-pay',
'description' => 'Invoice reconciliation',
'event_types' => [
'succeeded',
'failed',
'outcome_unknown',
'reversed'
],
'include_provider_payload' => false
], JSON_THROW_ON_ERROR),
]);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException($raw);
$result = $raw !== '' ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
print_r($result);
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func main() {
baseURL := os.Getenv("NORIA_API_URL")
if baseURL == "" { baseURL = "https://pay.example.com" }
body := strings.NewReader("{\n \"url\": \"https://billgrid.example.com/webhooks/noria-pay\",\n \"description\": \"Invoice reconciliation\",\n \"event_types\": [\n \"succeeded\",\n \"failed\",\n \"outcome_unknown\",\n \"reversed\"\n ],\n \"include_provider_payload\": false\n}")
req, err := http.NewRequest("POST", baseURL + "/v1/webhook-endpoints", body)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer " + os.Getenv("NORIA_API_KEY"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 400 { panic(string(data)) }
fmt.Println(string(data))
}
require "json"
require "net/http"
require "uri"
uri = URI(ENV.fetch("NORIA_API_URL", "https://pay.example.com") + "/v1/webhook-endpoints")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('NORIA_API_KEY')}"
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request.body = JSON.generate({
"url" => "https://billgrid.example.com/webhooks/noria-pay",
"description" => "Invoice reconciliation",
"event_types" => [
"succeeded",
"failed",
"outcome_unknown",
"reversed"
],
"include_provider_payload" => false
})
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
http.request(request)
end
raise "Noria Pay #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
result = response.body.to_s.empty? ? nil : JSON.parse(response.body)
puts result
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
class Example {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("NORIA_API_URL", "https://pay.example.com");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/v1/webhook-endpoints"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NORIA_API_KEY"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("{\n \"url\": \"https://billgrid.example.com/webhooks/noria-pay\",\n \"description\": \"Invoice reconciliation\",\n \"event_types\": [\n \"succeeded\",\n \"failed\",\n \"outcome_unknown\",\n \"reversed\"\n ],\n \"include_provider_payload\": false\n}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}
using System;
using System.Net.Http;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("NORIA_API_URL") ?? "https://pay.example.com";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(new HttpMethod("POST"), baseUrl + "/v1/webhook-endpoints");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORIA_API_KEY"));
request.Headers.Add("Accept", "application/json");
request.Content = new StringContent(@"{
""url"": ""https://billgrid.example.com/webhooks/noria-pay"",
""description"": ""Invoice reconciliation"",
""event_types"": [
""succeeded"",
""failed"",
""outcome_unknown"",
""reversed""
],
""include_provider_payload"": false
}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var raw = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) throw new Exception(raw);
Console.WriteLine(raw);
The response contains the signing secret once. Store it in your secret manager. Omit
event_types to receive all events.
Projects and source routing
A workspace contains independent payment projects. Owners and administrators create them in
the dashboard’s Projects view or through /v1/applications with a browser session.
Each project owns its gateway credentials, keys, links and webhooks. Register the project’s
endpoint and initiate payments using its application key; Pay infers application_id
and source. Browser sessions select a project with
X-Noria-Application. Project keys cannot select sibling projects.
Events route to the endpoints of the project that owns the payment. Refunds and hosted checkout inherit ownership
from the original payment or link. Archived projects stop new payment initiation while confirmations,
statement reconciliation and webhook delivery continue.
Verified callbacks reconcile in Pay and commit delivery jobs before waking workers immediately.
A dedicated worker must be running. Accepted dispatches allow 60 seconds for callbacks before
recovery polling starts; unknown outcomes query promptly. Callback authentication may require an
immediate verification query. Polling and statement imports emit the same normalized events.
Subscribe to succeeded, failed, outcome_unknown and
reversed for reconciliation. Enable include_provider_payload on an endpoint
for available redacted gateway data under data.provider_payload; caller metadata remains separate.
Independent project statement imports require payment_method_id. Use
GET /v1/payment-methods?include_retired=true to select historical credential versions.
Inspect attempts through GET /v1/webhook-deliveries. Paused endpoints retain queued
events without consuming attempts; resuming wakes delivery. Retry a failed or pending event with
POST /v1/webhook-deliveries/{id}/retry. This replays the saved event without sending another payment.
Event types
Type Meaning
createdA transaction was created locally.
dispatchedThe provider accepted the request.
callback_receivedA provider callback arrived.
queriedA status query was performed.
succeededFunds settled. This is the success event.
failedThe payment failed definitively.
cancelled, expiredClosed without payment. A verified late payment can still settle; local cancellation or timeout keeps an uncertain payment in unknown.
outcome_unknownThe provider gave no definitive answer. Reconcile; do not retry blindly.
reversedA settlement was reversed.
Verify the signature
Every delivery includes Pay-Signature: t=<unix>,v1=<hex>,
Pay-Event-Id and Pay-Event-Type. Compute
HMAC-SHA256 over the exact raw body prefixed with the timestamp and a dot:
t.body. Compare in constant time and reject timestamps outside your tolerance window.
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("hex");
const received = Buffer.from(parts.v1 || "", "hex");
const computed = Buffer.from(expected, "hex");
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
!
Use the raw request body. Parsing and re-serialising JSON changes whitespace and key
order, so the signature will not match. Capture the bytes before your JSON middleware runs.
Delivery and retries
A delivery is successful on any 2xx response. Non-2xx responses and timeouts are retried
with exponential backoff until the delivery exhausts its attempts. Pausing retains queued events and stops attempts until resumed. Return
2xx quickly, then process asynchronously. Deduplicate on Pay-Event-Id. Deliveries can arrive out of order;
keep the largest sequence per transaction and ignore older updates. Sequence zero
marks a snapshot reconstructed during a database rebuild; fetch the current transaction if one needs recovery. Apply credits idempotently
by data.transaction_id: a late provider receipt can produce a newer succeeded
event with data.detail.reconciled: true, updating the receipt without another payment credit.
Payload
{
"id": "0197d1f0-21a0-7000-8000-000000000007",
"type": "succeeded",
"sequence": 3,
"created_at": "2026-10-20T09:00:18Z",
"data": {
"transaction_id": "0197d1f0-21a0-7000-8000-000000000003",
"workspace_id": "0197d1f0-21a0-7000-8000-000000000001",
"application_id": "0197d1f0-21a0-7000-8000-000000000002",
"source": "billgrid",
"direction": "collection",
"status": "succeeded",
"provider": "daraja",
"channel": "mpesa",
"mode": "sandbox",
"currency": "KES",
"amount_minor": 125000,
"settled_minor": 125000,
"underpaid": false,
"fee_minor": null,
"reference": "BG-INV-2026-1042",
"parent_transaction_id": null,
"payment_link_id": null,
"provider_ref": "ws_CO_20102026090000123456789012",
"provider_receipt": "UJK7A4M2Q9",
"failure_reason": null,
"settled_at": "2026-10-20T09:00:18Z",
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
},
"detail": {
"provider_receipt": "UJK7A4M2Q9"
}
}
}
Reliability
Idempotency
Money-moving requests require an Idempotency-Key. It is how you safely retry a request
without charging or paying twice.
How it works
Send a unique key with the request. Noria Pay stores the key with a fingerprint of the request body:
The same key and the same body returns the original response, including its status code.
The same key with a different body returns 409 idempotency_mismatch.
A missing key returns 400 idempotency_required before the provider is called.
Idempotency keys are scoped to the project. Keep the key stable for the operation you are protecting.
Choose keys
# Good: stable for this business operation
Idempotency-Key: bg-invoice-1042-mpesa-attempt-1
# Also good: a UUID generated once and stored with the order
Idempotency-Key: 2c7e8f40-5a41-4d1f-8d61-9a2b6c3e4f50
# Bad: a new value on every retry
Idempotency-Key: charge-$(date +%s%N)
If your process crashes before it records the response, reuse the key when you retry. Persist the key
next to the order or payout record before making the call.
After a timeout
A 504 outcome_unknown means the request may have reached the provider. Reuse the same key
for the retry, or query the transaction by reference. Do not use a fresh key to "make sure".
Reliability
Errors
Every error has a machine-readable code, a human-readable message, optional details and a request ID.
Log the request ID; it is the fastest way to trace a payment.
Error shape
{
"error": {
"code": "validation_error",
"message": "payer_phone is required for an mpesa charge",
"details": {
"field": "payer_phone"
},
"request_id": "req_01J8Z6T4M8G3K2Q9V7N1R5C0XA"
}
}
Status codes
Status Codes What to do
400validation_error, idempotency_requiredFix the request. Do not retry unchanged.
401unauthorizedCheck the key and environment.
403forbiddenThe key lacks the required scope.
404not_foundThe resource is not in this workspace.
409conflict, idempotency_mismatch, duplicate_receipt, not_refundable, link_closedResolve the state conflict; do not retry blindly.
422provider_not_configured, amount_not_settleable, insufficient_balanceFix configuration or amount before retrying.
429rate_limitedBack off with jitter; respect Retry-After.
502provider_unavailable, provider_rejectedProvider problem. Retry with the same idempotency key if it was safe to retry.
504outcome_unknownThe money may have moved. Query the transaction; do not create a new one.
500internal_errorRetry with backoff using the same key, then contact support with the request ID.
!
outcome_unknown is not a failure. It means the provider did not
confirm the result. Query GET /v1/transactions/{id} or wait for the webhook. A new
charge with a new key is how duplicate payments happen.
Reliability
Pagination
List endpoints are cursor-paginated. Cursors are opaque; pass them back unchanged and do not build
them yourself.
Parameters
Parameter Default Notes
limit251–100. Larger pages are not guaranteed to be faster.
cursor— The next_cursor from the previous response.
Response
{
"object": "list",
"data": [
{
"id": "0197d1f0-21a0-7000-8000-000000000003",
"object": "transaction",
"workspace_id": "0197d1f0-21a0-7000-8000-000000000001",
"application_id": "0197d1f0-21a0-7000-8000-000000000002",
"source": "billgrid",
"direction": "collection",
"status": "succeeded",
"channel": "mpesa",
"provider": "daraja",
"mode": "sandbox",
"currency": "KES",
"amount_minor": 125000,
"settled_minor": 125000,
"fee_minor": null,
"reference": "BG-INV-2026-1042",
"description": "BillGrid invoice 1042",
"parent_transaction_id": null,
"payment_link_id": null,
"payer_name": "Amina Otieno",
"payer_phone": "254712345678",
"payer_email": null,
"recipient_name": null,
"recipient_phone": null,
"recipient_account": null,
"provider_ref": "ws_CO_20102026090000123456789012",
"provider_receipt": "UJK7A4M2Q9",
"failure_reason": null,
"next_action": {
"type": "none"
},
"metadata": {
"invoice_id": "1042",
"customer_id": "cust_ami_204"
},
"expires_at": "2026-10-20T09:05:00Z",
"settled_at": "2026-10-20T09:00:18Z",
"created_at": "2026-10-20T09:00:00Z"
}
],
"next_cursor": null
}
next_cursor is null on the last page. A cursor is only valid for the same
filter, workspace and project; changing selection starts a new pagination run.