Appearance
Webhooks
Payd posts an event to the callback_url you set on a payment each time the payment succeeds, fails or settles. Every webhook is signed, so you can trust it once you check the signature.
Set the URL
Put the URL in payment_config.callback_url on each payin or payout:
json
"payment_config": {
"callback_url": "https://example.com/webhooks/payd",
"reference": "order-8842"
}There is no account-wide webhook setting. A payment sent without a callback_url gets no webhook. Use an HTTPS URL.
Events
event | When | status |
|---|---|---|
payin.succeeded | The customer paid. | success |
payin.failed | The collection failed, expired or the customer cancelled. | failed |
payin.settled | Card payments only: Payd released the money to your balance. | settled |
payout.succeeded | The payout reached the recipient. | success |
payout.failed | The network refused the payout. The amount is back in your balance. | failed |
Each payment gets at most one succeeded or failed event. A card payment gets payin.succeeded first and payin.settled later. See how long payments take.
What Payd sends
Payd sends a POST with a JSON body, Content-Type: application/json and an X-Payd-Signature header:
json
{
"event": "payout.succeeded",
"success": true,
"status": "success",
"message": "transaction processed successfully",
"reference": "SID131831221eW",
"external_reference": "payout-1042",
"amount": 2500,
"currency": "KES",
"transaction_type": "withdrawal",
"transaction_date": "2026-10-16T11:42:03Z",
"transaction_id": "AG_20261016_0102b3c4d5e6",
"provider_reference": "UJG8K2L4QX",
"callback_urls": ["https://example.com/webhooks/payd"]
}| Field | Meaning |
|---|---|
event | What happened. See events. |
status | success, settled (cards only) or failed. |
success | false only when status is failed. |
reference | Payd's reference for the payment, the merchant_reference you stored. |
external_reference | The payment_config.reference you sent. |
amount, currency | The amount of the payment, in the currency you sent. |
transaction_type | receipt for a collection, withdrawal for a payout. |
transaction_date | When you created the payment, not when it finished. |
transaction_id | The network's id for the payment. |
provider_reference | The receipt number from the network, such as the M-Pesa confirmation code. Empty when the network gives none. Show it to customers who ask for proof of payment. |
message | Text about the result. Fine for your logs. Do not parse it. |
The body has other fields. Ignore them.
The older format
Integrations built on the older M-Pesa collection endpoint can also receive this body, sometimes right after the one above, at the same URL. It is signed the same way.
json
{
"transaction_reference": "9BD103350408eR",
"result_code": 0,
"remarks": "transaction processed successfully",
"third_party_trans_id": "UJG8K2L4QX",
"amount": 1500,
"phone_number": "254712345678",
"channel": "momo",
"forward_url": "https://example.com/webhooks/payd",
"order_id": "",
"success": true,
"status": "success"
}result_code is 0 for success and 1 for failure. Tell the two formats apart by their keys: the current one has reference, the older one has transaction_reference. Accept both and treat them as the same event.
Verify the signature
The X-Payd-Signature header looks like this:
X-Payd-Signature: t=1760614923,v1=5d1b3f0c9a7e...tis when Payd signed the webhook, in Unix seconds.v1is the HMAC-SHA256 oft, a full stop, and the exact body bytes, keyed with your whole webhook secret (includingwhsec_), in hex.- After you rotate the secret, the header carries two
v1values for 24 hours: one for each secret. Accept the webhook if either matches.
To check a webhook:
- Read the raw body before any JSON parsing. Parsing and re-encoding changes the bytes.
- Compute
HMAC-SHA256(secret, t + "." + raw_body)and compare it with eachv1value using a constant-time compare. - Reject the webhook if
tis more than 5 minutes old, so an old webhook cannot be replayed. - Answer
2xxquickly, then do the work in the background.
js
import crypto from 'node:crypto'
import express from 'express'
const app = express()
export function verifyPayd(rawBody, header, secret, toleranceSeconds = 300) {
if (!header) return false
const parts = header.split(',').map((part) => part.trim().split('='))
const t = parts.find(([key]) => key === 't')?.[1]
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value)
if (!t || !signatures.length) return false
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex')
return signatures.some(
(s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
)
}
// express.raw keeps the exact bytes Payd signed
app.post('/webhooks/payd', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyPayd(req.body, req.get('X-Payd-Signature'), process.env.PAYD_WEBHOOK_SECRET)) {
return res.sendStatus(400)
}
res.sendStatus(200)
const event = JSON.parse(req.body.toString('utf8'))
queueEvent(event) // handle it in the background
})
app.listen(3000)python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
def verify_payd(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
parts = [p.strip().split("=", 1) for p in header.split(",")]
parts = [p for p in parts if len(p) == 2]
t = next((v for k, v in parts if k == "t"), None)
signatures = [v for k, v in parts if k == "v1"]
if not t or not signatures or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)
@app.post("/webhooks/payd")
def payd_webhook():
if not verify_payd(request.get_data(), request.headers.get("X-Payd-Signature"), os.environ["PAYD_WEBHOOK_SECRET"]):
abort(400)
queue_event(request.get_json()) # handle it in the background
return "", 200php
<?php
function verify_payd(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
if (!$header) {
return false;
}
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') $t = $value;
if ($key === 'v1') $signatures[] = $value;
}
if (!$t || !$signatures || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) return true;
}
return false;
}
$rawBody = file_get_contents('php://input');
if (!verify_payd($rawBody, $_SERVER['HTTP_X_PAYD_SIGNATURE'] ?? null, getenv('PAYD_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
http_response_code(200);
queue_event(json_decode($rawBody, true)); // handle it in the backgroundGet your webhook secret
GET
https://api.payd.money/api/v2/webhooks/secretbash
curl https://api.payd.money/api/v2/webhooks/secret \
-u "$PAYD_API_USERNAME:$PAYD_API_PASSWORD"json
{
"secret": "whsec_4kq9X2v7...",
"created_at": "2026-10-16T11:42:03Z",
"previous_secret_expires_at": ""
}There is one secret per Payd account. Payd creates it the first time you ask for it or the first time it sends you a webhook. Keep it with your other secrets, never in client code.
Rotate the secret
POST
https://api.payd.money/api/v2/webhooks/secret/rotatebash
curl -X POST https://api.payd.money/api/v2/webhooks/secret/rotate \
-u "$PAYD_API_USERNAME:$PAYD_API_PASSWORD" \
-H "Content-Type: application/json" \
-d '{}'The response has the new secret. The old one keeps signing alongside it until previous_secret_expires_at, 24 hours later, so you have time to deploy the new one. You can rotate again only after that time: before it, Payd answers 400 invalid_argument with the time the previous secret stops signing.
If a secret leaks, rotate with revoke_previous set to true. Payd makes a new secret and every earlier one stops signing at once, so deploy the new secret straight away. This works at any time, even within 24 hours of the last rotation:
bash
curl -X POST https://api.payd.money/api/v2/webhooks/secret/rotate \
-u "$PAYD_API_USERNAME:$PAYD_API_PASSWORD" \
-H "Content-Type: application/json" \
-d '{"revoke_previous": true}'Handle webhooks safely
- Check the signature on every webhook. Answer
400when it does not match. - Answer
2xxwithin 20 seconds. Do slow work after you answer. - Make your handler safe to run twice. Store each
referenceandeventpair you have handled, and skip repeats. - Webhooks can arrive more than once and out of order. A card payment's
payin.settledcan arrive while you are still handling itspayin.succeeded. - For high-value orders, you can also confirm with the status endpoint before you ship.
Delivery and retries
- Payd waits up to 20 seconds for your answer. Any
2xxcounts as delivered. - If your endpoint times out, cannot be reached or answers
5xx, Payd tries twice more within a few seconds. - If it still has no
2xx, including after a4xx, Payd tries again 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours after the first failure, then stops. - Each retry has a new
tand signature. - Run a job that checks the status of any payment still
pendingorprocessingafter an hour, in case your endpoint was down for longer.
Test your endpoint
While you build, point callback_url at a request inspector such as webhook.site, or expose your local server with a tunnel, and send a small real payment. Check your signature code against the header you receive.

