Ontre API / Webhooks
Send events to Ontre
Connect your backend to Ontre with one signed JSON event per request. This guide covers the project webhook URL, the event shape, and the exact bytes to sign.
Use your Ontre environment’s base URL. https://YOUR_ONTRE_HOST is a placeholder. The webhook path comes from the connection’s postTo field.
Overview
An admin creates a connection for a project. Your backend sends signed requests to that connection’s stable URL. Ontre verifies the signature before reading the event and returns 202 after an active connection has stored it.
The URL identifies the connection. The workspace signing key proves that the request came from your backend. Keep the key on the server.
Set up a connection
postTo path.signingKey is READY, then store the key in your server’s secrets.Project admins can create a connection with POST /api/v1/projects/{projectId}/connections and a JSON body containing displayName. The response includes id, postTo, and signingKey status. The workspace key is available through authenticated GET /api/v1/connections/signing-key when ready.
The creation response also includes a token shown only once. The project URL uses the connection ID, so store the postTo path for delivery.
Request and headers
https://YOUR_ONTRE_HOST/webhooks/v2/{connectionId}| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-Ontre-Timestamp | Current Unix time in seconds. |
X-Ontre-Signature | Lowercase hexadecimal HMAC-SHA256 digest of {timestamp}.{rawBody}, using your workspace signing key. |
For an older workspace connection created at POST /api/v1/connections, use its returned /webhooks/v1/{connectionToken} path. The headers, payload, and signing process are the same.
Event payload
Send a single JSON object. An events array is rejected.
{
"source": "checkout-api",
"eventType": "payment.failed",
"eventId": "evt_2f9a",
"occurredAt": "2026-09-16T14:05:00Z",
"correlationId": "order_88f2",
"attributes": {
"amount": 4200,
"currency": "NGN",
"errorCode": "gateway_timeout"
}
}| Field | Required | Meaning |
|---|---|---|
source | Yes | Your system’s name, such as checkout-api. |
eventType | Yes | Your label for the event. |
eventId | Yes | An ID unique within that source. Keep it unchanged when retrying the same event. |
occurredAt | No | ISO-8601 event time. If missing or unreadable, Ontre uses receipt time. Events more than 30 days old are rejected. |
correlationId | No | A shared order or session ID that connects related steps. |
attributes | No | Free-form JSON object with event details. |
Sign each request
Serialize the JSON body first. Compute HMAC-SHA256 over the UTF-8 bytes of the Unix timestamp, one period, and the exact body you will transmit. Put the lowercase hex digest in X-Ontre-Signature.
{timestamp}.{rawBody}Use the same timestamp in the header and signed input. Ontre accepts a clock difference of up to five minutes. For every retry, create a new timestamp and signature without changing the event ID. Do not reformat the body after signing.
Code examples
Set ONTRE_BASE_URL, ONTRE_WEBHOOK_PATH (the returned postTo path), and ONTRE_SIGNING_KEY in your server environment. Use the signing key as its UTF-8 text value. Each sample signs that exact body and sends it.
import { createHmac } from 'node:crypto';
const baseUrl = process.env.ONTRE_BASE_URL;
const webhookPath = process.env.ONTRE_WEBHOOK_PATH;
const signingKey = process.env.ONTRE_SIGNING_KEY;
if (!baseUrl || !webhookPath || !signingKey) {
throw new Error('Missing Ontre configuration');
}
const body = JSON.stringify({
source: 'checkout-api',
eventType: 'payment.failed',
eventId: 'evt_2f9a',
occurredAt: new Date().toISOString(),
correlationId: 'order_88f2',
attributes: { amount: 4200, currency: 'NGN' }
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac('sha256', signingKey)
.update(timestamp + '.' + body, 'utf8')
.digest('hex');
const response = await fetch(new URL(webhookPath, baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Ontre-Timestamp': timestamp,
'X-Ontre-Signature': signature
},
body
});
if (response.status !== 202) {
throw new Error('Ontre returned ' + response.status + ': ' + await response.text());
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HexFormat;
public class SendEvent {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv("ONTRE_BASE_URL");
String webhookPath = System.getenv("ONTRE_WEBHOOK_PATH");
String signingKey = System.getenv("ONTRE_SIGNING_KEY");
if (baseUrl == null || webhookPath == null || signingKey == null) {
throw new IllegalStateException("Missing Ontre configuration");
}
String body = "{"
+ "\"source\":\"checkout-api\","
+ "\"eventType\":\"payment.failed\","
+ "\"eventId\":\"evt_2f9a\","
+ "\"occurredAt\":\"" + Instant.now() + "\","
+ "\"correlationId\":\"order_88f2\","
+ "\"attributes\":{\"amount\":4200,\"currency\":\"NGN\"}"
+ "}";
String timestamp = Long.toString(Instant.now().getEpochSecond());
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = HexFormat.of().formatHex(
mac.doFinal((timestamp + "." + body).getBytes(StandardCharsets.UTF_8)));
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl).resolve(webhookPath))
.header("Content-Type", "application/json; charset=utf-8")
.header("X-Ontre-Timestamp", timestamp)
.header("X-Ontre-Signature", signature)
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 202) {
throw new IllegalStateException(
"Ontre returned " + response.statusCode() + ": " + response.body());
}
}
}
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
var baseUrl = Environment.GetEnvironmentVariable("ONTRE_BASE_URL");
var webhookPath = Environment.GetEnvironmentVariable("ONTRE_WEBHOOK_PATH");
var signingKey = Environment.GetEnvironmentVariable("ONTRE_SIGNING_KEY");
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(webhookPath) || string.IsNullOrEmpty(signingKey))
{
throw new InvalidOperationException("Missing Ontre configuration");
}
var occurredAt = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'");
var body = "{"
+ "\"source\":\"checkout-api\","
+ "\"eventType\":\"payment.failed\","
+ "\"eventId\":\"evt_2f9a\","
+ "\"occurredAt\":\"" + occurredAt + "\","
+ "\"correlationId\":\"order_88f2\","
+ "\"attributes\":{\"amount\":4200,\"currency\":\"NGN\"}"
+ "}";
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signature = Convert.ToHexString(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(signingKey),
Encoding.UTF8.GetBytes(timestamp + "." + body))).ToLowerInvariant();
using var client = new HttpClient();
using var request = new HttpRequestMessage(
HttpMethod.Post, new Uri(new Uri(baseUrl), webhookPath));
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
request.Headers.TryAddWithoutValidation("X-Ontre-Timestamp", timestamp);
request.Headers.TryAddWithoutValidation("X-Ontre-Signature", signature);
var response = await client.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
if ((int)response.StatusCode != 202)
{
throw new InvalidOperationException($"Ontre returned {(int)response.StatusCode}: {text}");
}
import hashlib
import hmac
import json
import os
import time
from datetime import datetime, timezone
from urllib.error import HTTPError
from urllib.request import Request, urlopen
base_url = os.environ.get("ONTRE_BASE_URL")
webhook_path = os.environ.get("ONTRE_WEBHOOK_PATH")
signing_key = os.environ.get("ONTRE_SIGNING_KEY")
if not base_url or not webhook_path or not signing_key:
raise SystemExit("Missing Ontre configuration")
body = json.dumps({
"source": "checkout-api",
"eventType": "payment.failed",
"eventId": "evt_2f9a",
"occurredAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"correlationId": "order_88f2",
"attributes": {"amount": 4200, "currency": "NGN"},
}, separators=(",", ":"))
timestamp = str(int(time.time()))
signature = hmac.new(
signing_key.encode("utf-8"),
f"{timestamp}.{body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
request = Request(
base_url.rstrip("/") + "/" + webhook_path.lstrip("/"),
data=body.encode("utf-8"),
method="POST",
headers={
"Content-Type": "application/json; charset=utf-8",
"X-Ontre-Timestamp": timestamp,
"X-Ontre-Signature": signature,
},
)
try:
with urlopen(request) as response:
status, text = response.status, response.read().decode()
except HTTPError as error:
status, text = error.code, error.read().decode()
if status != 202:
raise SystemExit(f"Ontre returned {status}: {text}")
Generate a new eventId for each business event. A retry keeps that ID and uses a fresh request timestamp and signature.
Responses and retries
| Status | Meaning | What to do |
|---|---|---|
202 | Stored, duplicate accepted, or valid event ignored while suspended. | Mark the event delivered. |
400 | Malformed event or event more than 30 days old. | Fix the event before sending again. |
401 | Invalid signature or timestamp outside the five-minute window. | Check the key, exact body bytes, and server clock. |
404 | Unknown or disabled connection. | Check the URL and connection status. |
413 | Body exceeds 1 MiB. | Reduce the event body. |
429 | Rate limited, if returned by your environment. | Back off and retry. |
5xx | Temporary server failure, including a missing signing key. | Retry with exponential backoff and jitter. |
Queue events until you receive 202. Ontre deduplicates by workspace, source, and eventId. Delivery order does not determine event time; occurredAt does.
Connection lifecycle
An admin can suspend or activate a project connection through authenticated endpoints:
/api/v1/projects/{projectId}/connections/{connectionId}/suspend/api/v1/projects/{projectId}/connections/{connectionId}/activateBoth return 204 on success. GET /api/v1/projects/{projectId}/connections lists status and the postTo path. Disabling a connection stops delivery to that URL.
Suspended events are discarded. A valid signed request still returns 202. Ontre does not store those events or replay them after activation.