Webhooks overview
Every event we send, exactly what the payload contains, and how to write a receiver that does not break.
A webhook is us calling you when a transaction changes state, instead of you asking. It is the lowest-latency way to learn an outcome and the biggest single saving against your rate limit.
Setting it up
Show the PayDirect client (defined once in Authentication)
PayDirect has no SDK, so write this once. Every example in these guides calls the client defined here, which means signing lives in exactly one place - and this block is itself a single shared file, so if it changes, it changes on every page at once.
Each version reads PAYDIRECT_HOST, PAYDIRECT_PUBLIC_KEY, PAYDIRECT_RAW_KEY and
PAYDIRECT_SECRET_KEY from the environment, signs every request, and sends an Idempotency-Key
on writes (see Idempotency).
cURL
# paydirect.sh - source this, then call `paydirect POST /transactions/collect "$BODY"`
# Works for GET, POST, PATCH and DELETE; a body is optional.
paydirect() {
local method="$1" path="$2" body="${3:-}"
local ts sig
ts=$(date +%s000) # milliseconds, not seconds
sig=$(printf '%s:%s' "$PAYDIRECT_RAW_KEY" "$ts" \
| openssl dgst -sha256 -hmac "$PAYDIRECT_SECRET_KEY" -hex \
| awk '{print $2}')
curl -sS -X "$method" "$PAYDIRECT_HOST$path" \
-H "Content-Type: application/json" \
-H "x-api-key-id: $PAYDIRECT_PUBLIC_KEY" \
-H "x-api-timestamp: $ts" \
-H "x-api-signature: $sig" \
${body:+-H "Idempotency-Key: $(uuidgen)"} \
${body:+-d "$body"}
}
Node.js
// paydirect.js - Node 18+, no dependencies
const crypto = require('crypto');
function signedHeaders() {
const timestamp = Date.now().toString(); // milliseconds
const signature = crypto
.createHmac('sha256', process.env.PAYDIRECT_SECRET_KEY)
.update(`${process.env.PAYDIRECT_RAW_KEY}:${timestamp}`)
.digest('hex');
return {
'Content-Type': 'application/json',
'x-api-key-id': process.env.PAYDIRECT_PUBLIC_KEY,
'x-api-timestamp': timestamp,
'x-api-signature': signature,
};
}
async function request(method, path, body, idempotencyKey) {
const headers = signedHeaders();
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
const response = await fetch(`${process.env.PAYDIRECT_HOST}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, body: await response.json() };
}
const payDirect = {
get: path => request('GET', path),
post: (path, body, idempotencyKey) => request('POST', path, body, idempotencyKey),
patch: (path, body) => request('PATCH', path, body),
del: path => request('DELETE', path),
};
module.exports = { payDirect };
Python
# paydirect.py - requires `requests`
import hashlib, hmac, os, time, uuid
import requests
HOST = os.environ["PAYDIRECT_HOST"]
def _signed_headers() -> dict:
timestamp = str(int(time.time() * 1000)) # milliseconds, not seconds
signature = hmac.new(
os.environ["PAYDIRECT_SECRET_KEY"].encode(),
f'{os.environ["PAYDIRECT_RAW_KEY"]}:{timestamp}'.encode(),
hashlib.sha256,
).hexdigest()
return {
"Content-Type": "application/json",
"x-api-key-id": os.environ["PAYDIRECT_PUBLIC_KEY"],
"x-api-timestamp": timestamp,
"x-api-signature": signature,
}
def request(method: str, path: str, body: dict | None = None, idempotency_key: str | None = None):
headers = _signed_headers()
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
response = requests.request(method, f"{HOST}{path}", json=body, headers=headers, timeout=30)
return response.status_code, response.json()
def get(path: str):
return request("GET", path)
def post(path: str, body: dict, idempotency_key: str | None = None):
return request("POST", path, body, idempotency_key or str(uuid.uuid4()))
def patch(path: str, body: dict):
return request("PATCH", path, body)
def delete(path: str):
return request("DELETE", path)
PHP
<?php
// PayDirect.php - requires ext-curl, no packages
final class PayDirect
{
public function get(string $path): array
{
return $this->request('GET', $path);
}
public function post(string $path, array $body, ?string $idempotencyKey = null): array
{
return $this->request('POST', $path, $body, $idempotencyKey ?? bin2hex(random_bytes(16)));
}
public function patch(string $path, array $body): array
{
return $this->request('PATCH', $path, $body);
}
public function delete(string $path): array
{
return $this->request('DELETE', $path);
}
private function signedHeaders(): array
{
$timestamp = (string) round(microtime(true) * 1000); // milliseconds
$signature = hash_hmac(
'sha256',
getenv('PAYDIRECT_RAW_KEY') . ':' . $timestamp,
getenv('PAYDIRECT_SECRET_KEY')
);
return [
'Content-Type: application/json',
'x-api-key-id: ' . getenv('PAYDIRECT_PUBLIC_KEY'),
'x-api-timestamp: ' . $timestamp,
'x-api-signature: ' . $signature,
];
}
private function request(string $method, string $path, ?array $body = null, ?string $key = null): array
{
$headers = $this->signedHeaders();
if ($key !== null) {
$headers[] = 'Idempotency-Key: ' . $key;
}
$ch = curl_init(getenv('PAYDIRECT_HOST') . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode($raw, true)];
}
}
Java
// PayDirect.java - JDK 11+, no dependencies
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
public final class PayDirect {
private static final String HOST = System.getenv("PAYDIRECT_HOST");
private final HttpClient http = HttpClient.newHttpClient();
public HttpResponse<String> get(String path) throws Exception {
return request("GET", path, null, null);
}
public HttpResponse<String> post(String path, String jsonBody) throws Exception {
return request("POST", path, jsonBody, UUID.randomUUID().toString());
}
public HttpResponse<String> patch(String path, String jsonBody) throws Exception {
return request("PATCH", path, jsonBody, null);
}
public HttpResponse<String> delete(String path) throws Exception {
return request("DELETE", path, null, null);
}
private static String hmacHex(String message, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
StringBuilder hex = new StringBuilder();
for (byte b : mac.doFinal(message.getBytes(StandardCharsets.UTF_8))) {
hex.append(String.format("%02x", b)); // lowercase hex
}
return hex.toString();
}
private HttpResponse<String> request(String method, String path, String body, String key)
throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis()); // milliseconds
String signature = hmacHex(System.getenv("PAYDIRECT_RAW_KEY") + ":" + timestamp,
System.getenv("PAYDIRECT_SECRET_KEY"));
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(HOST + path))
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "application/json")
.header("x-api-key-id", System.getenv("PAYDIRECT_PUBLIC_KEY"))
.header("x-api-timestamp", timestamp)
.header("x-api-signature", signature)
.method(method, body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (key != null) {
builder.header("Idempotency-Key", key);
}
return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
}
C#
// PayDirect.cs - .NET 6+, no packages
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
public sealed class PayDirect
{
private static readonly HttpClient Http = new();
private static readonly string Host = Environment.GetEnvironmentVariable("PAYDIRECT_HOST")!;
public Task<HttpResponseMessage> GetAsync(string path) =>
SendAsync(HttpMethod.Get, path, null, null);
public Task<HttpResponseMessage> PostAsync(string path, object body, string? idempotencyKey = null) =>
SendAsync(HttpMethod.Post, path, body, idempotencyKey ?? Guid.NewGuid().ToString());
public Task<HttpResponseMessage> PatchAsync(string path, object body) =>
SendAsync(HttpMethod.Patch, path, body, null);
public Task<HttpResponseMessage> DeleteAsync(string path) =>
SendAsync(HttpMethod.Delete, path, null, null);
private static string HmacHex(string message, string secret)
{
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(message));
return Convert.ToHexString(hash).ToLowerInvariant(); // lowercase hex
}
private static async Task<HttpResponseMessage> SendAsync(
HttpMethod method, string path, object? body, string? idempotencyKey)
{
// Milliseconds since the epoch, not seconds.
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
var signature = HmacHex(
$"{Environment.GetEnvironmentVariable("PAYDIRECT_RAW_KEY")}:{timestamp}",
Environment.GetEnvironmentVariable("PAYDIRECT_SECRET_KEY")!);
using var request = new HttpRequestMessage(method, Host + path);
request.Headers.Add("x-api-key-id", Environment.GetEnvironmentVariable("PAYDIRECT_PUBLIC_KEY"));
request.Headers.Add("x-api-timestamp", timestamp);
request.Headers.Add("x-api-signature", signature);
if (idempotencyKey is not null)
{
request.Headers.Add("Idempotency-Key", idempotencyKey);
}
if (body is not null)
{
request.Content = JsonContent.Create(body);
}
return await Http.SendAsync(request);
}
}
cURL
paydirect PATCH /integrators/$INTEGRATOR_ID/webhook-url \
'{ "webhookUrl": "https://yourapp.com/hooks/paydirect" }'
Node.js
await payDirect.patch(`/integrators/${integratorId}/webhook-url`, {
webhookUrl: 'https://yourapp.com/hooks/paydirect', // https only
});
Python
status, body = patch(f"/integrators/{integrator_id}/webhook-url", {
"webhookUrl": "https://yourapp.com/hooks/paydirect", # https only
})
PHP
<?php
(new PayDirect())->patch("/integrators/{$integratorId}/webhook-url", [
'webhookUrl' => 'https://yourapp.com/hooks/paydirect', // https only
]);
Java
new PayDirect().patch("/integrators/" + integratorId + "/webhook-url",
"""
{ "webhookUrl": "https://yourapp.com/hooks/paydirect" }
""");
C#
await new PayDirect().PatchAsync(
$"/integrators/{integratorId}/webhook-url",
new { webhookUrl = "https://yourapp.com/hooks/paydirect" }); // https only
HTTPS only. You also need a signing secret, so you can verify deliveries really came from us. PayDirect issues it with your credentials, and it is shown once. Store it in your secret manager. To have it replaced, for example after a suspected leak, contact support; the new secret is likewise shown once.
Setting the webhook URL needs a dashboard session or a key that has not been narrowed - see API keys.
Caution
One webhook URL serves both TEST and LIVE. There is no per-environment split. Every payload carries an
environmentfield and your receiver must branch on it - otherwise a sandbox payment will look exactly like a real one.
Events
| Event | Fires when |
|---|---|
TRANSACTION_CREATED |
A transaction is created |
TRANSACTION_SUCCEEDED |
A transaction resolves SUCCESS |
TRANSACTION_FAILED |
A transaction resolves FAILED |
TRANSACTION_COMPENSATION_REQUIRED |
A multi-leg journey needs compensating (e.g. collected but could not settle) |
TRANSACTION_COMPENSATION_COMPLETED |
Compensation finished |
PAYOUT_SUCCEEDED |
A payout resolved SUCCESS |
PAYOUT_FAILED |
A payout resolved FAILED |
CHECKOUT_SESSION_PAID |
A hosted checkout session's transaction resolved SUCCESS |
SUBACCOUNT_SETTLEMENT_SUCCEEDED |
A payout of a subaccount's balance resolved SUCCESS |
SUBACCOUNT_SETTLEMENT_FAILED |
A payout of a subaccount's balance resolved FAILED - the amount is back on the subaccount's balance |
For a split collection the payload also carries a split
object with each party's share, and a subaccount settlement's payload carries the
subaccountCode that was paid.
Note
CHECKOUT_SESSION_PAIDfires in addition to the underlyingTRANSACTION_SUCCEEDED, not instead of it. A single paid checkout produces two events. Write your handler so processing both is harmless.
Payload
{
"eventId": "f0c6a0e0-3a4e-4a1f-9c58-2e3f1d0a7b21",
"event": "PAYOUT_SUCCEEDED",
"transactionId": "550e8400-e29b-41d4-a716-446655440002",
"transactionType": "PAYOUT",
"status": "SUCCESS",
"amountToSend": "100.00",
"sendingCurrency": "GHS",
"amountReceived": "100.00",
"receivingCurrency": "GHS",
"totalDebit": "100.50",
"charges": "0.50",
"commission": "0.00",
"invoiceOrAccountNumber": "payout-aug-0417",
"environment": "LIVE",
"occurredAt": "2026-08-19T20:14:03.118Z"
}
| Field | Notes |
|---|---|
eventId |
Your dedupe key. Unique per event, stable across retries of the same event. |
event |
One of the events above |
transactionId |
Our id - use it to look the transaction up |
status |
PENDING, SUCCESS or FAILED |
amountToSend |
The principal. Always the same value you submitted. |
totalDebit, charges |
What was actually debited, and the charge inside it |
commission |
Your own commission on it ("0.00" when none). Separate from charges; when the sender bears the charge, totalDebit = amountToSend + charges + commission. |
invoiceOrAccountNumber |
Your reference - lets you correlate without knowing our id |
environment |
TEST or LIVE. Branch on this. |
occurredAt |
ISO 8601 |
Payloads can carry further fields not listed here. Treat any unlisted field as informational: it may change or disappear without notice, so do not branch on it.
Amounts arrive as strings, to survive JSON without float rounding. Parse them into a decimal type, not a float.
Tip
invoiceOrAccountNumberis what makes hosted checkout reconcilable. When a payer pays a link, the transaction did not exist when you created the session - so you could not have stored our transaction id. Your own reference travels through and comes back here.
Ordering
Webhooks are queued when the transaction changes state and delivered after that change is
saved, so normally your API call returns first. Do not depend on it, though: a slow network on
your side can let a webhook arrive while your request is still waiting for its response. Make
your handler work in either order - look the transaction up by invoiceOrAccountNumber or
transactionId, and create your record if it is not there yet.
Beyond that, do not assume ordering between different events. A retried TRANSACTION_CREATED can
land after TRANSACTION_SUCCEEDED. Treat each event as a statement about state at occurredAt,
and let the transaction's current status - not the arrival order - be your authority.
Writing the receiver
Node.js
app.post('/hooks/paydirect', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify before parsing. See the signature-verification guide.
if (!verifySignature(req.headers['x-umopay-signature'], req.body)) {
return res.status(400).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// 2. Ignore the environment you are not in.
if (event.environment !== EXPECTED_ENVIRONMENT) return res.status(200).end();
// 3. Dedupe on eventId - delivery is at-least-once.
if (!(await claimEvent(event.eventId))) return res.status(200).end();
// 4. Acknowledge FAST, then do the work out of band.
res.status(200).end();
await queue.enqueue(event);
});
Python
@app.post("/hooks/paydirect")
def hook():
raw = request.get_data() # raw bytes, not request.json
# 1. Verify before parsing. See the signature-verification guide.
if not verify(request.headers.get("X-UmoPay-Signature"), raw, SECRET):
return "", 400
event = json.loads(raw)
# 2. Ignore the environment you are not in.
if event["environment"] != EXPECTED_ENVIRONMENT:
return "", 200
# 3. Dedupe on eventId - delivery is at-least-once.
if not claim_event(event["eventId"]):
return "", 200
# 4. Acknowledge FAST, then do the work out of band.
queue.enqueue(event)
return "", 200
PHP
<?php
$raw = file_get_contents('php://input'); // raw body, not $_POST
// 1. Verify before parsing. See the signature-verification guide.
if (!verifyWebhook($_SERVER['HTTP_X_UMOPAY_SIGNATURE'] ?? null, $raw, getenv('PAYDIRECT_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// 2. Ignore the environment you are not in.
if ($event['environment'] !== EXPECTED_ENVIRONMENT) {
http_response_code(200);
exit;
}
// 3. Dedupe on eventId - delivery is at-least-once.
if (!claimEvent($event['eventId'])) {
http_response_code(200);
exit;
}
// 4. Acknowledge FAST, then do the work out of band.
http_response_code(200);
fastcgi_finish_request(); // flush the 200 before the slow work starts
$queue->enqueue($event);
Java
@PostMapping(value = "/hooks/paydirect", consumes = MediaType.ALL_VALUE)
ResponseEntity<Void> hook(@RequestBody byte[] rawBody,
@RequestHeader(value = "X-UmoPay-Signature", required = false) String sig)
throws Exception {
// 1. Verify before parsing. See the signature-verification guide.
if (!WebhookVerifier.verify(sig, rawBody, secret)) {
return ResponseEntity.badRequest().build();
}
Event event = mapper.readValue(rawBody, Event.class);
// 2. Ignore the environment you are not in.
if (!event.environment().equals(expectedEnvironment)) return ResponseEntity.ok().build();
// 3. Dedupe on eventId - delivery is at-least-once.
if (!claimEvent(event.eventId())) return ResponseEntity.ok().build();
// 4. Acknowledge FAST, then do the work out of band.
queue.enqueue(event);
return ResponseEntity.ok().build();
}
C#
app.MapPost("/hooks/paydirect", async (HttpRequest req) =>
{
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
var raw = ms.ToArray(); // raw bytes, not a model-bound DTO
// 1. Verify before parsing. See the signature-verification guide.
if (!WebhookVerifier.Verify(req.Headers["X-UmoPay-Signature"], raw, Secret))
return Results.BadRequest();
var evt = JsonSerializer.Deserialize<PayDirectEvent>(raw)!;
// 2. Ignore the environment you are not in.
if (evt.Environment != ExpectedEnvironment) return Results.Ok();
// 3. Dedupe on eventId - delivery is at-least-once.
if (!await ClaimEventAsync(evt.EventId)) return Results.Ok();
// 4. Acknowledge FAST, then do the work out of band.
await queue.EnqueueAsync(evt);
return Results.Ok();
});
Four rules, in order of how often each one is the thing that breaks:
- Verify the signature before you parse or trust anything.
- Branch on
environment. - Dedupe on
eventId. Delivery is at-least-once; the same event will arrive twice eventually. - Return
2xxquickly - within a couple of seconds - and do the real work asynchronously. Anything non-2xx(or a timeout) is treated as a failed delivery and retried.
Warning
Never fulfil an order from the webhook payload alone without checking it against your own record of what was owed. Verify the signature, then treat the payload as a notification that something changed - read the amount you expect from your own database, or re-read the transaction with
GET /transactions/find/{id}.
If you have no signing secret
If a signing secret has not been configured, no signature header is sent at all - not an invalid one. A receiver that only rejects mismatched signatures, rather than also rejecting missing ones, would accept anything. Rotate a secret before you rely on verification, and reject unsigned deliveries outright.
Next
Verifying signatures - the exact algorithm, with code.