Webhooks & fulfilment
Your app tells the customer what happened. Your server decides what to ship. This page covers the two signed messages your server receives, the webhook and the return URL, and how to verify each one.
On this page
Why fulfil on the webhook
After the customer pays, three different signals reach you, in roughly this order:
| Signal | Arrives at | Trust it for |
|---|---|---|
| The SDK result | Your app | Updating the screen. It is read from the API, so it is accurate, but it lives on a device you do not control. |
| The return URL | Your app, or your website | A fast, signed hint that checkout finished. Verifiable on your server, but delivered through a browser that can be closed at any moment. |
| The webhook | Your server | Shipping the goods. Signed with your secret key, delivered server to server, unaffected by anything the customer does with their phone. |
A phone can be rooted, an app can be patched, a request from it can be replayed. None of that can forge an HMAC computed with a key that only your server and YallaPay hold.
The webhook payload
One POST per payment, sent to the endpoint you configured under Webhooks, or to the endpoint whose store_id you passed when creating the session.
POST /webhooks/yallapay HTTP/1.1
Content-Type: application/json
X-YallaPay-Signature: t=1755950400,v1=5f2a9c1d7e3b4a6f8c0d2e1f3a5b7c9d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b
{
"transaction_id": "07071234567890123456",
"external_id": "881",
"amount": "250.00",
"net_amount": "244.25",
"currency": "AED",
"status": 1,
"payment_method": "card",
"payment_date": "2026-08-27 13:24:11",
"webhook_secret": "…"
}
| transaction_id | YallaPay's reference for the payment. The same value the SDK returns as the transaction id, and the same one shown in your Payment Reports. |
| external_id | Your order id, exactly as you sent it when creating the session. Use this to find the order. |
| amount | What the customer paid, as a string with two decimals. |
| net_amount | What you receive after fees, as a string with two decimals. |
| currency | Three-letter code. |
| status | 1 paid, 0 failed. Fulfil only on 1. |
| payment_method | How the customer paid, as a label. |
| payment_date | YYYY-MM-DD HH:MM:SS in the account timezone. |
| webhook_secret | Deprecated. Present for older integrations only; it authenticates nothing. Verify the signature header instead. |
| test | true only on payloads sent by the Send test button. Absent on real payments. |
"250.00" rather than 250, so that two decimal places survive the trip. Compare them as decimals, not as floats, and never derive the amount to fulfil from anything other than your own order record.
Verifying the signature
The X-YallaPay-Signature header has two parts: the Unix timestamp the message was signed at, and an HMAC-SHA256 over the timestamp and the exact request body, keyed with your secret key.
X-YallaPay-Signature: t=1755950400,v1=<hex hmac> signed_payload = t + "." + raw_request_body v1 = HMAC_SHA256(key = your secret key, message = signed_payload), hex encoded
To verify:
- Read the raw request body as bytes, before any JSON parsing.
- Split the header on commas, then each part on the first
=, to gettandv1. - Reject if
tis more than 5 minutes from now. This blocks replays of an old, genuine message. - Compute HMAC-SHA256 over
t + "." + bodywith your secret key, hex encoded. - Compare with
v1using a constant-time comparison.
function verifyYallaPaySignature(string $header, string $rawBody, string $secretKey): bool
{
$parts = [];
foreach (explode(',', $header) as $segment) {
[$k, $v] = array_pad(explode('=', $segment, 2), 2, '');
$parts[trim($k)] = trim($v);
}
$t = (int) ($parts['t'] ?? 0);
$v1 = $parts['v1'] ?? '';
if ($t <= 0 || $v1 === '' || abs(time() - $t) > 300) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secretKey);
return hash_equals($expected, $v1);
}
// Laravel
$rawBody = $request->getContent();
$ok = verifyYallaPaySignature($request->header('X-YallaPay-Signature', ''), $rawBody, env('YALLAPAY_SECRET_KEY'));
import crypto from 'node:crypto';
export function verifyYallaPaySignature(header, rawBody, secretKey) {
const parts = Object.fromEntries(
header.split(',').map(s => { const i = s.indexOf('='); return [s.slice(0, i).trim(), s.slice(i + 1).trim()]; })
);
const t = Number(parts.t || 0);
const v1 = parts.v1 || '';
if (!t || !v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secretKey).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: keep the raw body — do not let a JSON middleware re-serialise it
app.post('/webhooks/yallapay', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyYallaPaySignature(req.get('X-YallaPay-Signature') || '', raw, process.env.YALLAPAY_SECRET_KEY)) {
return res.status(401).end();
}
const event = JSON.parse(raw);
// ...
res.status(200).end();
});
import hmac, hashlib, time
def verify_yallapay_signature(header: str, raw_body: bytes, secret_key: str) -> bool:
parts = dict(s.strip().split("=", 1) for s in header.split(",") if "=" in s)
try:
t = int(parts.get("t", "0"))
except ValueError:
return False
v1 = parts.get("v1", "")
if t <= 0 or not v1 or abs(time.time() - t) > 300:
return False
msg = f"{t}.".encode() + raw_body
expected = hmac.new(secret_key.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask
@app.post("/webhooks/yallapay")
def yallapay_webhook():
if not verify_yallapay_signature(request.headers.get("X-YallaPay-Signature", ""), request.get_data(), os.environ["YALLAPAY_SECRET_KEY"]):
abort(401)
event = request.get_json(force=True)
# ...
return "", 200
The signature covers the exact bytes YallaPay sent. If your framework parses the JSON and you re-serialise it before hashing, key order or escaping can change and the check fails over a difference you cannot see. Read the body before your JSON middleware touches it.
Writing the handler
A good webhook handler is boring. It does exactly this, in this order:
- Verify the signature. Respond 401 and stop if it fails. Log the attempt.
- Parse the body and find the order by
external_id. If it is a test payload ("test": true) or an order you do not recognise, respond 200 and stop. - Check
status. Only 1 means paid. - Be idempotent. If the order is already marked paid with this
transaction_id, respond 200 and stop. A webhook can be delivered more than once and your handler must not ship twice. - Check the amount and currency against the order. A mismatch is a bug worth alerting on, not something to fulfil.
- Mark the order paid with the transaction id, and enqueue fulfilment (email, shipping, provisioning) for a background job.
- Respond 200 quickly. YallaPay waits at most 20 seconds. Anything slow belongs in the queue.
// Laravel, sketched public function __invoke(Request $request) { abort_unless(verifyYallaPaySignature($request->header('X-YallaPay-Signature', ''), $request->getContent(), config('services.yallapay.secret')), 401); $event = $request->json()->all(); if (!empty($event['test'])) return response()->noContent(200); $order = Order::where('id', $event['external_id'])->first(); if (!$order || (int) $event['status'] !== 1) return response()->noContent(200); DB::transaction(function () use ($order, $event) { $order = Order::lockForUpdate()->find($order->id); if ($order->paid_at) return; // already handled if (bccomp($order->total, $event['amount'], 2) !== 0) { // alert, don't fulfil report(new \RuntimeException("Amount mismatch on order {$order->id}")); return; } $order->forceFill(['paid_at' => now(), 'transaction_id' => $event['transaction_id']])->save(); FulfilOrder::dispatch($order); }); return response()->noContent(200); }
Delivery, retries and testing
- When: immediately after the payment is confirmed, once per payment.
- Timeouts: 5 seconds to connect, 20 seconds for a response.
- Retries: a delivery that fails is logged and can be resent by YallaPay support. Do not rely on automatic retries; run the reconciliation job described below so a missed webhook is caught within minutes.
- Logs: every attempt, its payload and your response are visible under Webhooks in your dashboard.
- Testing: the Send test button under Webhooks delivers a signed sample payload with
"test": trueto your endpoint right away. Use it to prove your signature check before a real payment.
Which endpoint receives a payment's webhook: the endpoint whose store id was passed as store_id when the session was created, if any; otherwise your most recently saved active endpoint.
The return URL
When checkout finishes, the customer is sent to the return_url you gave the session, with the outcome appended as query parameters. For an app, that URL is the deep link your SDK is listening for:
myapp://yallapay/return ?status=success &trxid=07071234567890123456 &session_id=cs_live_9fK3rM2pQ7xTb4Lw8nHv1sZc6Yd0JeAq &external_id=881 &payment_method=card &getway=… &signature=3c9e…
| status | success or failed. |
| trxid | The transaction id, when there is one. |
| session_id | The session this return belongs to. Match it to the session you created rather than trusting a transaction id you have never seen. |
| external_id | Your order id. |
| payment_method | card, apple_pay or google_pay. |
| getway | Deprecated. Ignore it; it will be removed. |
| signature | HMAC over the other parameters. See below. |
The SDKs read none of these values. They treat the deep link purely as "checkout is over" and ask the API for the truth. A website integration that wants to show a result page before its webhook arrives may verify the signature on its server and trust the parameters.
Anyone can type a URL with status=success into a browser. Never mark an order paid from return-URL parameters without verifying the signature on your server, and even then, ship on the webhook.
Verifying a return URL
The redirect signature is different from the webhook one: there is no timestamp, and the message is built from the parameters themselves.
- Take every query parameter except
signature. Drop any that are empty. - Sort them by name, ascending.
- Join them as
name=value&name=value, with the values URL-decoded, exactly as they were before encoding. - Compute HMAC-SHA256 over that string with your secret key, without its
sk_live_orsk_test_prefix, hex encoded. - Compare with
signaturein constant time.
Webhooks are signed with the full secret key. Redirects are signed with the key material after the prefix: the 50 characters following sk_live_. Parameters are sorted because browsers and proxies do not preserve query order.
function verifyYallaPayReturn(array $query, string $secretKey): bool
{
$presented = $query['signature'] ?? '';
unset($query['signature']);
$query = array_filter($query, fn ($v) => $v !== null && $v !== '');
ksort($query);
$keyMaterial = preg_replace('/^sk_(live|test)_/', '', $secretKey);
$expected = hash_hmac('sha256', urldecode(http_build_query($query)), $keyMaterial);
return $presented !== '' && hash_equals($expected, $presented);
}
import crypto from 'node:crypto';
export function verifyYallaPayReturn(query, secretKey) {
const { signature = '', ...rest } = query;
const payload = Object.keys(rest)
.filter(k => rest[k] !== undefined && rest[k] !== null && rest[k] !== '')
.sort()
.map(k => `${k}=${rest[k]}`)
.join('&');
const keyMaterial = secretKey.replace(/^sk_(live|test)_/, '');
const expected = crypto.createHmac('sha256', keyMaterial).update(payload).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(signature);
return signature !== '' && a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hmac, hashlib, re
def verify_yallapay_return(query: dict, secret_key: str) -> bool:
presented = query.get("signature", "")
rest = {k: v for k, v in query.items() if k != "signature" and v not in (None, "")}
payload = "&".join(f"{k}={rest[k]}" for k in sorted(rest))
key_material = re.sub(r"^sk_(live|test)_", "", secret_key)
expected = hmac.new(key_material.encode(), payload.encode(), hashlib.sha256).hexdigest()
return bool(presented) and hmac.compare_digest(expected, presented)
Reconciliation
Webhooks are reliable, not infallible. Your endpoint might be down for a deploy, or a network hiccup might drop one delivery. A small job closes that gap:
// every few minutes orders where session_id is set and paid_at is null and created_at > now() - 1 day: session = GET /api/v1/sessions/{session_id} (secret key) if session.status == "paid": mark paid with session.transaction_id, enqueue fulfilment if session.status == "expired": mark the order as abandoned
Because the read endpoint returns the same facts the webhook carries, a reconciled order is exactly as trustworthy as a webhooked one. Run the job, and a missed webhook becomes a delay of minutes rather than a lost order.
Next: Sandbox & go-live to test the whole flow end to end.